From d2473033ef3b20fac551363ae6d298a322a68457 Mon Sep 17 00:00:00 2001 From: Kyle Delaney Date: Thu, 24 Oct 2019 13:52:43 -0700 Subject: [PATCH 001/616] Apply conversation reference in TurnContext.update_activity (#358) * Create test_update_activity_should_apply_conversation_reference * Apply conversation reference in TurnContext.update_activity --- .../botbuilder/core/turn_context.py | 6 +++-- .../tests/test_turn_context.py | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 99d53996a..0155a992b 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -173,9 +173,11 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ + reference = TurnContext.get_conversation_reference(self.activity) + return await self._emit( self._on_update_activity, - activity, + TurnContext.apply_conversation_reference(activity, reference), self.adapter.update_activity(self, activity), ) @@ -240,7 +242,7 @@ async def next_handler(): raise error await emit_next(0) - # This should be changed to `return await logic()` + # logic does not use parentheses because it's a coroutine return await logic @staticmethod diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 8e7c6f407..2fe6bdcc5 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -10,7 +10,7 @@ Mention, ResourceResponse, ) -from botbuilder.core import BotAdapter, TurnContext +from botbuilder.core import BotAdapter, MessageFactory, TurnContext ACTIVITY = Activity( id="1234", @@ -40,11 +40,12 @@ async def send_activities(self, context, activities): async def update_activity(self, context, activity): assert context is not None assert activity is not None + return ResourceResponse(id=activity.id) async def delete_activity(self, context, reference): assert context is not None assert reference is not None - assert reference.activity_id == "1234" + assert reference.activity_id == ACTIVITY.id class TestBotContext(aiounittest.AsyncTestCase): @@ -225,6 +226,26 @@ async def update_handler(context, activity, next_handler_coroutine): await context.update_activity(ACTIVITY) assert called is True + async def test_update_activity_should_apply_conversation_reference(self): + activity_id = "activity ID" + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def update_handler(context, activity, next_handler_coroutine): + nonlocal called + called = True + assert context is not None + assert activity.id == activity_id + assert activity.conversation.id == ACTIVITY.conversation.id + await next_handler_coroutine() + + context.on_update_activity(update_handler) + new_activity = MessageFactory.text("test text") + new_activity.id = activity_id + update_result = await context.update_activity(new_activity) + assert called is True + assert update_result.id == activity_id + def test_get_conversation_reference_should_return_valid_reference(self): reference = TurnContext.get_conversation_reference(ACTIVITY) From 9119769b5f92e2ffdc4493d33c1093d01b8a3fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 24 Oct 2019 15:48:38 -0700 Subject: [PATCH 002/616] Added trace activity helper in turn context (#359) --- .../botbuilder/core/turn_context.py | 23 ++++++++++++++- .../tests/test_turn_context.py | 29 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 0155a992b..75bb278d3 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -3,8 +3,15 @@ import re from copy import copy +from datetime import datetime from typing import List, Callable, Union, Dict -from botbuilder.schema import Activity, ConversationReference, Mention, ResourceResponse +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + Mention, + ResourceResponse, +) class TurnContext: @@ -245,6 +252,20 @@ async def next_handler(): # logic does not use parentheses because it's a coroutine return await logic + async def send_trace_activity( + self, name: str, value: object, value_type: str, label: str + ) -> ResourceResponse: + trace_activity = Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name=name, + value=value, + value_type=value_type, + label=label, + ) + + return await self.send_activity(trace_activity) + @staticmethod def get_conversation_reference(activity: Activity) -> ConversationReference: """ diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 2fe6bdcc5..d39da5d20 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -1,10 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import Callable, List import aiounittest from botbuilder.schema import ( Activity, + ActivityTypes, ChannelAccount, ConversationAccount, Mention, @@ -33,7 +35,7 @@ async def send_activities(self, context, activities): assert activities for (idx, activity) in enumerate(activities): # pylint: disable=unused-variable assert isinstance(activity, Activity) - assert activity.type == "message" + assert activity.type == "message" or activity.type == ActivityTypes.trace responses.append(ResourceResponse(id="5678")) return responses @@ -319,3 +321,28 @@ def test_should_remove_at_mention_from_activity(self): assert text, " test activity" assert activity.text, " test activity" + + async def test_should_send_a_trace_activity(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + # pylint: disable=unused-argument + async def aux_func( + ctx: TurnContext, activities: List[Activity], next: Callable + ): + nonlocal called + called = True + assert isinstance(activities, list), "activities not array." + assert len(activities) == 1, "invalid count of activities." + assert activities[0].type == ActivityTypes.trace, "type wrong." + assert activities[0].name == "name-text", "name wrong." + assert activities[0].value == "value-text", "value worng." + assert activities[0].value_type == "valueType-text", "valeuType wrong." + assert activities[0].label == "label-text", "label wrong." + return [] + + context.on_send_activities(aux_func) + await context.send_trace_activity( + "name-text", "value-text", "valueType-text", "label-text" + ) + assert called From f352df51708f8ac6d82b023b0f7cb5a4451d28b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 24 Oct 2019 16:43:23 -0700 Subject: [PATCH 003/616] Merge master into 4.5.0b5 (#360) * Apply conversation reference in TurnContext.update_activity (#358) * Create test_update_activity_should_apply_conversation_reference * Apply conversation reference in TurnContext.update_activity * Added trace activity helper in turn context (#359) --- .../botbuilder/core/turn_context.py | 29 ++++++++-- .../tests/test_turn_context.py | 54 +++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 99d53996a..75bb278d3 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -3,8 +3,15 @@ import re from copy import copy +from datetime import datetime from typing import List, Callable, Union, Dict -from botbuilder.schema import Activity, ConversationReference, Mention, ResourceResponse +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + Mention, + ResourceResponse, +) class TurnContext: @@ -173,9 +180,11 @@ async def update_activity(self, activity: Activity): :param activity: :return: """ + reference = TurnContext.get_conversation_reference(self.activity) + return await self._emit( self._on_update_activity, - activity, + TurnContext.apply_conversation_reference(activity, reference), self.adapter.update_activity(self, activity), ) @@ -240,9 +249,23 @@ async def next_handler(): raise error await emit_next(0) - # This should be changed to `return await logic()` + # logic does not use parentheses because it's a coroutine return await logic + async def send_trace_activity( + self, name: str, value: object, value_type: str, label: str + ) -> ResourceResponse: + trace_activity = Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name=name, + value=value, + value_type=value_type, + label=label, + ) + + return await self.send_activity(trace_activity) + @staticmethod def get_conversation_reference(activity: Activity) -> ConversationReference: """ diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 8e7c6f407..d39da5d20 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -1,16 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import Callable, List import aiounittest from botbuilder.schema import ( Activity, + ActivityTypes, ChannelAccount, ConversationAccount, Mention, ResourceResponse, ) -from botbuilder.core import BotAdapter, TurnContext +from botbuilder.core import BotAdapter, MessageFactory, TurnContext ACTIVITY = Activity( id="1234", @@ -33,18 +35,19 @@ async def send_activities(self, context, activities): assert activities for (idx, activity) in enumerate(activities): # pylint: disable=unused-variable assert isinstance(activity, Activity) - assert activity.type == "message" + assert activity.type == "message" or activity.type == ActivityTypes.trace responses.append(ResourceResponse(id="5678")) return responses async def update_activity(self, context, activity): assert context is not None assert activity is not None + return ResourceResponse(id=activity.id) async def delete_activity(self, context, reference): assert context is not None assert reference is not None - assert reference.activity_id == "1234" + assert reference.activity_id == ACTIVITY.id class TestBotContext(aiounittest.AsyncTestCase): @@ -225,6 +228,26 @@ async def update_handler(context, activity, next_handler_coroutine): await context.update_activity(ACTIVITY) assert called is True + async def test_update_activity_should_apply_conversation_reference(self): + activity_id = "activity ID" + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def update_handler(context, activity, next_handler_coroutine): + nonlocal called + called = True + assert context is not None + assert activity.id == activity_id + assert activity.conversation.id == ACTIVITY.conversation.id + await next_handler_coroutine() + + context.on_update_activity(update_handler) + new_activity = MessageFactory.text("test text") + new_activity.id = activity_id + update_result = await context.update_activity(new_activity) + assert called is True + assert update_result.id == activity_id + def test_get_conversation_reference_should_return_valid_reference(self): reference = TurnContext.get_conversation_reference(ACTIVITY) @@ -298,3 +321,28 @@ def test_should_remove_at_mention_from_activity(self): assert text, " test activity" assert activity.text, " test activity" + + async def test_should_send_a_trace_activity(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + # pylint: disable=unused-argument + async def aux_func( + ctx: TurnContext, activities: List[Activity], next: Callable + ): + nonlocal called + called = True + assert isinstance(activities, list), "activities not array." + assert len(activities) == 1, "invalid count of activities." + assert activities[0].type == ActivityTypes.trace, "type wrong." + assert activities[0].name == "name-text", "name wrong." + assert activities[0].value == "value-text", "value worng." + assert activities[0].value_type == "valueType-text", "valeuType wrong." + assert activities[0].label == "label-text", "label wrong." + return [] + + context.on_send_activities(aux_func) + await context.send_trace_activity( + "name-text", "value-text", "valueType-text", "label-text" + ) + assert called From 48b4f806f7e7c93cf059a1cb039361f3c22eb804 Mon Sep 17 00:00:00 2001 From: trojenguri Date: Fri, 25 Oct 2019 22:04:15 +0530 Subject: [PATCH 004/616] [QnAMaker] Active learning low score variation multiplier value (#361) Active learning low score variation multiplier value - PreviousLowScoreVariationMultiplier to 0.7 - MaxLowScoreVariationMultiplier to 1.0 --- .../botbuilder/ai/qna/utils/active_learning_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py index 4fc6c536f..9b438d4fc 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py @@ -7,8 +7,8 @@ from ..models import QueryResult MINIMUM_SCORE_FOR_LOW_SCORE_VARIATION = 20.0 -PREVIOUS_LOW_SCORE_VARIATION_MULTIPLIER = 1.4 -MAX_LOW_SCORE_VARIATION_MULTIPLIER = 2.0 +PREVIOUS_LOW_SCORE_VARIATION_MULTIPLIER = 0.7 +MAX_LOW_SCORE_VARIATION_MULTIPLIER = 1.0 MAX_SCORE_FOR_LOW_SCORE_VARIATION = 95.0 From 5d66369dbb3a3e47f02b8e7abb79d6e817556b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 25 Oct 2019 10:06:51 -0700 Subject: [PATCH 005/616] Fixing readme from botbuilder testing in order to publish (#362) --- libraries/botbuilder-testing/README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-testing/README.rst b/libraries/botbuilder-testing/README.rst index 76d0531a2..128ee8a8a 100644 --- a/libraries/botbuilder-testing/README.rst +++ b/libraries/botbuilder-testing/README.rst @@ -1,7 +1,7 @@ -============================ +================================= BotBuilder-Testing SDK for Python -============================ +================================= .. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI From 2a9cc2ec9a66f2b137c349e854556f6a7a286881 Mon Sep 17 00:00:00 2001 From: trojenguri Date: Fri, 25 Oct 2019 23:31:43 +0530 Subject: [PATCH 006/616] [QnAMaker] Active learning low score variation multiplier value (#361) (#363) Active learning low score variation multiplier value - PreviousLowScoreVariationMultiplier to 0.7 - MaxLowScoreVariationMultiplier to 1.0 --- .../botbuilder/ai/qna/utils/active_learning_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py index 4fc6c536f..9b438d4fc 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py @@ -7,8 +7,8 @@ from ..models import QueryResult MINIMUM_SCORE_FOR_LOW_SCORE_VARIATION = 20.0 -PREVIOUS_LOW_SCORE_VARIATION_MULTIPLIER = 1.4 -MAX_LOW_SCORE_VARIATION_MULTIPLIER = 2.0 +PREVIOUS_LOW_SCORE_VARIATION_MULTIPLIER = 0.7 +MAX_LOW_SCORE_VARIATION_MULTIPLIER = 1.0 MAX_SCORE_FOR_LOW_SCORE_VARIATION = 95.0 From 8dd9df9f5fcb762021dcecdd803d4b0b48bfbfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 25 Oct 2019 11:54:55 -0700 Subject: [PATCH 007/616] Fixing readme from botbuilder testing in order to publish --- libraries/botbuilder-testing/README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-testing/README.rst b/libraries/botbuilder-testing/README.rst index 76d0531a2..128ee8a8a 100644 --- a/libraries/botbuilder-testing/README.rst +++ b/libraries/botbuilder-testing/README.rst @@ -1,7 +1,7 @@ -============================ +================================= BotBuilder-Testing SDK for Python -============================ +================================= .. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI From eee74d9cc9448a339f80ea1222c92a060e62d3a3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 28 Oct 2019 13:17:31 -0700 Subject: [PATCH 008/616] Adding processor as exported package --- libraries/botbuilder-applicationinsights/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index b48bf73cf..71aba4f54 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -47,6 +47,7 @@ "botbuilder.applicationinsights", "botbuilder.applicationinsights.django", "botbuilder.applicationinsights.flask", + "botbuilder.applicationinsights.processor", ], install_requires=REQUIRES + TESTS_REQUIRES, tests_require=TESTS_REQUIRES, From 793f8e299f28223431dc9861bff4d61fe5a314be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 29 Oct 2019 09:42:20 -0700 Subject: [PATCH 009/616] Update black formatting (#377) * Black formatting updates --- .../botbuilder-ai/botbuilder/ai/luis/luis_util.py | 2 +- .../ai/qna/utils/active_learning_utils.py | 2 +- .../botbuilder-azure/tests/test_blob_storage.py | 10 +++++----- .../botbuilder-azure/tests/test_cosmos_storage.py | 12 ++++++------ .../botbuilder-azure/tests/test_key_validation.py | 2 +- .../botbuilder/core/inspection/trace_activity.py | 2 +- .../tests/test_auto_save_middleware.py | 2 +- .../botbuilder-core/tests/test_bot_adapter.py | 2 +- .../tests/test_inspection_middleware.py | 4 ++-- .../botbuilder-core/tests/test_memory_storage.py | 14 +++++++------- .../tests/test_show_typing_middleware.py | 2 +- .../botbuilder-core/tests/test_test_adapter.py | 2 +- .../botbuilder-core/tests/test_turn_context.py | 6 +++--- .../botbuilder/dialogs/choices/channel.py | 2 +- .../botbuilder/dialogs/prompts/confirm_prompt.py | 4 ++-- .../botbuilder-dialogs/tests/test_choice_prompt.py | 4 ++-- .../botbuilder-dialogs/tests/test_waterfall.py | 6 +++--- .../botframework-connector/tests/test_auth.py | 12 ++++++------ .../tests/test_conversations.py | 2 +- .../tests/test_conversations_async.py | 2 +- 20 files changed, 47 insertions(+), 47 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index d9ea75df8..9f620be34 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -299,7 +299,7 @@ def get_user_agent(): @staticmethod def recognizer_result_as_dict( - recognizer_result: RecognizerResult + recognizer_result: RecognizerResult, ) -> Dict[str, object]: # an internal method that returns a dict for json serialization. diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py index 9b438d4fc..625d77dbc 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/active_learning_utils.py @@ -17,7 +17,7 @@ class ActiveLearningUtils: @staticmethod def get_low_score_variation( - qna_search_results: List[QueryResult] + qna_search_results: List[QueryResult], ) -> List[QueryResult]: """ Returns a list of QnA search results, which have low score variation. diff --git a/libraries/botbuilder-azure/tests/test_blob_storage.py b/libraries/botbuilder-azure/tests/test_blob_storage.py index b29d2ad9f..40db0f61e 100644 --- a/libraries/botbuilder-azure/tests/test_blob_storage.py +++ b/libraries/botbuilder-azure/tests/test_blob_storage.py @@ -82,7 +82,7 @@ async def test_blob_storage_write_should_add_new_value(self): @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk( - self + self, ): storage = BlobStorage(BLOB_STORAGE_SETTINGS) await storage.write({"user": SimpleStoreItem()}) @@ -135,7 +135,7 @@ async def test_blob_storage_delete_should_delete_according_cached_data(self): @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_delete_should_delete_multiple_values_when_given_multiple_valid_keys( - self + self, ): storage = BlobStorage(BLOB_STORAGE_SETTINGS) await storage.write({"test": SimpleStoreItem(), "test2": SimpleStoreItem(2)}) @@ -147,7 +147,7 @@ async def test_blob_storage_delete_should_delete_multiple_values_when_given_mult @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_delete_should_delete_values_when_given_multiple_valid_keys_and_ignore_other_data( - self + self, ): storage = BlobStorage(BLOB_STORAGE_SETTINGS) await storage.write( @@ -165,7 +165,7 @@ async def test_blob_storage_delete_should_delete_values_when_given_multiple_vali @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_delete_invalid_key_should_do_nothing_and_not_affect_cached_data( - self + self, ): storage = BlobStorage(BLOB_STORAGE_SETTINGS) await storage.write({"test": SimpleStoreItem()}) @@ -179,7 +179,7 @@ async def test_blob_storage_delete_invalid_key_should_do_nothing_and_not_affect_ @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_delete_invalid_keys_should_do_nothing_and_not_affect_cached_data( - self + self, ): storage = BlobStorage(BLOB_STORAGE_SETTINGS) await storage.write({"test": SimpleStoreItem()}) diff --git a/libraries/botbuilder-azure/tests/test_cosmos_storage.py b/libraries/botbuilder-azure/tests/test_cosmos_storage.py index 7e686f20b..a9bfe5191 100644 --- a/libraries/botbuilder-azure/tests/test_cosmos_storage.py +++ b/libraries/botbuilder-azure/tests/test_cosmos_storage.py @@ -27,7 +27,7 @@ async def reset(): def get_mock_client(identifier: str = "1"): - # pylint: disable=invalid-name + # pylint: disable=attribute-defined-outside-init, invalid-name mock = MockClient() mock.QueryDatabases = Mock(return_value=[]) @@ -159,7 +159,7 @@ async def test_cosmos_storage_write_should_add_new_value(self): @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk( - self + self, ): await reset() storage = CosmosDbStorage(COSMOS_DB_CONFIG) @@ -228,7 +228,7 @@ async def test_cosmos_storage_delete_should_delete_according_cached_data(self): @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_delete_should_delete_multiple_values_when_given_multiple_valid_keys( - self + self, ): await reset() storage = CosmosDbStorage(COSMOS_DB_CONFIG) @@ -241,7 +241,7 @@ async def test_cosmos_storage_delete_should_delete_multiple_values_when_given_mu @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_delete_should_delete_values_when_given_multiple_valid_keys_and_ignore_other_data( - self + self, ): await reset() storage = CosmosDbStorage(COSMOS_DB_CONFIG) @@ -260,7 +260,7 @@ async def test_cosmos_storage_delete_should_delete_values_when_given_multiple_va @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_delete_invalid_key_should_do_nothing_and_not_affect_cached_data( - self + self, ): await reset() storage = CosmosDbStorage(COSMOS_DB_CONFIG) @@ -275,7 +275,7 @@ async def test_cosmos_storage_delete_invalid_key_should_do_nothing_and_not_affec @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_delete_invalid_keys_should_do_nothing_and_not_affect_cached_data( - self + self, ): await reset() storage = CosmosDbStorage(COSMOS_DB_CONFIG) diff --git a/libraries/botbuilder-azure/tests/test_key_validation.py b/libraries/botbuilder-azure/tests/test_key_validation.py index e9c1694ef..5cb8e9025 100644 --- a/libraries/botbuilder-azure/tests/test_key_validation.py +++ b/libraries/botbuilder-azure/tests/test_key_validation.py @@ -65,7 +65,7 @@ def test_should_not_truncate_short_key(self): assert len(fixed2) == 16, "short key was truncated improperly" def test_should_create_sufficiently_different_truncated_keys_of_similar_origin( - self + self, ): # create 2 very similar extra long key where the difference will definitely be trimmed off by truncate function long_key = "x" * 300 + "1" diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py b/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py index 409c4b503..307ef64cd 100644 --- a/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py +++ b/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py @@ -42,7 +42,7 @@ def from_state(bot_state: Union[BotState, Dict]) -> Activity: def from_conversation_reference( - conversation_reference: ConversationReference + conversation_reference: ConversationReference, ) -> Activity: return Activity( type=ActivityTypes.trace, diff --git a/libraries/botbuilder-core/tests/test_auto_save_middleware.py b/libraries/botbuilder-core/tests/test_auto_save_middleware.py index 4e28c68be..275bd6d91 100644 --- a/libraries/botbuilder-core/tests/test_auto_save_middleware.py +++ b/libraries/botbuilder-core/tests/test_auto_save_middleware.py @@ -107,7 +107,7 @@ async def test_should_support_plugins_passed_to_constructor(self): assert foo_state.write_called, "save_all_changes() not called." async def test_should_not_add_any_bot_state_on_construction_if_none_are_passed_in( - self + self, ): middleware = AutoSaveStateMiddleware() assert ( diff --git a/libraries/botbuilder-core/tests/test_bot_adapter.py b/libraries/botbuilder-core/tests/test_bot_adapter.py index 86752482b..dafbe29d8 100644 --- a/libraries/botbuilder-core/tests/test_bot_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_adapter.py @@ -30,7 +30,7 @@ def test_adapter_use_chaining(self): async def test_pass_resource_responses_through(self): def validate_responses( # pylint: disable=unused-argument - activities: List[Activity] + activities: List[Activity], ): pass # no need to do anything. diff --git a/libraries/botbuilder-core/tests/test_inspection_middleware.py b/libraries/botbuilder-core/tests/test_inspection_middleware.py index 34d90463b..30c8ce7bf 100644 --- a/libraries/botbuilder-core/tests/test_inspection_middleware.py +++ b/libraries/botbuilder-core/tests/test_inspection_middleware.py @@ -37,7 +37,7 @@ async def aux_func(context: TurnContext): assert outbound_activity.text, "hi" async def test_should_replicate_activity_data_to_listening_emulator_following_open_and_attach( - self + self, ): inbound_expectation, outbound_expectation, state_expectation = ( False, @@ -147,7 +147,7 @@ async def exec_test2(turn_context): assert mocker.call_count, 3 async def test_should_replicate_activity_data_to_listening_emulator_following_open_and_attach_with_at_mention( - self + self, ): inbound_expectation, outbound_expectation, state_expectation = ( False, diff --git a/libraries/botbuilder-core/tests/test_memory_storage.py b/libraries/botbuilder-core/tests/test_memory_storage.py index 16afdb0e2..63946ad60 100644 --- a/libraries/botbuilder-core/tests/test_memory_storage.py +++ b/libraries/botbuilder-core/tests/test_memory_storage.py @@ -24,7 +24,7 @@ def test_memory_storage__e_tag_should_start_at_0(self): assert storage._e_tag == 0 # pylint: disable=protected-access async def test_memory_storage_initialized_with_memory_should_have_accessible_data( - self + self, ): storage = MemoryStorage({"test": SimpleStoreItem()}) data = await storage.read(["test"]) @@ -53,7 +53,7 @@ async def test_memory_storage_write_should_add_new_value(self): assert data["user"].counter == 1 async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk_1( - self + self, ): storage = MemoryStorage() await storage.write({"user": SimpleStoreItem(e_tag="1")}) @@ -63,7 +63,7 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri assert data["user"].counter == 10 async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk_2( - self + self, ): storage = MemoryStorage() await storage.write({"user": SimpleStoreItem(e_tag="1")}) @@ -92,7 +92,7 @@ async def test_memory_storage_delete_should_delete_according_cached_data(self): assert not data.keys() async def test_memory_storage_delete_should_delete_multiple_values_when_given_multiple_valid_keys( - self + self, ): storage = MemoryStorage( {"test": SimpleStoreItem(), "test2": SimpleStoreItem(2, "2")} @@ -103,7 +103,7 @@ async def test_memory_storage_delete_should_delete_multiple_values_when_given_mu assert not data.keys() async def test_memory_storage_delete_should_delete_values_when_given_multiple_valid_keys_and_ignore_other_data( - self + self, ): storage = MemoryStorage( { @@ -118,7 +118,7 @@ async def test_memory_storage_delete_should_delete_values_when_given_multiple_va assert len(data.keys()) == 1 async def test_memory_storage_delete_invalid_key_should_do_nothing_and_not_affect_cached_data( - self + self, ): storage = MemoryStorage({"test": "test"}) @@ -129,7 +129,7 @@ async def test_memory_storage_delete_invalid_key_should_do_nothing_and_not_affec assert not data.keys() async def test_memory_storage_delete_invalid_keys_should_do_nothing_and_not_affect_cached_data( - self + self, ): storage = MemoryStorage({"test": "test"}) diff --git a/libraries/botbuilder-core/tests/test_show_typing_middleware.py b/libraries/botbuilder-core/tests/test_show_typing_middleware.py index 0d1af513c..0e1aff56e 100644 --- a/libraries/botbuilder-core/tests/test_show_typing_middleware.py +++ b/libraries/botbuilder-core/tests/test_show_typing_middleware.py @@ -29,7 +29,7 @@ def assert_is_typing(activity, description): # pylint: disable=unused-argument await step5.assert_reply("echo:bar") async def test_should_not_automatically_send_a_typing_indicator_if_no_middleware( - self + self, ): async def aux(context): await context.send_activity(f"echo:{context.activity.text}") diff --git a/libraries/botbuilder-core/tests/test_test_adapter.py b/libraries/botbuilder-core/tests/test_test_adapter.py index 8f1fe2db8..1d095c222 100644 --- a/libraries/botbuilder-core/tests/test_test_adapter.py +++ b/libraries/botbuilder-core/tests/test_test_adapter.py @@ -37,7 +37,7 @@ async def logic(context: TurnContext): await adapter.receive_activity(Activity(type="message", text="test")) async def test_should_set_activity_type_when_receive_activity_receives_activity_without_type( - self + self, ): async def logic(context: TurnContext): assert context.activity.type == "message" diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index d39da5d20..fee872462 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -259,7 +259,7 @@ def test_get_conversation_reference_should_return_valid_reference(self): assert reference.service_url == ACTIVITY.service_url def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( - self + self, ): reference = TurnContext.get_conversation_reference(ACTIVITY) reply = TurnContext.apply_conversation_reference( @@ -273,7 +273,7 @@ def test_apply_conversation_reference_should_return_prepare_reply_when_is_incomi assert reply.channel_id == ACTIVITY.channel_id def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepare_a_reply( - self + self, ): reference = TurnContext.get_conversation_reference(ACTIVITY) reply = TurnContext.apply_conversation_reference( @@ -287,7 +287,7 @@ def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepar assert reply.channel_id == ACTIVITY.channel_id async def test_should_get_conversation_reference_using_get_reply_conversation_reference( - self + self, ): context = TurnContext(SimpleAdapter(), ACTIVITY) reply = await context.send_activity("test") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py index 763791957..4c1c59d0f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py @@ -88,7 +88,7 @@ def has_message_feed(channel_id: str) -> bool: @staticmethod def max_action_title_length( # pylint: disable=unused-argument - channel_id: str + channel_id: str, ) -> int: """Maximum length allowed for Action Titles. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index f307e1f49..706369cc6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -165,6 +165,6 @@ def determine_culture(self, activity: Activity) -> str: ) if not culture or culture not in self.choice_defaults: culture = ( - "English" - ) # TODO: Fix to reference recognizer to use proper constants + "English" # TODO: Fix to reference recognizer to use proper constants + ) return culture diff --git a/libraries/botbuilder-dialogs/tests/test_choice_prompt.py b/libraries/botbuilder-dialogs/tests/test_choice_prompt.py index 8b4499e1d..d0f581647 100644 --- a/libraries/botbuilder-dialogs/tests/test_choice_prompt.py +++ b/libraries/botbuilder-dialogs/tests/test_choice_prompt.py @@ -315,7 +315,7 @@ async def validator(prompt: PromptValidatorContext) -> bool: await step3.assert_reply("red") async def test_should_use_context_activity_locale_over_default_locale_when_rendering_choices( - self + self, ): async def exec_test(turn_context: TurnContext): dialog_context = await dialogs.create_context(turn_context) @@ -477,7 +477,7 @@ async def exec_test(turn_context: TurnContext): await step3.assert_reply("red") async def test_should_create_prompt_with_suggested_action_style_when_specified( - self + self, ): async def exec_test(turn_context: TurnContext): dialog_context = await dialogs.create_context(turn_context) diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index 2ace9b666..c26f6ee01 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -21,19 +21,19 @@ def __init__(self, dialog_id: str): super(MyWaterfallDialog, self).__init__(dialog_id) async def waterfall2_step1( - step_context: WaterfallStepContext + step_context: WaterfallStepContext, ) -> DialogTurnResult: await step_context.context.send_activity("step1") return Dialog.end_of_turn async def waterfall2_step2( - step_context: WaterfallStepContext + step_context: WaterfallStepContext, ) -> DialogTurnResult: await step_context.context.send_activity("step2") return Dialog.end_of_turn async def waterfall2_step3( - step_context: WaterfallStepContext + step_context: WaterfallStepContext, ) -> DialogTurnResult: await step_context.context.send_activity("step3") return Dialog.end_of_turn diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index b3d904b4d..2418558fb 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -39,7 +39,7 @@ class TestAuth: @pytest.mark.asyncio async def test_connector_auth_header_correct_app_id_and_service_url_should_validate( - self + self, ): header = ( "Bearer " @@ -58,7 +58,7 @@ async def test_connector_auth_header_correct_app_id_and_service_url_should_valid @pytest.mark.asyncio async def test_connector_auth_header_with_different_bot_app_id_should_not_validate( - self + self, ): header = ( "Bearer " @@ -100,7 +100,7 @@ async def test_empty_header_and_no_credential_should_throw(self): @pytest.mark.asyncio async def test_emulator_msa_header_correct_app_id_and_service_url_should_validate( - self + self, ): header = ( "Bearer " @@ -212,7 +212,7 @@ async def test_channel_authentication_disabled_should_be_anonymous(self): @pytest.mark.asyncio # Tests with no authentication header and makes sure the service URL is not added to the trusted list. async def test_channel_authentication_disabled_service_url_should_not_be_trusted( - self + self, ): activity = Activity(service_url="https://webchat.botframework.com/") header = "" @@ -226,7 +226,7 @@ async def test_channel_authentication_disabled_service_url_should_not_be_trusted @pytest.mark.asyncio async def test_emulator_auth_header_correct_app_id_and_service_url_with_gov_channel_service_should_validate( - self + self, ): await jwt_token_validation_validate_auth_header_with_channel_service_succeeds( "2cd87869-38a0-4182-9251-d056e8f0ac24", # emulator creds @@ -236,7 +236,7 @@ async def test_emulator_auth_header_correct_app_id_and_service_url_with_gov_chan @pytest.mark.asyncio async def test_emulator_auth_header_correct_app_id_and_service_url_with_private_channel_service_should_validate( - self + self, ): await jwt_token_validation_validate_auth_header_with_channel_service_succeeds( "2cd87869-38a0-4182-9251-d056e8f0ac24", # emulator creds diff --git a/libraries/botframework-connector/tests/test_conversations.py b/libraries/botframework-connector/tests/test_conversations.py index b75960c00..badd636d7 100644 --- a/libraries/botframework-connector/tests/test_conversations.py +++ b/libraries/botframework-connector/tests/test_conversations.py @@ -190,7 +190,7 @@ def test_conversations_send_to_conversation_with_attachment(self): assert response is not None def test_conversations_send_to_conversation_with_invalid_conversation_id_fails( - self + self, ): activity = Activity( type=ActivityTypes.message, diff --git a/libraries/botframework-connector/tests/test_conversations_async.py b/libraries/botframework-connector/tests/test_conversations_async.py index 8445391bc..a6ad2242b 100644 --- a/libraries/botframework-connector/tests/test_conversations_async.py +++ b/libraries/botframework-connector/tests/test_conversations_async.py @@ -203,7 +203,7 @@ def test_conversations_send_to_conversation_with_attachment(self): assert response is not None def test_conversations_send_to_conversation_with_invalid_conversation_id_fails( - self + self, ): activity = Activity( type=ActivityTypes.message, From 5bc70b2e5c1b9f3738072da372ca3b49e706743b Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 29 Oct 2019 12:04:18 -0500 Subject: [PATCH 010/616] Standardized app.py and on_error messages in original samples. (#376) --- samples/06.using-cards/app.py | 51 +++-- samples/06.using-cards/config.py | 4 - samples/06.using-cards/requirements.txt | 3 +- .../13.core-bot/adapter_with_error_handler.py | 28 ++- samples/13.core-bot/app.py | 15 +- samples/21.corebot-app-insights/README.md | 28 ++- .../{main.py => app.py} | 213 ++++++++++-------- samples/45.state-management/app.py | 56 +++-- samples/45.state-management/config.py | 6 +- samples/45.state-management/requirements.txt | 3 +- 10 files changed, 239 insertions(+), 168 deletions(-) rename samples/21.corebot-app-insights/{main.py => app.py} (56%) diff --git a/samples/06.using-cards/app.py b/samples/06.using-cards/app.py index 98d5bc583..611090cb6 100644 --- a/samples/06.using-cards/app.py +++ b/samples/06.using-cards/app.py @@ -6,6 +6,8 @@ """ import asyncio import sys +from datetime import datetime +from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -16,49 +18,65 @@ TurnContext, UserState, ) -from botbuilder.schema import Activity +from botbuilder.schema import Activity, ActivityTypes from dialogs import MainDialog from bots import RichCardsBot +# Create the loop and Flask app LOOP = asyncio.get_event_loop() APP = Flask(__name__, instance_relative_config=True) APP.config.from_object("config.DefaultConfig") -SETTINGS = BotFrameworkAdapterSettings( - APP.config["APP_ID"], APP.config["APP_PASSWORD"] -) +# 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 +async def on_error(self, 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) + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + # Send a message to the user - await context.send_activity("Oops. Something went wrong!") + 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) + # Clear out state await CONVERSATION_STATE.delete(context) - -ADAPTER.on_turn_error = on_error +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) # Create MemoryStorage, UserState and ConversationState MEMORY = MemoryStorage() - -# Commented out user_state because it's not being used. USER_STATE = UserState(MEMORY) CONVERSATION_STATE = ConversationState(MEMORY) - +# Create dialog and Bot DIALOG = MainDialog() BOT = RichCardsBot(CONVERSATION_STATE, USER_STATE, DIALOG) +# Listen for incoming requests on /api/messages. @APP.route("/api/messages", methods=["POST"]) def messages(): - """Main bot message handler.""" + # Main bot message handler.s if "application/json" in request.headers["Content-Type"]: body = request.json else: @@ -69,12 +87,9 @@ def messages(): request.headers["Authorization"] if "Authorization" in request.headers else "" ) - async def aux_func(turn_context): - await BOT.on_turn(turn_context) - try: task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, aux_func) + ADAPTER.process_activity(activity, auth_header, BOT.on_turn) ) LOOP.run_until_complete(task) return Response(status=201) diff --git a/samples/06.using-cards/config.py b/samples/06.using-cards/config.py index 83f1bbbdf..e007d0fa9 100644 --- a/samples/06.using-cards/config.py +++ b/samples/06.using-cards/config.py @@ -13,7 +13,3 @@ class DefaultConfig: PORT = 3978 APP_ID = os.environ.get("MicrosoftAppId", "") APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") - LUIS_APP_ID = os.environ.get("LuisAppId", "") - LUIS_API_KEY = os.environ.get("LuisAPIKey", "") - # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" - LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/samples/06.using-cards/requirements.txt b/samples/06.using-cards/requirements.txt index 2d41bcf0e..e44abb535 100644 --- a/samples/06.using-cards/requirements.txt +++ b/samples/06.using-cards/requirements.txt @@ -1,2 +1,3 @@ botbuilder-core>=4.4.0b1 -botbuilder-dialogs>=4.4.0b1 \ No newline at end of file +botbuilder-dialogs>=4.4.0b1 +flask>=1.0.3 diff --git a/samples/13.core-bot/adapter_with_error_handler.py b/samples/13.core-bot/adapter_with_error_handler.py index 10aaa238f..8a4bcaf54 100644 --- a/samples/13.core-bot/adapter_with_error_handler.py +++ b/samples/13.core-bot/adapter_with_error_handler.py @@ -1,14 +1,15 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import sys +from datetime import datetime + from botbuilder.core import ( BotFrameworkAdapter, BotFrameworkAdapterSettings, ConversationState, - MessageFactory, TurnContext, ) -from botbuilder.schema import InputHints +from botbuilder.schema import InputHints, ActivityTypes, Activity class AdapterWithErrorHandler(BotFrameworkAdapter): @@ -25,14 +26,25 @@ async def on_error(context: TurnContext, error: Exception): # This check writes out errors to console log # NOTE: In production environment, you should consider logging this to Azure # application insights. - print(f"\n [on_turn_error]: {error}", file=sys.stderr) + print(f"\n [on_turn_error] unhandled 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) + 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) + # Clear out state nonlocal self await self._conversation_state.delete(context) diff --git a/samples/13.core-bot/app.py b/samples/13.core-bot/app.py index bee45cd18..d09b2d991 100644 --- a/samples/13.core-bot/app.py +++ b/samples/13.core-bot/app.py @@ -25,6 +25,7 @@ from adapter_with_error_handler import AdapterWithErrorHandler from flight_booking_recognizer import FlightBookingRecognizer +# Create the loop and Flask app LOOP = asyncio.get_event_loop() APP = Flask(__name__, instance_relative_config=True) APP.config.from_object("config.DefaultConfig") @@ -35,17 +36,22 @@ MEMORY = MemoryStorage() USER_STATE = UserState(MEMORY) CONVERSATION_STATE = ConversationState(MEMORY) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. ADAPTER = AdapterWithErrorHandler(SETTINGS, CONVERSATION_STATE) + +# Create dialogs and Bot RECOGNIZER = FlightBookingRecognizer(APP.config) BOOKING_DIALOG = BookingDialog() - DIALOG = MainDialog(RECOGNIZER, BOOKING_DIALOG) BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) +# Listen for incoming requests on /api/messages. @APP.route("/api/messages", methods=["POST"]) def messages(): - """Main bot message handler.""" + # Main bot message handler. if "application/json" in request.headers["Content-Type"]: body = request.json else: @@ -56,12 +62,9 @@ def messages(): request.headers["Authorization"] if "Authorization" in request.headers else "" ) - async def aux_func(turn_context): - await BOT.on_turn(turn_context) - try: task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, aux_func) + ADAPTER.process_activity(activity, auth_header, BOT.on_turn) ) LOOP.run_until_complete(task) return Response(status=201) diff --git a/samples/21.corebot-app-insights/README.md b/samples/21.corebot-app-insights/README.md index 3e7d71599..bea70586c 100644 --- a/samples/21.corebot-app-insights/README.md +++ b/samples/21.corebot-app-insights/README.md @@ -1,4 +1,4 @@ -# CoreBot +# CoreBot with Application Insights Bot Framework v4 core bot sample. @@ -12,14 +12,12 @@ This bot has been created using [Bot Framework](https://dev.botframework.com), i ## Prerequisites -This sample **requires** prerequisites in order to run. +### Install Python 3.6 ### Overview -This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding. - -### Install Python 3.6 - +This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding +and [Application Insights](https://docs.microsoft.com/azure/azure-monitor/app/cloudservices), an extensible Application Performance Management (APM) service for web developers on multiple platforms. ### Create a LUIS Application to enable language understanding @@ -27,6 +25,16 @@ LUIS language model setup, training, and application configuration steps can be If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). +### Add Application Insights service to enable the bot monitoring +Application Insights resource creation steps can be found [here](https://docs.microsoft.com/azure/azure-monitor/app/create-new-resource). + +## Running the sample +- Run `pip install -r requirements.txt` to install all dependencies +- Update LuisAppId, LuisAPIKey and LuisAPIHostName in `config.py` with the information retrieved from the [LUIS portal](https://www.luis.ai) +- Update AppInsightsInstrumentationKey in `config.py` +- Run `python app.py` +- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` + ## Testing the bot using Bot Framework Emulator @@ -50,8 +58,8 @@ If you wish to create a LUIS application via the CLI, these steps can be found i - [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) -- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file +- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) +- [Application insights Overview](https://docs.microsoft.com/azure/azure-monitor/app/app-insights-overview) +- [Getting Started with Application Insights](https://github.com/Microsoft/ApplicationInsights-aspnetcore/wiki/Getting-Started-with-Application-Insights-for-ASP.NET-Core) +- [Filtering and preprocessing telemetry in the Application Insights SDK](https://docs.microsoft.com/azure/azure-monitor/app/api-filtering-sampling) diff --git a/samples/21.corebot-app-insights/main.py b/samples/21.corebot-app-insights/app.py similarity index 56% rename from samples/21.corebot-app-insights/main.py rename to samples/21.corebot-app-insights/app.py index b6d2ccf05..9cc6896be 100644 --- a/samples/21.corebot-app-insights/main.py +++ b/samples/21.corebot-app-insights/app.py @@ -1,93 +1,120 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -This sample shows how to create a bot that demonstrates the following: -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. -- Implement a multi-turn conversation using Dialogs. -- Handle user interruptions for such things as `Help` or `Cancel`. -- Prompt for and validate requests for information from the user. - -""" - -import asyncio -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - UserState, - TurnContext, -) -from botbuilder.schema import Activity -from botbuilder.applicationinsights import ApplicationInsightsTelemetryClient -from botbuilder.applicationinsights.flask import BotTelemetryMiddleware - -from dialogs import MainDialog -from bots import DialogAndWelcomeBot - - -LOOP = asyncio.get_event_loop() -APP = Flask(__name__, instance_relative_config=True) -APP.config.from_object("config.DefaultConfig") -APP.wsgi_app = BotTelemetryMiddleware(APP.wsgi_app) - -SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) -ADAPTER = BotFrameworkAdapter(SETTINGS) - -# pylint:disable=unused-argument -async def on_error(context: TurnContext, error: Exception): - """ Catch-all for errors.""" - # Send a message to the user - await context.send_activity("Oops. Something went wrong!") - # Clear out state - await CONVERSATION_STATE.delete(context) - - -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage, UserState and ConversationState -MEMORY = MemoryStorage() - -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) -INSTRUMENTATION_KEY = APP.config["APPINSIGHTS_INSTRUMENTATION_KEY"] -TELEMETRY_CLIENT = ApplicationInsightsTelemetryClient(INSTRUMENTATION_KEY) -DIALOG = MainDialog(APP.config, telemetry_client=TELEMETRY_CLIENT) -BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG, TELEMETRY_CLIENT) - - -@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 "" - ) - - async def aux_func(turn_context): - await BOT.on_turn(turn_context) - - try: - future = asyncio.ensure_future( - ADAPTER.process_activity(activity, auth_header, aux_func), loop=LOOP - ) - LOOP.run_until_complete(future) - return Response(status=201) - except Exception as exception: - raise exception - - -if __name__ == "__main__": - try: - APP.run(debug=True, port=APP.config["PORT"]) - - except Exception as exception: - raise exception +#!/usr/bin/env python +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +This sample shows how to create a bot that demonstrates the following: +- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. +- Implement a multi-turn conversation using Dialogs. +- Handle user interruptions for such things as `Help` or `Cancel`. +- Prompt for and validate requests for information from the user. + +""" + +import asyncio +import sys +from datetime import datetime +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, + TurnContext, +) +from botbuilder.schema import Activity, ActivityTypes +from botbuilder.applicationinsights import ApplicationInsightsTelemetryClient +from botbuilder.applicationinsights.flask import BotTelemetryMiddleware + +from dialogs import MainDialog +from bots import DialogAndWelcomeBot + +# Create the loop and Flask app +LOOP = asyncio.get_event_loop() +APP = Flask(__name__, instance_relative_config=True) +APP.config.from_object("config.DefaultConfig") +APP.wsgi_app = BotTelemetryMiddleware(APP.wsgi_app) + +# 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(self, 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) + + # Clear out state + nonlocal self + await CONVERSATION_STATE.delete(context) + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create telemetry client +INSTRUMENTATION_KEY = APP.config["APPINSIGHTS_INSTRUMENTATION_KEY"] +TELEMETRY_CLIENT = ApplicationInsightsTelemetryClient(INSTRUMENTATION_KEY) + +# Create dialog and Bot +DIALOG = MainDialog(APP.config, telemetry_client=TELEMETRY_CLIENT) +BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG, TELEMETRY_CLIENT) + + +# 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=True, port=APP.config["PORT"]) + + except Exception as exception: + raise exception diff --git a/samples/45.state-management/app.py b/samples/45.state-management/app.py index 04f42895f..1268ffcd2 100644 --- a/samples/45.state-management/app.py +++ b/samples/45.state-management/app.py @@ -1,11 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -""" -This sample shows how to manage state in a bot. -""" import asyncio import sys +from datetime import datetime +from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -16,47 +15,63 @@ TurnContext, UserState, ) -from botbuilder.schema import Activity +from botbuilder.schema import Activity, ActivityTypes from bots import StateManagementBot +# Create the loop and Flask app LOOP = asyncio.get_event_loop() APP = Flask(__name__, instance_relative_config=True) APP.config.from_object("config.DefaultConfig") -SETTINGS = BotFrameworkAdapterSettings( - APP.config["APP_ID"], APP.config["APP_PASSWORD"] -) +# 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 +async def on_error(self, 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) + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + # Send a message to the user - await context.send_activity("Oops. Something went wrong!") + 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) + # Clear out state await CONVERSATION_STATE.delete(context) +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage, UserState and ConversationState +# Create MemoryStorage and state MEMORY = MemoryStorage() - -# Commented out user_state because it's not being used. USER_STATE = UserState(MEMORY) CONVERSATION_STATE = ConversationState(MEMORY) - +# Create Bot BOT = StateManagementBot(CONVERSATION_STATE, USER_STATE) +# Listen for incoming requests on /api/messages. @APP.route("/api/messages", methods=["POST"]) def messages(): - """Main bot message handler.""" + # Main bot message handler. if "application/json" in request.headers["Content-Type"]: body = request.json else: @@ -67,12 +82,9 @@ def messages(): request.headers["Authorization"] if "Authorization" in request.headers else "" ) - async def aux_func(turn_context): - await BOT.on_turn(turn_context) - try: task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, aux_func) + ADAPTER.process_activity(activity, auth_header, BOT.on_turn) ) LOOP.run_until_complete(task) return Response(status=201) diff --git a/samples/45.state-management/config.py b/samples/45.state-management/config.py index c8c926f07..e007d0fa9 100644 --- a/samples/45.state-management/config.py +++ b/samples/45.state-management/config.py @@ -7,13 +7,9 @@ """ Bot Configuration """ -class DefaultConfig(object): +class DefaultConfig: """ Bot Configuration """ PORT = 3978 APP_ID = os.environ.get("MicrosoftAppId", "") APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") - LUIS_APP_ID = os.environ.get("LuisAppId", "") - LUIS_API_KEY = os.environ.get("LuisAPIKey", "") - # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" - LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/samples/45.state-management/requirements.txt b/samples/45.state-management/requirements.txt index 0c4745525..7e54b62ec 100644 --- a/samples/45.state-management/requirements.txt +++ b/samples/45.state-management/requirements.txt @@ -1 +1,2 @@ -botbuilder-core>=4.4.0b1 \ No newline at end of file +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From 0987404603ab2ddb6362cedf11cff94cfec6f560 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 29 Oct 2019 12:11:10 -0500 Subject: [PATCH 011/616] Added 05.multi-turn-prompt (#368) * Added 05.multi-turn-prompt * Removed LUIS keys from settings. * PR corrections --- samples/05.multi-turn-prompt/README.md | 50 ++++++ samples/05.multi-turn-prompt/app.py | 102 +++++++++++ samples/05.multi-turn-prompt/bots/__init__.py | 6 + .../05.multi-turn-prompt/bots/dialog_bot.py | 51 ++++++ samples/05.multi-turn-prompt/config.py | 15 ++ .../data_models/__init__.py | 6 + .../data_models/user_profile.py | 13 ++ .../05.multi-turn-prompt/dialogs/__init__.py | 6 + .../dialogs/user_profile_dialog.py | 158 ++++++++++++++++++ .../05.multi-turn-prompt/helpers/__init__.py | 6 + .../helpers/dialog_helper.py | 19 +++ samples/05.multi-turn-prompt/requirements.txt | 4 + 12 files changed, 436 insertions(+) create mode 100644 samples/05.multi-turn-prompt/README.md create mode 100644 samples/05.multi-turn-prompt/app.py create mode 100644 samples/05.multi-turn-prompt/bots/__init__.py create mode 100644 samples/05.multi-turn-prompt/bots/dialog_bot.py create mode 100644 samples/05.multi-turn-prompt/config.py create mode 100644 samples/05.multi-turn-prompt/data_models/__init__.py create mode 100644 samples/05.multi-turn-prompt/data_models/user_profile.py create mode 100644 samples/05.multi-turn-prompt/dialogs/__init__.py create mode 100644 samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py create mode 100644 samples/05.multi-turn-prompt/helpers/__init__.py create mode 100644 samples/05.multi-turn-prompt/helpers/dialog_helper.py create mode 100644 samples/05.multi-turn-prompt/requirements.txt diff --git a/samples/05.multi-turn-prompt/README.md b/samples/05.multi-turn-prompt/README.md new file mode 100644 index 000000000..405a70f2a --- /dev/null +++ b/samples/05.multi-turn-prompt/README.md @@ -0,0 +1,50 @@ +# multi-turn prompt + +Bot Framework v4 welcome users bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use the prompts classes included in `botbuilder-dialogs`. This bot will ask for the user's name and age, then store the responses. It demonstrates a multi-turn dialog flow using a text prompt, a number prompt, and state accessors to store and retrieve values. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Run `pip install -r requirements.txt` to install all dependencies +- Run `python app.py` +- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` + + +### Visual studio code +- Activate your desired virtual environment +- Open `botbuilder-python\samples\45.state-management` folder +- Bring up a terminal, navigate to `botbuilder-python\samples\05.multi-turn-prompt` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + + +## Prompts + +A conversation between a bot and a user often involves asking (prompting) the user for information, parsing the user's response, +and then acting on that information. This sample demonstrates how to prompt users for information using the different prompt types +included in the [botbuilder-dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) library +and supported by the SDK. + +The `botbuilder-dialogs` library includes a variety of pre-built prompt classes, including text, number, and datetime types. This +sample demonstrates using a text prompt to collect the user's name, then using a number prompt to collect an age. + +# 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) +- [Dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Gathering Input Using Prompts](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/05.multi-turn-prompt/app.py b/samples/05.multi-turn-prompt/app.py new file mode 100644 index 000000000..2a358711d --- /dev/null +++ b/samples/05.multi-turn-prompt/app.py @@ -0,0 +1,102 @@ +# 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 ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from dialogs import UserProfileDialog +from bots import DialogBot + +# 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 + # 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 + 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) + + # Clear out state + await CONVERSATION_STATE.delete(context) + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +CONVERSATION_STATE = ConversationState(MEMORY) +USER_STATE = UserState(MEMORY) + +# create main dialog and bot +DIALOG = UserProfileDialog(USER_STATE) +BOT = DialogBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +# Listen for incoming requests on /api/messages. +@APP.route("/api/messages", methods=["POST"]) +def messages(): + # Main bot message handler.s + 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/samples/05.multi-turn-prompt/bots/__init__.py b/samples/05.multi-turn-prompt/bots/__init__.py new file mode 100644 index 000000000..306aca22c --- /dev/null +++ b/samples/05.multi-turn-prompt/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot + +__all__ = ["DialogBot"] diff --git a/samples/05.multi-turn-prompt/bots/dialog_bot.py b/samples/05.multi-turn-prompt/bots/dialog_bot.py new file mode 100644 index 000000000..37a140966 --- /dev/null +++ b/samples/05.multi-turn-prompt/bots/dialog_bot.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState +from botbuilder.dialogs import Dialog +from helpers.dialog_helper import DialogHelper + +""" +This Bot implementation can run any type of Dialog. The use of type parameterization is to allows multiple +different bots to be run at different endpoints within the same project. This can be achieved by defining distinct +Controller types each with dependency on distinct Bot types. The ConversationState is used by the Dialog system. The +UserState isn't, however, it might have been used in a Dialog implementation, and the requirement is that all +BotState objects are saved at the end of a turn. +""" + + +class DialogBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog + ): + if conversation_state is None: + raise TypeError( + "[DialogBot]: Missing parameter. conversation_state is required but None was given" + ) + if user_state is None: + raise TypeError( + "[DialogBot]: Missing parameter. user_state is required but None was given" + ) + if dialog is None: + raise Exception("[DialogBot]: Missing parameter. dialog is required") + + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have ocurred during the turn. + await self.conversation_state.save_changes(turn_context) + await self.user_state.save_changes(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/05.multi-turn-prompt/config.py b/samples/05.multi-turn-prompt/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/05.multi-turn-prompt/config.py @@ -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", "") diff --git a/samples/05.multi-turn-prompt/data_models/__init__.py b/samples/05.multi-turn-prompt/data_models/__init__.py new file mode 100644 index 000000000..35a5934d4 --- /dev/null +++ b/samples/05.multi-turn-prompt/data_models/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .user_profile import UserProfile + +__all__ = ["UserProfile"] diff --git a/samples/05.multi-turn-prompt/data_models/user_profile.py b/samples/05.multi-turn-prompt/data_models/user_profile.py new file mode 100644 index 000000000..efdc77eeb --- /dev/null +++ b/samples/05.multi-turn-prompt/data_models/user_profile.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" + This is our application state. Just a regular serializable Python class. +""" + + +class UserProfile: + def __init__(self, name: str = None, transport: str = None, age: int = 0): + self.name = name + self.transport = transport + self.age = age diff --git a/samples/05.multi-turn-prompt/dialogs/__init__.py b/samples/05.multi-turn-prompt/dialogs/__init__.py new file mode 100644 index 000000000..2de723d58 --- /dev/null +++ b/samples/05.multi-turn-prompt/dialogs/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .user_profile_dialog import UserProfileDialog + +__all__ = ["UserProfileDialog"] diff --git a/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py new file mode 100644 index 000000000..86eea641b --- /dev/null +++ b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py @@ -0,0 +1,158 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult +) +from botbuilder.dialogs.prompts import ( + TextPrompt, + NumberPrompt, + ChoicePrompt, + ConfirmPrompt, + PromptOptions, + PromptValidatorContext +) +from botbuilder.dialogs.choices import Choice +from botbuilder.core import MessageFactory, UserState + +from data_models import UserProfile + + +class UserProfileDialog(ComponentDialog): + def __init__( + self, user_state: UserState + ): + super(UserProfileDialog, self).__init__(UserProfileDialog.__name__) + + self.user_profile_accessor = user_state.create_property("UserProfile") + + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, [ + self.transport_step, + self.name_step, + self.name_confirm_step, + self.age_step, + self.confirm_step, + self.summary_step + ] + ) + ) + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog( + NumberPrompt( + NumberPrompt.__name__, + UserProfileDialog.age_prompt_validator + ) + ) + self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def transport_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # WaterfallStep always finishes with the end of the Waterfall or with another dialog; + # here it is a Prompt Dialog. Running a prompt here means the next WaterfallStep will + # be run when the users response is received. + return await step_context.prompt( + ChoicePrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Please enter your mode of transport."), + choices=[Choice("Car"), Choice("Bus"), Choice("Bicycle")] + ) + ) + + async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + step_context.values["transport"] = step_context.result.value + + return await step_context.prompt( + TextPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Please enter your name.") + ) + ) + + async def name_confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + step_context.values["name"] = step_context.result + + # We can send messages to the user at any point in the WaterfallStep. + await step_context.context.send_activity( + MessageFactory.text(f"Thanks {step_context.result}") + ) + + # WaterfallStep always finishes with the end of the Waterfall or + # with another dialog; here it is a Prompt Dialog. + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Would you like to give your age?") + ) + ) + + async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if step_context.result: + # User said "yes" so we will be prompting for the age. + # WaterfallStep always finishes with the end of the Waterfall or with another dialog, + # here it is a Prompt Dialog. + return await step_context.prompt( + NumberPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Please enter your age."), + retry_prompt=MessageFactory.text( + "The value entered must be greater than 0 and less than 150." + ) + ) + ) + else: + # User said "no" so we will skip the next step. Give -1 as the age. + return await step_context.next(-1) + + async def confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + age = step_context.result + step_context.values["age"] = step_context.result + + msg = "No age given." if step_context.result == -1 else f"I have your age as {age}." + + # We can send messages to the user at any point in the WaterfallStep. + await step_context.context.send_activity(MessageFactory.text(msg)) + + # WaterfallStep always finishes with the end of the Waterfall or + # with another dialog; here it is a Prompt Dialog. + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Is this ok?") + ) + ) + + async def summary_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if step_context.result: + # Get the current profile object from user state. Changes to it + # will saved during Bot.on_turn. + user_profile = await self.user_profile_accessor.get(step_context.context, UserProfile) + + user_profile.transport = step_context.values["transport"] + user_profile.name = step_context.values["name"] + user_profile.age = step_context.values["age"] + + msg = f"I have your mode of transport as {user_profile.transport} and your name as {user_profile.name}." + if user_profile.age != -1: + msg += f" And age as {user_profile.age}." + + await step_context.context.send_activity(MessageFactory.text(msg)) + else: + await step_context.context.send_activity( + MessageFactory.text("Thanks. Your profile will not be kept.") + ) + + # WaterfallStep always finishes with the end of the Waterfall or with another + # dialog, here it is the end. + return await step_context.end_dialog() + + @staticmethod + async def age_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + # This condition is our validation rule. You can also change the value at this point. + return prompt_context.recognized.succeeded and 0 < prompt_context.recognized.value < 150 diff --git a/samples/05.multi-turn-prompt/helpers/__init__.py b/samples/05.multi-turn-prompt/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/05.multi-turn-prompt/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/05.multi-turn-prompt/helpers/dialog_helper.py b/samples/05.multi-turn-prompt/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/05.multi-turn-prompt/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/05.multi-turn-prompt/requirements.txt b/samples/05.multi-turn-prompt/requirements.txt new file mode 100644 index 000000000..676447d22 --- /dev/null +++ b/samples/05.multi-turn-prompt/requirements.txt @@ -0,0 +1,4 @@ +botbuilder-dialogs>=4.4.0.b1 +botbuilder-ai>=4.4.0.b1 +flask>=1.0.3 + From 84b3a099009ccf445fc7b5a9717dab28c6415617 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 29 Oct 2019 12:52:57 -0500 Subject: [PATCH 012/616] Added 02.echo-bot (#369) * Added 02.echo-bot * Removed LUIS keys from settings. * Added new on_error messages, standardized app.py * Corrected 02.echo-bot README * Removed adapter_with_error_handler.py (migrated to app.py) --- samples/02.echo-bot/README.md | 30 ++++++++++ samples/02.echo-bot/app.py | 83 ++++++++++++++++++++++++++++ samples/02.echo-bot/bots/__init__.py | 6 ++ samples/02.echo-bot/bots/echo_bot.py | 17 ++++++ samples/02.echo-bot/config.py | 15 +++++ samples/02.echo-bot/requirements.txt | 2 + 6 files changed, 153 insertions(+) create mode 100644 samples/02.echo-bot/README.md create mode 100644 samples/02.echo-bot/app.py create mode 100644 samples/02.echo-bot/bots/__init__.py create mode 100644 samples/02.echo-bot/bots/echo_bot.py create mode 100644 samples/02.echo-bot/config.py create mode 100644 samples/02.echo-bot/requirements.txt diff --git a/samples/02.echo-bot/README.md b/samples/02.echo-bot/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/samples/02.echo-bot/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/samples/02.echo-bot/app.py b/samples/02.echo-bot/app.py new file mode 100644 index 000000000..9762bafb9 --- /dev/null +++ b/samples/02.echo-bot/app.py @@ -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 EchoBot + +# 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(self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = EchoBot() + +# 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/samples/02.echo-bot/bots/__init__.py b/samples/02.echo-bot/bots/__init__.py new file mode 100644 index 000000000..f95fbbbad --- /dev/null +++ b/samples/02.echo-bot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/samples/02.echo-bot/bots/echo_bot.py b/samples/02.echo-bot/bots/echo_bot.py new file mode 100644 index 000000000..985c0694c --- /dev/null +++ b/samples/02.echo-bot/bots/echo_bot.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount + + +class EchoBot(ActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + return await turn_context.send_activity(MessageFactory.text(f"Echo: {turn_context.activity.text}")) diff --git a/samples/02.echo-bot/config.py b/samples/02.echo-bot/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/02.echo-bot/config.py @@ -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", "") diff --git a/samples/02.echo-bot/requirements.txt b/samples/02.echo-bot/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/02.echo-bot/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From bfe266960f9450645e799973cf1462238541ce18 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 29 Oct 2019 13:02:34 -0500 Subject: [PATCH 013/616] Added 08.suggested-actions (#372) * Added 08.suggested-actions * Removed LUIS keys from settings. * Updated on_error messages, standardized app.py * Removed adapter_with_error_handler (migrated into app.py), corrected README. --- samples/08.suggested-actions/README.md | 28 ++++++ samples/08.suggested-actions/app.py | 84 ++++++++++++++++++ samples/08.suggested-actions/bots/__init__.py | 6 ++ .../bots/suggested_actions_bot.py | 88 +++++++++++++++++++ samples/08.suggested-actions/config.py | 15 ++++ samples/08.suggested-actions/requirements.txt | 2 + 6 files changed, 223 insertions(+) create mode 100644 samples/08.suggested-actions/README.md create mode 100644 samples/08.suggested-actions/app.py create mode 100644 samples/08.suggested-actions/bots/__init__.py create mode 100644 samples/08.suggested-actions/bots/suggested_actions_bot.py create mode 100644 samples/08.suggested-actions/config.py create mode 100644 samples/08.suggested-actions/requirements.txt diff --git a/samples/08.suggested-actions/README.md b/samples/08.suggested-actions/README.md new file mode 100644 index 000000000..4e0e76ebb --- /dev/null +++ b/samples/08.suggested-actions/README.md @@ -0,0 +1,28 @@ +# suggested actions + +Bot Framework v4 using adaptive cards bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use suggested actions. Suggested actions enable your bot to present buttons that the user can tap to provide input. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Bring up a terminal, navigate to `botbuilder-python\samples\08.suggested-actions` 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 + +## Suggested actions + +Suggested actions enable your bot to present buttons that the user can tap to provide input. Suggested actions appear close to the composer and enhance user experience. diff --git a/samples/08.suggested-actions/app.py b/samples/08.suggested-actions/app.py new file mode 100644 index 000000000..4e9403486 --- /dev/null +++ b/samples/08.suggested-actions/app.py @@ -0,0 +1,84 @@ +# 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, BotFrameworkAdapter, TurnContext +from botbuilder.schema import Activity, ActivityTypes + +from bots import SuggestActionsBot + +# 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(self, 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 = MethodType(on_error, ADAPTER) + +# Create Bot +BOT = SuggestActionsBot() + + +# 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 diff --git a/samples/08.suggested-actions/bots/__init__.py b/samples/08.suggested-actions/bots/__init__.py new file mode 100644 index 000000000..cbf771a32 --- /dev/null +++ b/samples/08.suggested-actions/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .suggested_actions_bot import SuggestActionsBot + +__all__ = ["SuggestActionsBot"] diff --git a/samples/08.suggested-actions/bots/suggested_actions_bot.py b/samples/08.suggested-actions/bots/suggested_actions_bot.py new file mode 100644 index 000000000..5bee547be --- /dev/null +++ b/samples/08.suggested-actions/bots/suggested_actions_bot.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, SuggestedActions + +""" +This bot will respond to the user's input with suggested actions. +Suggested actions enable your bot to present buttons that the user +can tap to provide input. +""" + + +class SuggestActionsBot(ActivityHandler): + async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext): + """ + Send a welcome message to the user and tell them what actions they may perform to use this bot + """ + + return await self._send_welcome_message(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + """ + Respond to the users choice and display the suggested actions again. + """ + + text = turn_context.activity.text.lower() + response_text = self._process_input(text) + + await turn_context.send_activity(MessageFactory.text(response_text)) + + return await self._send_suggested_actions(turn_context) + + async def _send_welcome_message(self, turn_context: TurnContext): + for member in turn_context.activity.members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity(MessageFactory.text( + f"Welcome to SuggestedActionsBot {member.name}. This bot will introduce you to suggestedActions. " + f"Please answer the question: " + )) + + await self._send_suggested_actions(turn_context) + + def _process_input(self, text: str): + color_text = "is the best color, I agree." + + if text == "red": + return f"Red {color_text}" + + if text == "yellow": + return f"Yellow {color_text}" + + if text == "blue": + return f"Blue {color_text}" + + return "Please select a color from the suggested action choices" + + async def _send_suggested_actions(self, turn_context: TurnContext): + """ + Creates and sends an activity with suggested actions to the user. When the user + clicks one of the buttons the text value from the "CardAction" will be displayed + in the channel just as if the user entered the text. There are multiple + "ActionTypes" that may be used for different situations. + """ + + reply = MessageFactory.text("What is your favorite color?") + + reply.suggested_actions = SuggestedActions( + actions=[ + CardAction( + title="Red", + type=ActionTypes.im_back, + value="Read" + ), + CardAction( + title="Yellow", + type=ActionTypes.im_back, + value="Yellow" + ), + CardAction( + title="Blue", + type=ActionTypes.im_back, + value="Blue" + ) + ] + ) + + return await turn_context.send_activity(reply) diff --git a/samples/08.suggested-actions/config.py b/samples/08.suggested-actions/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/08.suggested-actions/config.py @@ -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", "") diff --git a/samples/08.suggested-actions/requirements.txt b/samples/08.suggested-actions/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/08.suggested-actions/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From ef3ef4b90d844e0fce79a3f303e181f81ef1fe88 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 29 Oct 2019 13:10:03 -0500 Subject: [PATCH 014/616] Added 03.welcome-user sample (#365) * Added 03.welcome-user sample * Removed LUIS keys from settings. * Updated on_error messages, standardized app.py * Corrected README --- samples/03.welcome-user/README.md | 36 +++++ samples/03.welcome-user/app.py | 93 ++++++++++++ samples/03.welcome-user/bots/__init__.py | 6 + .../03.welcome-user/bots/welcome_user_bot.py | 133 ++++++++++++++++++ samples/03.welcome-user/config.py | 15 ++ .../03.welcome-user/data_models/__init__.py | 6 + .../data_models/welcome_user_state.py | 7 + samples/03.welcome-user/requirements.txt | 2 + 8 files changed, 298 insertions(+) create mode 100644 samples/03.welcome-user/README.md create mode 100644 samples/03.welcome-user/app.py create mode 100644 samples/03.welcome-user/bots/__init__.py create mode 100644 samples/03.welcome-user/bots/welcome_user_bot.py create mode 100644 samples/03.welcome-user/config.py create mode 100644 samples/03.welcome-user/data_models/__init__.py create mode 100644 samples/03.welcome-user/data_models/welcome_user_state.py create mode 100644 samples/03.welcome-user/requirements.txt diff --git a/samples/03.welcome-user/README.md b/samples/03.welcome-user/README.md new file mode 100644 index 000000000..ac6c37553 --- /dev/null +++ b/samples/03.welcome-user/README.md @@ -0,0 +1,36 @@ +# welcome users + + +Bot Framework v4 welcome users bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), is shows how to welcome users when they join the conversation. + +## 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\03.welcome-user` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + + +## Welcoming Users + +The primary goal when creating any bot is to engage your user in a meaningful conversation. One of the best ways to achieve this goal is to ensure that from the moment a user first connects, they understand your bot’s main purpose and capabilities, the reason your bot was created. See [Send welcome message to users](https://aka.ms/botframework-welcome-instructions) for additional information on how a bot can welcome users to a conversation. + +## 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) diff --git a/samples/03.welcome-user/app.py b/samples/03.welcome-user/app.py new file mode 100644 index 000000000..7a771763d --- /dev/null +++ b/samples/03.welcome-user/app.py @@ -0,0 +1,93 @@ +# 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 ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import WelcomeUserBot + +# 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(self, 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 = MethodType(on_error, ADAPTER) + +# Create MemoryStorage, UserState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) + +# Create the Bot +BOT = WelcomeUserBot(USER_STATE) + +# 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 diff --git a/samples/03.welcome-user/bots/__init__.py b/samples/03.welcome-user/bots/__init__.py new file mode 100644 index 000000000..4f3e70d59 --- /dev/null +++ b/samples/03.welcome-user/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .welcome_user_bot import WelcomeUserBot + +__all__ = ["WelcomeUserBot"] diff --git a/samples/03.welcome-user/bots/welcome_user_bot.py b/samples/03.welcome-user/bots/welcome_user_bot.py new file mode 100644 index 000000000..8fca0919f --- /dev/null +++ b/samples/03.welcome-user/bots/welcome_user_bot.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, TurnContext, UserState, CardFactory, MessageFactory +from botbuilder.schema import ChannelAccount, HeroCard, CardImage, CardAction, ActionTypes + +from data_models import WelcomeUserState + + +class WelcomeUserBot(ActivityHandler): + def __init__(self, user_state: UserState): + if user_state is None: + raise TypeError( + "[WelcomeUserBot]: Missing parameter. user_state is required but None was given" + ) + + self.user_state = user_state + + self.user_state_accessor = self.user_state.create_property("WelcomeUserState") + + self.WELCOME_MESSAGE = """This is a simple Welcome Bot sample. This bot will introduce you + to welcoming and greeting users. You can say 'intro' to see the + introduction card. If you are running this bot in the Bot Framework + Emulator, press the 'Restart Conversation' button to simulate user joining + a bot or a channel""" + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # save changes to WelcomeUserState after each turn + await self.user_state.save_changes(turn_context) + + """ + Greet when users are added to the conversation. + Note that all channels do not send the conversation update activity. + If you find that this bot works in the emulator, but does not in + another channel the reason is most likely that the channel does not + send this activity. + """ + + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + f"Hi there { member.name }. " + self.WELCOME_MESSAGE + ) + + await turn_context.send_activity("""You are seeing this message because the bot received at least one + 'ConversationUpdate' event, indicating you (and possibly others) + joined the conversation. If you are using the emulator, pressing + the 'Start Over' button to trigger this event again. The specifics + of the 'ConversationUpdate' event depends on the channel. You can + read more information at: https://aka.ms/about-botframework-welcome-user""" + ) + + await turn_context.send_activity("""It is a good pattern to use this event to send general greeting + to user, explaining what your bot can do. In this example, the bot + handles 'hello', 'hi', 'help' and 'intro'. Try it now, type 'hi'""" + ) + + """ + Respond to messages sent from the user. + """ + + async def on_message_activity(self, turn_context: TurnContext): + # Get the state properties from the turn context. + welcome_user_state = await self.user_state_accessor.get(turn_context, WelcomeUserState) + + if not welcome_user_state.did_welcome_user: + welcome_user_state.did_welcome_user = True + + await turn_context.send_activity( + "You are seeing this message because this was your first message ever to this bot." + ) + + name = turn_context.activity.from_property.name + await turn_context.send_activity( + f"It is a good practice to welcome the user and provide personal greeting. For example: Welcome { name }" + ) + + else: + # This example hardcodes specific utterances. You should use LUIS or QnA for more advance language + # understanding. + text = turn_context.activity.text.lower() + if text in ("hello", "hi"): + await turn_context.send_activity( + f"You said { text }" + ) + elif text in ("intro", "help"): + await self.__send_intro_card(turn_context) + else: + await turn_context.send_activity(self.WELCOME_MESSAGE) + + async def __send_intro_card(self, turn_context: TurnContext): + card = HeroCard( + title="Welcome to Bot Framework!", + text="Welcome to Welcome Users bot sample! This Introduction card " + "is a great way to introduce your Bot to the user and suggest " + "some things to get them started. We use this opportunity to " + "recommend a few next steps for learning more creating and deploying bots.", + images=[ + CardImage( + url="https://aka.ms/bf-welcome-card-image" + ) + ], + buttons=[ + CardAction( + type=ActionTypes.open_url, + title="Get an overview", + text="Get an overview", + display_text="Get an overview", + value="https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" + ), + CardAction( + type=ActionTypes.open_url, + title="Ask a question", + text="Ask a question", + display_text="Ask a question", + value="https://stackoverflow.com/questions/tagged/botframework" + ), + CardAction( + type=ActionTypes.open_url, + title="Learn how to deploy", + text="Learn how to deploy", + display_text="Learn how to deploy", + value="https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" + ) + ] + ) + + return await turn_context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) diff --git a/samples/03.welcome-user/config.py b/samples/03.welcome-user/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/03.welcome-user/config.py @@ -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", "") diff --git a/samples/03.welcome-user/data_models/__init__.py b/samples/03.welcome-user/data_models/__init__.py new file mode 100644 index 000000000..a7cd0686a --- /dev/null +++ b/samples/03.welcome-user/data_models/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .welcome_user_state import WelcomeUserState + +__all__ = ["WelcomeUserState"] diff --git a/samples/03.welcome-user/data_models/welcome_user_state.py b/samples/03.welcome-user/data_models/welcome_user_state.py new file mode 100644 index 000000000..7470d4378 --- /dev/null +++ b/samples/03.welcome-user/data_models/welcome_user_state.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class WelcomeUserState: + def __init__(self, did_welcome: bool = False): + self.did_welcome_user = did_welcome diff --git a/samples/03.welcome-user/requirements.txt b/samples/03.welcome-user/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/03.welcome-user/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From fca017852b29e3abfb20953dc415250c6a45c3c0 Mon Sep 17 00:00:00 2001 From: congysu Date: Tue, 29 Oct 2019 19:17:49 +0000 Subject: [PATCH 015/616] 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 --- .../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..5fb109576 --- /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 flask import Flask, Response, request + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MessageFactory, + TurnContext, +) +from botbuilder.schema import Activity, InputHints + +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 e5f445b74943128474457659d6a868bf47efef48 Mon Sep 17 00:00:00 2001 From: daveta <6182197+daveta@users.noreply.github.com> Date: Tue, 29 Oct 2019 14:04:53 -0700 Subject: [PATCH 016/616] Set up CI with Azure Pipelines (#357) * Initial bot placeholder * Set up CI with Azure Pipelines --- azure-pipelines.yml | 61 +++++++++++++ .../functionaltestbot/Dockerfile | 48 +++++++++++ .../functionaltestbot/client_driver/README.md | 5 ++ .../functionaltestbot/README.md | 35 ++++++++ .../functionaltestbot/about.py | 14 +++ .../functionaltestbot/app.py | 86 +++++++++++++++++++ .../functionaltestbot/bot.py | 19 ++++ .../functionaltestbot/config.py | 13 +++ .../functionaltestbot/requirements.txt | 3 + .../functionaltestbot/init.sh | 8 ++ .../functionaltestbot/setup.py | 40 +++++++++ .../functionaltestbot/sshd_config | 21 +++++ .../functionaltestbot/test.sh | 1 + 13 files changed, 354 insertions(+) create mode 100644 azure-pipelines.yml create mode 100644 libraries/functional-tests/functionaltestbot/Dockerfile create mode 100644 libraries/functional-tests/functionaltestbot/client_driver/README.md create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/README.md create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/about.py create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/app.py create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/config.py create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt create mode 100644 libraries/functional-tests/functionaltestbot/init.sh create mode 100644 libraries/functional-tests/functionaltestbot/setup.py create mode 100644 libraries/functional-tests/functionaltestbot/sshd_config create mode 100644 libraries/functional-tests/functionaltestbot/test.sh diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..c424c7f01 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,61 @@ +trigger: + branches: + include: + - daveta-python-functional + exclude: + - master + +variables: + # Container registry service connection established during pipeline creation + dockerRegistryServiceConnection: 'NightlyE2E-Acr' + azureRmServiceConnection: 'NightlyE2E-RM' + dockerFilePath: 'libraries/functional-tests/functionaltestbot/Dockerfile' + buildIdTag: $(Build.BuildNumber) + webAppName: 'e2epython' + containerRegistry: 'nightlye2etest.azurecr.io' + imageRepository: 'functionaltestpy' + + + + +jobs: +# Build and publish container +- job: Build + pool: + vmImage: 'Ubuntu-16.04' + displayName: Build and push bot image + continueOnError: false + steps: + - task: Docker@2 + displayName: Build and push bot image + inputs: + command: buildAndPush + repository: $(imageRepository) + dockerfile: $(dockerFilePath) + containerRegistry: $(dockerRegistryServiceConnection) + tags: $(buildIdTag) + + + +- job: Deploy + displayName: Provision bot container + pool: + vmImage: 'Ubuntu-16.04' + dependsOn: + - Build + steps: + - task: AzureRMWebAppDeployment@4 + displayName: Python Functional E2E test. + inputs: + ConnectionType: AzureRM + ConnectedServiceName: $(azureRmServiceConnection) + appType: webAppContainer + WebAppName: $(webAppName) + DockerNamespace: $(containerRegistry) + DockerRepository: $(imageRepository) + DockerImageTag: $(buildIdTag) + AppSettings: '-MicrosoftAppId $(botAppId) -MicrosoftAppPassword $(botAppPassword) -FLASK_APP /functionaltestbot/app.py -FLASK_DEBUG 1' + + #StartupCommand: 'flask run --host=0.0.0.0 --port=3978' + + diff --git a/libraries/functional-tests/functionaltestbot/Dockerfile b/libraries/functional-tests/functionaltestbot/Dockerfile new file mode 100644 index 000000000..3364fc380 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/Dockerfile @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +FROM tiangolo/uwsgi-nginx-flask:python3.6 + + +RUN mkdir /functionaltestbot + +EXPOSE 443 +# EXPOSE 2222 + +COPY ./functionaltestbot /functionaltestbot +COPY setup.py / +COPY test.sh / +# RUN ls -ltr +# RUN cat prestart.sh +# RUN cat main.py + +ENV FLASK_APP=/functionaltestbot/app.py +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PATH ${PATH}:/home/site/wwwroot + +WORKDIR / + +# Initialize the bot +RUN pip3 install -e . + +# ssh +ENV SSH_PASSWD "root:Docker!" +RUN apt-get update \ + && apt-get install -y --no-install-recommends dialog \ + && apt-get update \ + && apt-get install -y --no-install-recommends openssh-server \ + && echo "$SSH_PASSWD" | chpasswd \ + && apt install -y --no-install-recommends vim +COPY sshd_config /etc/ssh/ +COPY init.sh /usr/local/bin/ +RUN chmod u+x /usr/local/bin/init.sh + +# For Debugging, uncomment the following: +# ENTRYPOINT ["python3.6", "-c", "import time ; time.sleep(500000)"] +ENTRYPOINT ["init.sh"] + +# For Devops, they don't like entry points. This is now in the devops +# pipeline. +# ENTRYPOINT [ "flask" ] +# CMD [ "run", "--port", "3978", "--host", "0.0.0.0" ] diff --git a/libraries/functional-tests/functionaltestbot/client_driver/README.md b/libraries/functional-tests/functionaltestbot/client_driver/README.md new file mode 100644 index 000000000..317a457c9 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/client_driver/README.md @@ -0,0 +1,5 @@ +# Client Driver for Function E2E test + +This contains the client code that drives the bot functional test. + +It performs simple operations against the bot and validates results. \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md b/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md new file mode 100644 index 000000000..996e0909b --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md @@ -0,0 +1,35 @@ +# Console EchoBot +Bot Framework v4 console echo sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that you can talk to from the console window. + +This sample shows a simple echo bot and demonstrates the bot working as a console app using a sample console adapter. + +## To try this sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` + + +### Visual studio code +- open `botbuilder-python\samples\01.console-echo` folder +- Bring up a terminal, navigate to `botbuilder-python\samples\01.console-echo` folder +- type 'python main.py' + + +# Adapters +[Adapters](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#the-bot-adapter) provide an abstraction for your bot to work with a variety of environments. + +A bot is directed by it's adapter, which can be thought of as the conductor for your bot. The adapter is responsible for directing incoming and outgoing communication, authentication, and so on. The adapter differs based on it's environment (the adapter internally works differently locally versus on Azure) but in each instance it achieves the same goal. + +In most situations we don't work with the adapter directly, such as when creating a bot from a template, but it's good to know it's there and what it does. +The bot adapter encapsulates authentication processes and sends activities to and receives activities from the Bot Connector Service. When your bot receives an activity, the adapter wraps up everything about that activity, creates a [context object](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#turn-context), passes it to your bot's application logic, and sends responses generated by your bot back to the user's channel. + + +# Further reading + +- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Bot basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) +- [Channels and Bot Connector service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) +- [Activity processing](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py new file mode 100644 index 000000000..223c72f3d --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Package information.""" +import os + +__title__ = "functionaltestbot" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py new file mode 100644 index 000000000..071a17d2b --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MessageFactory, + TurnContext, +) +from botbuilder.schema import Activity, InputHints +from bot import MyBot + +# 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. +# pylint: disable=unused-argument +async def on_error(self, 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) + + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create the main dialog +BOT = MyBot() + +# Listen for incoming requests on GET / for Azure monitoring +@APP.route("/", methods=["GET"]) +def ping(): + return Response(status=200) + + +# 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 "" + ) + + async def aux_func(turn_context): + await BOT.on_turn(turn_context) + + try: + task = LOOP.create_task( + ADAPTER.process_activity(activity, auth_header, aux_func) + ) + 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/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py new file mode 100644 index 000000000..128f47cf6 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/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/functionaltestbot/config.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/config.py new file mode 100644 index 000000000..a3bd72174 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/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 = 443 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt new file mode 100644 index 000000000..2e5ecf3fc --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt @@ -0,0 +1,3 @@ +botbuilder-core>=4.5.0.b4 +flask>=1.0.3 + diff --git a/libraries/functional-tests/functionaltestbot/init.sh b/libraries/functional-tests/functionaltestbot/init.sh new file mode 100644 index 000000000..4a5a5be78 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/init.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Starting SSH ..." +service ssh start + +# flask run --port 3978 --host 0.0.0.0 +python /functionaltestbot/app.py --host 0.0.0.0 \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py new file mode 100644 index 000000000..359052349 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "botbuilder-core>=4.5.0.b4", + "flask>=1.0.3", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "functionaltestbot", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords="botframework azure botbuilder", + long_description=package_info["__summary__"], + license=package_info["__license__"], + packages=["functionaltestbot"], + install_requires=REQUIRES, + dependency_links=["https://github.com/pytorch/pytorch"], + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/libraries/functional-tests/functionaltestbot/sshd_config b/libraries/functional-tests/functionaltestbot/sshd_config new file mode 100644 index 000000000..7afb7469f --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/sshd_config @@ -0,0 +1,21 @@ +# +# /etc/ssh/sshd_config +# + +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PrintMotd no +IgnoreRhosts no +#deprecated option +#RhostsAuthentication no +RhostsRSAAuthentication yes +RSAAuthentication no +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/test.sh b/libraries/functional-tests/functionaltestbot/test.sh new file mode 100644 index 000000000..1c987232e --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/test.sh @@ -0,0 +1 @@ +curl -X POST --header 'Accept: application/json' -d '{"text": "Hi!"}' http://localhost:3979 From 6d847c51dc27fcbb9153e4cceea3f814bbcc7f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 30 Oct 2019 10:48:01 -0700 Subject: [PATCH 017/616] Session injection supported (#378) --- .../auth/microsoft_app_credentials.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 05a4f1cd4..5998e87c3 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -3,8 +3,9 @@ from datetime import datetime, timedelta from urllib.parse import urlparse -from msrest.authentication import BasicTokenAuthentication, Authentication import requests + +from msrest.authentication import Authentication from .constants import Constants # TODO: Decide to move this to Constants or viceversa (when porting OAuth) @@ -82,20 +83,25 @@ def __init__(self, app_id: str, password: str, channel_auth_tenant: str = None): self.oauth_scope = AUTH_SETTINGS["refreshScope"] self.token_cache_key = app_id + "-cache" - def signed_session(self) -> requests.Session: # pylint: disable=arguments-differ + # pylint: disable=arguments-differ + def signed_session(self, session: requests.Session = None) -> requests.Session: """ Gets the signed session. :returns: Signed requests.Session object """ - auth_token = self.get_access_token() - - basic_authentication = BasicTokenAuthentication({"access_token": auth_token}) - session = basic_authentication.signed_session() + if not session: + session = requests.Session() # If there is no microsoft_app_id and no self.microsoft_app_password, then there shouldn't # be an "Authorization" header on the outgoing activity. if not self.microsoft_app_id and not self.microsoft_app_password: - del session.headers["Authorization"] + session.headers.pop("Authorization", None) + + elif not session.headers.get("Authorization"): + auth_token = self.get_access_token() + header = "{} {}".format("Bearer", auth_token) + session.headers["Authorization"] = header + return session def get_access_token(self, force_refresh: bool = False) -> str: From 6731036469910b69034c6f899ad504e3ffff0c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 30 Oct 2019 12:30:06 -0700 Subject: [PATCH 018/616] Core bot generator (#375) * Initial template for echo bot * Added template zip file * Added template zip file * Readme for top level generators folder * Pylint fixes for generator, readme updates * Adding empty bot generator, ported latest on_error changes * PR changes addressed * Initial push for core template * Core bot with linting * Fixes on core generator * Downgraded to 3.6 --- .../app/templates/core/cookiecutter.json | 4 + .../core/{{cookiecutter.bot_name}}/.pylintrc | 498 ++++++++++++++++++ .../{{cookiecutter.bot_name}}/README-LUIS.md | 216 ++++++++ .../core/{{cookiecutter.bot_name}}/README.md | 61 +++ .../{{cookiecutter.bot_name}}/__init__.py | 2 + .../core/{{cookiecutter.bot_name}}/app.py | 110 ++++ .../booking_details.py | 18 + .../bots/__init__.py | 7 + .../bots/dialog_and_welcome_bot.py | 42 ++ .../bots/dialog_bot.py | 42 ++ .../cards/welcomeCard.json | 46 ++ .../cognitiveModels/FlightBooking.json | 339 ++++++++++++ .../core/{{cookiecutter.bot_name}}/config.py | 16 + .../dialogs/__init__.py | 9 + .../dialogs/booking_dialog.py | 137 +++++ .../dialogs/cancel_and_help_dialog.py | 44 ++ .../dialogs/date_resolver_dialog.py | 79 +++ .../dialogs/main_dialog.py | 133 +++++ .../flight_booking_recognizer.py | 32 ++ .../helpers/__init__.py | 11 + .../helpers/dialog_helper.py | 19 + .../helpers/luis_helper.py | 104 ++++ .../requirements.txt | 5 + .../echo/{{cookiecutter.bot_name}}/.pylintrc | 5 +- .../echo/{{cookiecutter.bot_name}}/app.py | 1 + .../empty/{{cookiecutter.bot_name}}/.pylintrc | 5 +- .../empty/{{cookiecutter.bot_name}}/app.py | 1 + 27 files changed, 1984 insertions(+), 2 deletions(-) create mode 100644 generators/app/templates/core/cookiecutter.json create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/README.md create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/app.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/config.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py create mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt diff --git a/generators/app/templates/core/cookiecutter.json b/generators/app/templates/core/cookiecutter.json new file mode 100644 index 000000000..4a14b6ade --- /dev/null +++ b/generators/app/templates/core/cookiecutter.json @@ -0,0 +1,4 @@ +{ + "bot_name": "my_chat_bot", + "bot_description": "Demonstrate the core capabilities of the Microsoft Bot Framework" +} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc new file mode 100644 index 000000000..9c1c70f04 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc @@ -0,0 +1,498 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore= + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=missing-docstring, + too-few-public-methods, + bad-continuation, + no-self-use, + duplicate-code, + broad-except, + no-name-in-module + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[LOGGING] + +# Format style used to check logging format string. `old` means using % +# formatting, while `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[STRING] + +# This flag controls whether the implicit-str-concat-in-sequence should +# generate a warning on implicit string concatenation in sequences defined over +# several lines. +check-str-concat-over-line-jumps=no + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md b/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md new file mode 100644 index 000000000..b6b9b925f --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md @@ -0,0 +1,216 @@ +# Setting up LUIS via CLI: + +This README contains information on how to create and deploy a LUIS application. When the bot is ready to be deployed to production, we recommend creating a LUIS Endpoint Resource for usage with your LUIS App. + +> _For instructions on how to create a LUIS Application via the LUIS portal, see these Quickstart steps:_ +> 1. _[Quickstart: Create a new app in the LUIS portal][Quickstart-create]_ +> 2. _[Quickstart: Deploy an app in the LUIS portal][Quickstart-deploy]_ + + [Quickstart-create]: https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-build-app + [Quickstart-deploy]:https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-deploy-app + +## Table of Contents: + +- [Prerequisites](#Prerequisites) +- [Import a new LUIS Application using a local LUIS application](#Import-a-new-LUIS-Application-using-a-local-LUIS-application) +- [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#How-to-create-a-LUIS-Endpoint-resource-in-Azure-and-pair-it-with-a-LUIS-Application) + +___ + +## [Prerequisites](#Table-of-Contents): + +#### Install Azure CLI >=2.0.61: + +Visit the following page to find the correct installer for your OS: +- https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest + +#### Install LUIS CLI >=2.4.0: + +Open a CLI of your choice and type the following: + +```bash +npm i -g luis-apis@^2.4.0 +``` + +#### LUIS portal account: + +You should already have a LUIS account with either https://luis.ai, https://eu.luis.ai, or https://au.luis.ai. To determine where to create a LUIS account, consider where you will deploy your LUIS applications, and then place them in [the corresponding region][LUIS-Authoring-Regions]. + +After you've created your account, you need your [Authoring Key][LUIS-AKey] and a LUIS application ID. + + [LUIS-Authoring-Regions]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-reference-regions#luis-authoring-regions] + [LUIS-AKey]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-concept-keys#authoring-key + +___ + +## [Import a new LUIS Application using a local LUIS application](#Table-of-Contents) + +### 1. Import the local LUIS application to luis.ai + +```bash +luis import application --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appName "FlightBooking" --in "./cognitiveModels/FlightBooking.json" +``` + +Outputs the following JSON: + +```json +{ + "id": "########-####-####-####-############", + "name": "FlightBooking", + "description": "A LUIS model that uses intent and entities.", + "culture": "en-us", + "usageScenario": "", + "domain": "", + "versionsCount": 1, + "createdDateTime": "2019-03-29T18:32:02Z", + "endpoints": {}, + "endpointHitsCount": 0, + "activeVersion": "0.1", + "ownerEmail": "bot@contoso.com", + "tokenizerVersion": "1.0.0" +} +``` + +For the next step, you'll need the `"id"` value for `--appId` and the `"activeVersion"` value for `--versionId`. + +### 2. Train the LUIS Application + +```bash +luis train version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --wait +``` + +### 3. Publish the LUIS Application + +```bash +luis publish version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --publishRegion "LuisAppPublishRegion" +``` + +> `--region` corresponds to the region you _author_ your application in. The regions available for this are "westus", "westeurope" and "australiaeast".
+> These regions correspond to the three available portals, https://luis.ai, https://eu.luis.ai, or https://au.luis.ai.
+> `--publishRegion` corresponds to the region of the endpoint you're publishing to, (e.g. "westus", "southeastasia", "westeurope", "brazilsouth").
+> See the [reference docs][Endpoint-API] for a list of available publish/endpoint regions. + + [Endpoint-API]: https://westus.dev.cognitive.microsoft.com/docs/services/5819c76f40a6350ce09de1ac/operations/5819c77140a63516d81aee78 + +Outputs the following: + +```json + { + "versionId": "0.1", + "isStaging": false, + "endpointUrl": "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/########-####-####-####-############", + "region": "westus", + "assignedEndpointKey": null, + "endpointRegion": "westus", + "failedRegions": "", + "publishedDateTime": "2019-03-29T18:40:32Z", + "directVersionPublish": false +} +``` + +To see how to create an LUIS Cognitive Service Resource in Azure, please see [the next README][README-LUIS]. This Resource should be used when you want to move your bot to production. The instructions will show you how to create and pair the resource with a LUIS Application. + + [README-LUIS]: ./README-LUIS.md + +___ + +## [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#Table-of-Contents) + +### 1. Create a new LUIS Cognitive Services resource on Azure via Azure CLI + +> _Note:_
+> _If you don't have a Resource Group in your Azure subscription, you can create one through the Azure portal or through using:_ +> ```bash +> az group create --subscription "AzureSubscriptionGuid" --location "westus" --name "ResourceGroupName" +> ``` +> _To see a list of valid locations, use `az account list-locations`_ + + +```bash +# Use Azure CLI to create the LUIS Key resource on Azure +az cognitiveservices account create --kind "luis" --name "NewLuisResourceName" --sku "S0" --location "westus" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" +``` + +The command will output a response similar to the JSON below: + +```json +{ + "endpoint": "https://westus.api.cognitive.microsoft.com/luis/v2.0", + "etag": "\"########-####-####-####-############\"", + "id": "/subscriptions/########-####-####-####-############/resourceGroups/ResourceGroupName/providers/Microsoft.CognitiveServices/accounts/NewLuisResourceName", + "internalId": "################################", + "kind": "luis", + "location": "westus", + "name": "NewLuisResourceName", + "provisioningState": "Succeeded", + "resourceGroup": "ResourceGroupName", + "sku": { + "name": "S0", + "tier": null + }, + "tags": null, + "type": "Microsoft.CognitiveServices/accounts" +} +``` + + + +Take the output from the previous command and create a JSON file in the following format: + +```json +{ + "azureSubscriptionId": "00000000-0000-0000-0000-000000000000", + "resourceGroup": "ResourceGroupName", + "accountName": "NewLuisResourceName" +} +``` + +### 2. Retrieve ARM access token via Azure CLI + +```bash +az account get-access-token --subscription "AzureSubscriptionGuid" +``` + +This will return an object that looks like this: + +```json +{ + "accessToken": "eyJ0eXAiOiJKVtokentokentokentokentokeng1dCI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyIsItokenI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTUzODc3MTUwLCJuYmYiOjE1NTM4NzcxNTAsImV4cCI6MTU1Mzg4MTA1MCwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2ZmZTQyM2RkLWJhM2YtNDg0Ny04NjgyLWExNTI5MDA4MjM4Ny9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVZRQXEvOEtBQUFBeGVUc201NDlhVHg4RE1mMFlRVnhGZmxxOE9RSC9PODR3QktuSmRqV1FqTkkwbmxLYzB0bHJEZzMyMFZ5bWZGaVVBSFBvNUFFUTNHL0FZNDRjdk01T3M0SEt0OVJkcE5JZW9WU0dzd0kvSkk9IiwiYW1yIjpbIndpYSIsIm1mYSJdLCJhcHBpZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiNDhmNDVjNjEtMTg3Zi00MjUxLTlmZWItMTllZGFkZmMwMmE3IiwiZmFtaWx5X25hbWUiOiJHdW0iLCJnaXZlbl9uYW1lIjoiU3RldmVuIiwiaXBhZGRyIjoiMTY3LjIyMC4yLjU1IiwibmFtZSI6IlN0ZXZlbiBHdW0iLCJvaWQiOiJmZmU0MjNkZC1iYTNmLTQ4NDctODY4Mi1hMTUyOTAwODIzODciLCJvbnByZW1fc2lkIjoiUy0xLTUtMjEtMjEyNzUyMTE4NC0xNjA0MDEyOTIwLTE4ODc5Mjc1MjctMjYwOTgyODUiLCJwdWlkIjoiMTAwMzdGRkVBMDQ4NjlBNyIsInJoIjoiSSIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ik1rMGRNMWszN0U5ckJyMjhieUhZYjZLSU85LXVFQVVkZFVhNWpkSUd1Nk0iLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InN0Z3VtQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzdGd1bUBtaWNyb3NvZnQuY29tIiwidXRpIjoiT2w2NGN0TXY4RVNEQzZZQWRqRUFtokenInZlciI6IjEuMCJ9.kFAsEilE0mlS1pcpqxf4rEnRKeYsehyk-gz-zJHUrE__oad3QjgDSBDPrR_ikLdweynxbj86pgG4QFaHURNCeE6SzrbaIrNKw-n9jrEtokenlosOxg_0l2g1LeEUOi5Q4gQREAU_zvSbl-RY6sAadpOgNHtGvz3Rc6FZRITfkckSLmsKAOFoh-aWC6tFKG8P52rtB0qVVRz9tovBeNqkMYL49s9ypduygbXNVwSQhm5JszeWDgrFuVFHBUP_iENCQYGQpEZf_KvjmX1Ur1F9Eh9nb4yI2gFlKncKNsQl-tokenK7-tokentokentokentokentokentokenatoken", + "expiresOn": "2200-12-31 23:59:59.999999", + "subscription": "AzureSubscriptionGuid", + "tenant": "tenant-guid", + "tokenType": "Bearer" +} +``` + +The value needed for the next step is the `"accessToken"`. + +### 3. Use `luis add appazureaccount` to pair your LUIS resource with a LUIS Application + +```bash +luis add appazureaccount --in "path/to/created/requestBody.json" --appId "LuisAppId" --authoringKey "LuisAuthoringKey" --armToken "accessToken" +``` + +If successful, it should yield a response like this: + +```json +{ + "code": "Success", + "message": "Operation Successful" +} +``` + +### 4. See the LUIS Cognitive Services' keys + +```bash +az cognitiveservices account keys list --name "NewLuisResourceName" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" +``` + +This will return an object that looks like this: + +```json +{ + "key1": "9a69####dc8f####8eb4####399f####", + "key2": "####f99e####4b1a####fb3b####6b9f" +} +``` \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md b/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md new file mode 100644 index 000000000..35a5eb2f1 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md @@ -0,0 +1,61 @@ +# CoreBot + +Bot Framework v4 core bot sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to: + +- Use [LUIS](https://www.luis.ai) to implement core AI capabilities +- Implement a multi-turn conversation using Dialogs +- Handle user interruptions for such things as `Help` or `Cancel` +- Prompt for and validate requests for information from the user + +## Prerequisites + +This sample **requires** prerequisites in order to run. + +### Overview + +This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding. + +### Install Python 3.6 + + +### Create a LUIS Application to enable language understanding + +LUIS language model setup, training, and application configuration steps can be found [here](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=cs). + +If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). + +## Running the sample +- Run `pip install -r requirements.txt` to install all dependencies +- Update LuisAppId, LuisAPIKey and LuisAPIHostName in `config.py` with the information retrieved from the [LUIS portal](https://www.luis.ai) +- Run `python app.py` +- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` + + +## 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 +- Enter a Bot URL of `http://localhost:3978/api/messages` + + +## 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) +- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) +- [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) +- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) +- [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/azure/cognitive-services/luis/) +- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py new file mode 100644 index 000000000..d08cff888 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=import-error + +""" +This sample shows how to create a bot that demonstrates the following: +- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. +- Implement a multi-turn conversation using Dialogs. +- Handle user interruptions for such things as `Help` or `Cancel`. +- Prompt for and validate requests for information from the user. +""" + +import asyncio +import sys +from datetime import datetime +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, + TurnContext +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import DialogAndWelcomeBot +from dialogs import MainDialog, BookingDialog +from flight_booking_recognizer import FlightBookingRecognizer + +LOOP = asyncio.get_event_loop() +APP = Flask(__name__, instance_relative_config=True) +APP.config.from_object("config.DefaultConfig") + +SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) +RECOGNIZER = FlightBookingRecognizer(APP.config) +BOOKING_DIALOG = BookingDialog() + +# 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. +# pylint: disable=unused-argument +async def on_error(self, 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 encounted 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 = MethodType(on_error, ADAPTER) + +DIALOG = MainDialog(RECOGNIZER, BOOKING_DIALOG) +BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +@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/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py new file mode 100644 index 000000000..ca0710ff0 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + + +class BookingDetails: + def __init__( + self, + destination: str = None, + origin: str = None, + travel_date: str = None, + unsupported_airports: List[str] = None, + ): + self.destination = destination + self.origin = origin + self.travel_date = travel_date + self.unsupported_airports = unsupported_airports or [] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py new file mode 100644 index 000000000..6925db302 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot +from .dialog_and_welcome_bot import DialogAndWelcomeBot + +__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py new file mode 100644 index 000000000..17bb2db80 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os.path + +from typing import List +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import Attachment, ChannelAccount + +from helpers import DialogHelper +from .dialog_bot import DialogBot + + +class DialogAndWelcomeBot(DialogBot): + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + # Greet anyone that was not the target (recipient) of this message. + # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. + if member.id != turn_context.activity.recipient.id: + welcome_card = self.create_adaptive_card_attachment() + response = MessageFactory.attachment(welcome_card) + await turn_context.send_activity(response) + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) + + # Load attachment from file. + def create_adaptive_card_attachment(self): + relative_path = os.path.abspath(os.path.dirname(__file__)) + path = os.path.join(relative_path, "../cards/welcomeCard.json") + with open(path) as card_file: + card = json.load(card_file) + + return Attachment( + content_type="application/vnd.microsoft.card.adaptive", content=card + ) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py new file mode 100644 index 000000000..5f2c148aa --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog + +from helpers import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog, + ): + if conversation_state is None: + raise Exception( + "[DialogBot]: Missing parameter. conversation_state is required" + ) + if user_state is None: + raise Exception("[DialogBot]: Missing parameter. user_state is required") + if dialog is None: + raise Exception("[DialogBot]: Missing parameter. dialog is required") + + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have occurred during the turn. + await self.conversation_state.save_changes(turn_context, False) + await self.user_state.save_changes(turn_context, False) + + async def on_message_activity(self, turn_context: TurnContext): + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json b/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json new file mode 100644 index 000000000..cc10cda9f --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Image", + "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", + "size": "stretch" + }, + { + "type": "TextBlock", + "spacing": "medium", + "size": "default", + "weight": "bolder", + "text": "Welcome to Bot Framework!", + "wrap": true, + "maxLines": 0 + }, + { + "type": "TextBlock", + "size": "default", + "isSubtle": "true", + "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", + "wrap": true, + "maxLines": 0 + } + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Get an overview", + "url": "https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0" + }, + { + "type": "Action.OpenUrl", + "title": "Ask a question", + "url": "https://stackoverflow.com/questions/tagged/botframework" + }, + { + "type": "Action.OpenUrl", + "title": "Learn how to deploy", + "url": "https://docs.microsoft.com/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" + } + ] +} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json b/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json new file mode 100644 index 000000000..f0e4b9770 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json @@ -0,0 +1,339 @@ +{ + "luis_schema_version": "3.2.0", + "versionId": "0.1", + "name": "FlightBooking", + "desc": "Luis Model for CoreBot", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "intents": [ + { + "name": "BookFlight" + }, + { + "name": "Cancel" + }, + { + "name": "GetWeather" + }, + { + "name": "None" + } + ], + "entities": [], + "composites": [ + { + "name": "From", + "children": [ + "Airport" + ], + "roles": [] + }, + { + "name": "To", + "children": [ + "Airport" + ], + "roles": [] + } + ], + "closedLists": [ + { + "name": "Airport", + "subLists": [ + { + "canonicalForm": "Paris", + "list": [ + "paris", + "cdg" + ] + }, + { + "canonicalForm": "London", + "list": [ + "london", + "lhr" + ] + }, + { + "canonicalForm": "Berlin", + "list": [ + "berlin", + "txl" + ] + }, + { + "canonicalForm": "New York", + "list": [ + "new york", + "jfk" + ] + }, + { + "canonicalForm": "Seattle", + "list": [ + "seattle", + "sea" + ] + } + ], + "roles": [] + } + ], + "patternAnyEntities": [], + "regex_entities": [], + "prebuiltEntities": [ + { + "name": "datetimeV2", + "roles": [] + } + ], + "model_features": [], + "regex_features": [], + "patterns": [], + "utterances": [ + { + "text": "book a flight", + "intent": "BookFlight", + "entities": [] + }, + { + "text": "book a flight from new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 26 + } + ] + }, + { + "text": "book a flight from seattle", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 25 + } + ] + }, + { + "text": "book a hotel in new york", + "intent": "None", + "entities": [] + }, + { + "text": "book a restaurant", + "intent": "None", + "entities": [] + }, + { + "text": "book flight from london to paris on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 17, + "endPos": 22 + }, + { + "entity": "To", + "startPos": 27, + "endPos": 31 + } + ] + }, + { + "text": "book flight to berlin on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 15, + "endPos": 20 + } + ] + }, + { + "text": "book me a flight from london to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 22, + "endPos": 27 + }, + { + "entity": "To", + "startPos": 32, + "endPos": 36 + } + ] + }, + { + "text": "bye", + "intent": "Cancel", + "entities": [] + }, + { + "text": "cancel booking", + "intent": "Cancel", + "entities": [] + }, + { + "text": "exit", + "intent": "Cancel", + "entities": [] + }, + { + "text": "find an airport near me", + "intent": "None", + "entities": [] + }, + { + "text": "flight to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "flight to paris from london on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + }, + { + "entity": "From", + "startPos": 21, + "endPos": 26 + } + ] + }, + { + "text": "fly from berlin to paris on may 5th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 9, + "endPos": 14 + }, + { + "entity": "To", + "startPos": 19, + "endPos": 23 + } + ] + }, + { + "text": "go to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 6, + "endPos": 10 + } + ] + }, + { + "text": "going from paris to berlin", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 11, + "endPos": 15 + }, + { + "entity": "To", + "startPos": 20, + "endPos": 25 + } + ] + }, + { + "text": "i'd like to rent a car", + "intent": "None", + "entities": [] + }, + { + "text": "ignore", + "intent": "Cancel", + "entities": [] + }, + { + "text": "travel from new york to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 12, + "endPos": 19 + }, + { + "entity": "To", + "startPos": 24, + "endPos": 28 + } + ] + }, + { + "text": "travel to new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 17 + } + ] + }, + { + "text": "travel to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "what's the forecast for this friday?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like for tomorrow", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like in new york", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "winter is coming", + "intent": "None", + "entities": [] + } + ], + "settings": [] +} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py new file mode 100644 index 000000000..8df9f92c8 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py @@ -0,0 +1,16 @@ +#!/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", "") + LUIS_APP_ID = os.environ.get("LuisAppId", "") + LUIS_API_KEY = os.environ.get("LuisAPIKey", "") + # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" + LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py new file mode 100644 index 000000000..567539f96 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .booking_dialog import BookingDialog +from .cancel_and_help_dialog import CancelAndHelpDialog +from .date_resolver_dialog import DateResolverDialog +from .main_dialog import MainDialog + +__all__ = ["BookingDialog", "CancelAndHelpDialog", "DateResolverDialog", "MainDialog"] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py new file mode 100644 index 000000000..c5912075d --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions +from botbuilder.core import MessageFactory +from botbuilder.schema import InputHints + +from datatypes_date_time.timex import Timex + +from .cancel_and_help_dialog import CancelAndHelpDialog +from .date_resolver_dialog import DateResolverDialog + + +class BookingDialog(CancelAndHelpDialog): + def __init__(self, dialog_id: str = None): + super(BookingDialog, self).__init__(dialog_id or BookingDialog.__name__) + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + self.add_dialog(DateResolverDialog(DateResolverDialog.__name__)) + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, + [ + self.destination_step, + self.origin_step, + self.travel_date_step, + self.confirm_step, + self.final_step, + ], + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def destination_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + If a destination city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + if booking_details.destination is None: + message_text = "Where would you like to travel to?" + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + return await step_context.next(booking_details.destination) + + async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + If an origin city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the response to the previous step's prompt + booking_details.destination = step_context.result + if booking_details.origin is None: + message_text = "From what city will you be travelling?" + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + return await step_context.next(booking_details.origin) + + async def travel_date_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + If a travel date has not been provided, prompt for one. + This will use the DATE_RESOLVER_DIALOG. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.origin = step_context.result + if not booking_details.travel_date or self.is_ambiguous( + booking_details.travel_date + ): + return await step_context.begin_dialog( + DateResolverDialog.__name__, booking_details.travel_date + ) + return await step_context.next(booking_details.travel_date) + + async def confirm_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + Confirm the information the user has provided. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.travel_date = step_context.result + message_text = ( + f"Please confirm, I have you traveling to: { booking_details.destination } from: " + f"{ booking_details.origin } on: { booking_details.travel_date}." + ) + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + + # Offer a YES/NO prompt. + return await step_context.prompt( + ConfirmPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + Complete the interaction and end the dialog. + :param step_context: + :return DialogTurnResult: + """ + if step_context.result: + booking_details = step_context.options + + return await step_context.end_dialog(booking_details) + return await step_context.end_dialog() + + def is_ambiguous(self, timex: str) -> bool: + timex_property = Timex(timex) + return "definite" not in timex_property.types diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py new file mode 100644 index 000000000..f09a63b62 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogContext, + DialogTurnResult, + DialogTurnStatus, +) +from botbuilder.schema import ActivityTypes, InputHints +from botbuilder.core import MessageFactory + + +class CancelAndHelpDialog(ComponentDialog): + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + result = await self.interrupt(inner_dc) + if result is not None: + return result + + return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) + + async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: + if inner_dc.context.activity.type == ActivityTypes.message: + text = inner_dc.context.activity.text.lower() + + help_message_text = "Show Help..." + help_message = MessageFactory.text( + help_message_text, help_message_text, InputHints.expecting_input + ) + + if text in ("help", "?"): + await inner_dc.context.send_activity(help_message) + return DialogTurnResult(DialogTurnStatus.Waiting) + + cancel_message_text = "Cancelling" + cancel_message = MessageFactory.text( + cancel_message_text, cancel_message_text, InputHints.ignoring_input + ) + + if text in ("cancel", "quit"): + await inner_dc.context.send_activity(cancel_message) + return await inner_dc.cancel_all_dialogs() + + return None diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py new file mode 100644 index 000000000..985dbf389 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext +from botbuilder.dialogs.prompts import ( + DateTimePrompt, + PromptValidatorContext, + PromptOptions, + DateTimeResolution, +) +from botbuilder.schema import InputHints +from datatypes_date_time.timex import Timex + +from .cancel_and_help_dialog import CancelAndHelpDialog + + +class DateResolverDialog(CancelAndHelpDialog): + def __init__(self, dialog_id: str = None): + super(DateResolverDialog, self).__init__( + dialog_id or DateResolverDialog.__name__ + ) + + self.add_dialog( + DateTimePrompt( + DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator + ) + ) + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__ + "2", [self.initial_step, self.final_step] + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + "2" + + async def initial_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + timex = step_context.options + + prompt_msg_text = "On what date would you like to travel?" + prompt_msg = MessageFactory.text( + prompt_msg_text, prompt_msg_text, InputHints.expecting_input + ) + + reprompt_msg_text = "I'm sorry, for best results, please enter your travel date " \ + "including the month, day and year." + reprompt_msg = MessageFactory.text( + reprompt_msg_text, reprompt_msg_text, InputHints.expecting_input + ) + + if timex is None: + # We were not given any date at all so prompt the user. + return await step_context.prompt( + DateTimePrompt.__name__, + PromptOptions(prompt=prompt_msg, retry_prompt=reprompt_msg), + ) + # We have a Date we just need to check it is unambiguous. + if "definite" not in Timex(timex).types: + # This is essentially a "reprompt" of the data we were given up front. + return await step_context.prompt( + DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) + ) + + return await step_context.next(DateTimeResolution(timex=timex)) + + async def final_step(self, step_context: WaterfallStepContext): + timex = step_context.result[0].timex + return await step_context.end_dialog(timex) + + @staticmethod + async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + if prompt_context.recognized.succeeded: + timex = prompt_context.recognized.value[0].timex.split("T")[0] + + return "definite" in Timex(timex).types + + return False diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py new file mode 100644 index 000000000..91566728d --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.dialogs.prompts import TextPrompt, PromptOptions +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import InputHints + +from booking_details import BookingDetails +from flight_booking_recognizer import FlightBookingRecognizer + +from helpers import LuisHelper, Intent +from .booking_dialog import BookingDialog + + +class MainDialog(ComponentDialog): + def __init__( + self, luis_recognizer: FlightBookingRecognizer, booking_dialog: BookingDialog + ): + super(MainDialog, self).__init__(MainDialog.__name__) + + self._luis_recognizer = luis_recognizer + self._booking_dialog_id = booking_dialog.id + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(booking_dialog) + self.add_dialog( + WaterfallDialog( + "WFDialog", [self.intro_step, self.act_step, self.final_step] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if not self._luis_recognizer.is_configured: + await step_context.context.send_activity( + MessageFactory.text( + "NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and " + "'LuisAPIHostName' to the appsettings.json file.", + input_hint=InputHints.ignoring_input, + ) + ) + + return await step_context.next(None) + message_text = ( + str(step_context.options) + if step_context.options + else "What can I help you with today?" + ) + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + + async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if not self._luis_recognizer.is_configured: + # LUIS is not configured, we just run the BookingDialog path with an empty BookingDetailsInstance. + return await step_context.begin_dialog( + self._booking_dialog_id, BookingDetails() + ) + + # Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.) + intent, luis_result = await LuisHelper.execute_luis_query( + self._luis_recognizer, step_context.context + ) + + if intent == Intent.BOOK_FLIGHT.value and luis_result: + # Show a warning for Origin and Destination if we can't resolve them. + await MainDialog._show_warning_for_unsupported_cities( + step_context.context, luis_result + ) + + # Run the BookingDialog giving it whatever details we have from the LUIS call. + return await step_context.begin_dialog(self._booking_dialog_id, luis_result) + + if intent == Intent.GET_WEATHER.value: + get_weather_text = "TODO: get weather flow here" + get_weather_message = MessageFactory.text( + get_weather_text, get_weather_text, InputHints.ignoring_input + ) + await step_context.context.send_activity(get_weather_message) + + else: + didnt_understand_text = ( + "Sorry, I didn't get that. Please try asking in a different way" + ) + didnt_understand_message = MessageFactory.text( + didnt_understand_text, didnt_understand_text, InputHints.ignoring_input + ) + await step_context.context.send_activity(didnt_understand_message) + + return await step_context.next(None) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # If the child dialog ("BookingDialog") was cancelled or the user failed to confirm, + # the Result here will be null. + if step_context.result is not None: + result = step_context.result + + # Now we have all the booking details call the booking service. + + # If the call to the booking service was successful tell the user. + # time_property = Timex(result.travel_date) + # travel_date_msg = time_property.to_natural_language(datetime.now()) + msg_txt = f"I have you booked to {result.destination} from {result.origin} on {result.travel_date}" + message = MessageFactory.text(msg_txt, msg_txt, InputHints.ignoring_input) + await step_context.context.send_activity(message) + + prompt_message = "What else can I do for you?" + return await step_context.replace_dialog(self.id, prompt_message) + + @staticmethod + async def _show_warning_for_unsupported_cities( + context: TurnContext, luis_result: BookingDetails + ) -> None: + if luis_result.unsupported_airports: + message_text = ( + f"Sorry but the following airports are not supported:" + f" {', '.join(luis_result.unsupported_airports)}" + ) + message = MessageFactory.text( + message_text, message_text, InputHints.ignoring_input + ) + await context.send_activity(message) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py new file mode 100644 index 000000000..7476103c7 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.ai.luis import LuisApplication, LuisRecognizer +from botbuilder.core import Recognizer, RecognizerResult, TurnContext + + +class FlightBookingRecognizer(Recognizer): + def __init__(self, configuration: dict): + self._recognizer = None + + luis_is_configured = ( + configuration["LUIS_APP_ID"] + and configuration["LUIS_API_KEY"] + and configuration["LUIS_API_HOST_NAME"] + ) + if luis_is_configured: + luis_application = LuisApplication( + configuration["LUIS_APP_ID"], + configuration["LUIS_API_KEY"], + "https://" + configuration["LUIS_API_HOST_NAME"], + ) + + self._recognizer = LuisRecognizer(luis_application) + + @property + def is_configured(self) -> bool: + # Returns true if luis is configured in the appsettings.json and initialized. + return self._recognizer is not None + + async def recognize(self, turn_context: TurnContext) -> RecognizerResult: + return await self._recognizer.recognize(turn_context) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py new file mode 100644 index 000000000..787a8ed1a --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .luis_helper import Intent, LuisHelper +from .dialog_helper import DialogHelper + +__all__ = [ + "DialogHelper", + "LuisHelper", + "Intent" +] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py new file mode 100644 index 000000000..30331a0d5 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from enum import Enum +from typing import Dict +from botbuilder.ai.luis import LuisRecognizer +from botbuilder.core import IntentScore, TopIntent, TurnContext + +from booking_details import BookingDetails + + +class Intent(Enum): + BOOK_FLIGHT = "BookFlight" + CANCEL = "Cancel" + GET_WEATHER = "GetWeather" + NONE_INTENT = "NoneIntent" + + +def top_intent(intents: Dict[Intent, dict]) -> TopIntent: + max_intent = Intent.NONE_INTENT + max_value = 0.0 + + for intent, value in intents: + intent_score = IntentScore(value) + if intent_score.score > max_value: + max_intent, max_value = intent, intent_score.score + + return TopIntent(max_intent, max_value) + + +class LuisHelper: + @staticmethod + async def execute_luis_query( + luis_recognizer: LuisRecognizer, turn_context: TurnContext + ) -> (Intent, object): + """ + Returns an object with pre-formatted LUIS results for the bot's dialogs to consume. + """ + result = None + intent = None + + try: + recognizer_result = await luis_recognizer.recognize(turn_context) + + intent = ( + sorted( + recognizer_result.intents, + key=recognizer_result.intents.get, + reverse=True, + )[:1][0] + if recognizer_result.intents + else None + ) + + if intent == Intent.BOOK_FLIGHT.value: + result = BookingDetails() + + # We need to get the result from the LUIS JSON which at every level + # returns an array. + to_entities = recognizer_result.entities.get("$instance", {}).get( + "To", [] + ) + if len(to_entities) > 0: + if recognizer_result.entities.get("To", [{"$instance": {}}])[0][ + "$instance" + ]: + result.destination = to_entities[0]["text"].capitalize() + else: + result.unsupported_airports.append( + to_entities[0]["text"].capitalize() + ) + + from_entities = recognizer_result.entities.get("$instance", {}).get( + "From", [] + ) + if len(from_entities) > 0: + if recognizer_result.entities.get("From", [{"$instance": {}}])[0][ + "$instance" + ]: + result.origin = from_entities[0]["text"].capitalize() + else: + result.unsupported_airports.append( + from_entities[0]["text"].capitalize() + ) + + # This value will be a TIMEX. And we are only interested in a Date so + # grab the first result and drop the Time part. + # TIMEX is a format that represents DateTime expressions that include + # some ambiguity. e.g. missing a Year. + date_entities = recognizer_result.entities.get("datetime", []) + if date_entities: + timex = date_entities[0]["timex"] + + if timex: + datetime = timex[0].split("T")[0] + + result.travel_date = datetime + + else: + result.travel_date = None + + except Exception as err: + print(err) + + return intent, result diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt b/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt new file mode 100644 index 000000000..c11eb2923 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt @@ -0,0 +1,5 @@ +botbuilder-dialogs>=4.4.0.b1 +botbuilder-ai>=4.4.0.b1 +datatypes-date-time>=1.0.0.a2 +flask>=1.0.3 + diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc index a77268c79..1baee5edb 100644 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc +++ b/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc @@ -62,7 +62,10 @@ confidence= # --disable=W". disable=missing-docstring, too-few-public-methods, - bad-continuation + bad-continuation, + no-self-use, + duplicate-code, + broad-except # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py index be9b70499..f7fa35cac 100644 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py +++ b/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py @@ -26,6 +26,7 @@ ADAPTER = BotFrameworkAdapter(SETTINGS) # Catch-all for errors. +# pylint: disable=unused-argument async def on_error(self, 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 diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc index a77268c79..1baee5edb 100644 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc +++ b/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc @@ -62,7 +62,10 @@ confidence= # --disable=W". disable=missing-docstring, too-few-public-methods, - bad-continuation + bad-continuation, + no-self-use, + duplicate-code, + broad-except # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py index 5dfdc30f1..4ab9d480f 100644 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py +++ b/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py @@ -25,6 +25,7 @@ ADAPTER = BotFrameworkAdapter(SETTINGS) # Catch-all for errors. +# pylint: disable=unused-argument async def on_error(self, 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 From 4df4e1fa76de2840513e022e4735899ae376a84e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 30 Oct 2019 14:37:46 -0500 Subject: [PATCH 019/616] Added 44.prompt-users-for-input (#380) --- samples/44.prompt-users-for-input/README.md | 37 +++++ samples/44.prompt-users-for-input/app.py | 99 +++++++++++++ .../bots/__init__.py | 6 + .../bots/custom_prompt_bot.py | 140 ++++++++++++++++++ samples/44.prompt-users-for-input/config.py | 15 ++ .../data_models/__init__.py | 7 + .../data_models/conversation_flow.py | 19 +++ .../data_models/user_profile.py | 9 ++ .../requirements.txt | 3 + 9 files changed, 335 insertions(+) create mode 100644 samples/44.prompt-users-for-input/README.md create mode 100644 samples/44.prompt-users-for-input/app.py create mode 100644 samples/44.prompt-users-for-input/bots/__init__.py create mode 100644 samples/44.prompt-users-for-input/bots/custom_prompt_bot.py create mode 100644 samples/44.prompt-users-for-input/config.py create mode 100644 samples/44.prompt-users-for-input/data_models/__init__.py create mode 100644 samples/44.prompt-users-for-input/data_models/conversation_flow.py create mode 100644 samples/44.prompt-users-for-input/data_models/user_profile.py create mode 100644 samples/44.prompt-users-for-input/requirements.txt diff --git a/samples/44.prompt-users-for-input/README.md b/samples/44.prompt-users-for-input/README.md new file mode 100644 index 000000000..527bb8a82 --- /dev/null +++ b/samples/44.prompt-users-for-input/README.md @@ -0,0 +1,37 @@ +# Prompt users for input + +This sample demonstrates how to create your own prompts with the Python Bot Framework. +The bot maintains conversation state to track and direct the conversation and ask the user questions. +The bot maintains user state to track the user's answers. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Bring up a terminal, navigate to `botbuilder-python\samples\44.prompt-user-for-input` 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 + + +## Bot State + +A key to good bot design is to track the context of a conversation, so that your bot remembers things like the answers to previous questions. Depending on what your bot is used for, you may even need to keep track of state or store information for longer than the lifetime of the conversation. A bot's state is information it remembers in order to respond appropriately to incoming messages. The Bot Builder SDK provides classes for storing and retrieving state data as an object associated with a user or a conversation. + +# Further reading + +- [Azure Bot Service Introduction](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?view=azure-bot-service-4.0) +- [Write directly to storage](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-v4-storage?view=azure-bot-service-4.0&tabs=csharpechorproperty%2Ccsetagoverwrite%2Ccsetag) +- [Managing conversation and user state](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-v4-state?view=azure-bot-service-4.0) +- [Microsoft Recognizers-Text](https://github.com/Microsoft/Recognizers-Text/tree/master/Python) \ No newline at end of file diff --git a/samples/44.prompt-users-for-input/app.py b/samples/44.prompt-users-for-input/app.py new file mode 100644 index 000000000..9e598f407 --- /dev/null +++ b/samples/44.prompt-users-for-input/app.py @@ -0,0 +1,99 @@ +# 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 ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import CustomPromptBot + +# 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(self, 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) + + # Clear out state + await CONVERSATION_STATE.delete(context) + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create Bot +BOT = CustomPromptBot(CONVERSATION_STATE, USER_STATE) + + +# 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 diff --git a/samples/44.prompt-users-for-input/bots/__init__.py b/samples/44.prompt-users-for-input/bots/__init__.py new file mode 100644 index 000000000..87a52e887 --- /dev/null +++ b/samples/44.prompt-users-for-input/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .custom_prompt_bot import CustomPromptBot + +__all__ = ["CustomPromptBot"] diff --git a/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py b/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py new file mode 100644 index 000000000..67cecfc54 --- /dev/null +++ b/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py @@ -0,0 +1,140 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime + +from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState, MessageFactory +from recognizers_number import recognize_number, Culture +from recognizers_date_time import recognize_datetime + +from data_models import ConversationFlow, Question, UserProfile + + +class ValidationResult: + def __init__(self, is_valid: bool = False, value: object = None, message: str = None): + self.is_valid = is_valid + self.value = value + self.message = message + + +class CustomPromptBot(ActivityHandler): + def __init__(self, conversation_state: ConversationState, user_state: UserState): + if conversation_state is None: + raise TypeError( + "[CustomPromptBot]: Missing parameter. conversation_state is required but None was given" + ) + if user_state is None: + raise TypeError( + "[CustomPromptBot]: Missing parameter. user_state is required but None was given" + ) + + self.conversation_state = conversation_state + self.user_state = user_state + + self.flow_accessor = self.conversation_state.create_property("ConversationFlow") + self.profile_accessor = self.conversation_state.create_property("UserProfile") + + async def on_message_activity(self, turn_context: TurnContext): + # Get the state properties from the turn context. + profile = await self.profile_accessor.get(turn_context, UserProfile) + flow = await self.flow_accessor.get(turn_context, ConversationFlow) + + await self._fill_out_user_profile(flow, profile, turn_context) + + # Save changes to UserState and ConversationState + await self.conversation_state.save_changes(turn_context) + await self.user_state.save_changes(turn_context) + + async def _fill_out_user_profile(self, flow: ConversationFlow, profile: UserProfile, turn_context: TurnContext): + user_input = turn_context.activity.text.strip() + + # ask for name + if flow.last_question_asked == Question.NONE: + await turn_context.send_activity(MessageFactory.text("Let's get started. What is your name?")) + flow.last_question_asked = Question.NAME + + # validate name then ask for age + elif flow.last_question_asked == Question.NAME: + validate_result = self._validate_name(user_input) + if not validate_result.is_valid: + await turn_context.send_activity(MessageFactory.text(validate_result.message)) + else: + profile.name = validate_result.value + await turn_context.send_activity(MessageFactory.text(f"Hi {profile.name}")) + await turn_context.send_activity(MessageFactory.text("How old are you?")) + flow.last_question_asked = Question.AGE + + # validate age then ask for date + elif flow.last_question_asked == Question.AGE: + validate_result = self._validate_age(user_input) + if not validate_result.is_valid: + await turn_context.send_activity(MessageFactory.text(validate_result.message)) + else: + profile.age = validate_result.value + await turn_context.send_activity(MessageFactory.text(f"I have your age as {profile.age}.")) + await turn_context.send_activity(MessageFactory.text("When is your flight?")) + flow.last_question_asked = Question.DATE + + # validate date and wrap it up + elif flow.last_question_asked == Question.DATE: + validate_result = self._validate_date(user_input) + if not validate_result.is_valid: + await turn_context.send_activity(MessageFactory.text(validate_result.message)) + else: + profile.date = validate_result.value + await turn_context.send_activity(MessageFactory.text( + f"Your cab ride to the airport is scheduled for {profile.date}.") + ) + await turn_context.send_activity(MessageFactory.text( + f"Thanks for completing the booking {profile.name}.") + ) + await turn_context.send_activity(MessageFactory.text("Type anything to run the bot again.")) + flow.last_question_asked = Question.NONE + + def _validate_name(self, user_input: str) -> ValidationResult: + if not user_input: + return ValidationResult(is_valid=False, message="Please enter a name that contains at least one character.") + else: + return ValidationResult(is_valid=True, value=user_input) + + def _validate_age(self, user_input: str) -> ValidationResult: + # Attempt to convert the Recognizer result to an integer. This works for "a dozen", "twelve", "12", and so on. + # The recognizer returns a list of potential recognition results, if any. + results = recognize_number(user_input, Culture.English) + for result in results: + if "value" in result.resolution: + age = int(result.resolution["value"]) + if 18 <= age <= 120: + return ValidationResult(is_valid=True, value=age) + + return ValidationResult(is_valid=False, message="Please enter an age between 18 and 120.") + + def _validate_date(self, user_input: str) -> ValidationResult: + try: + # Try to recognize the input as a date-time. This works for responses such as "11/14/2018", "9pm", + # "tomorrow", "Sunday at 5pm", and so on. The recognizer returns a list of potential recognition results, + # if any. + results = recognize_datetime(user_input, Culture.English) + for result in results: + for resolution in result.resolution["values"]: + if "value" in resolution: + now = datetime.now() + + value = resolution["value"] + if resolution["type"] == "date": + candidate = datetime.strptime(value, "%Y-%m-%d") + elif resolution["type"] == "time": + candidate = datetime.strptime(value, "%H:%M:%S") + candidate = candidate.replace(year=now.year, month=now.month, day=now.day) + else: + candidate = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + + # user response must be more than an hour out + diff = candidate - now + if diff.total_seconds() >= 3600: + return ValidationResult(is_valid=True, value=candidate.strftime("%m/%d/%y @ %H:%M")) + + return ValidationResult(is_valid=False, message="I'm sorry, please enter a date at least an hour out.") + except ValueError: + return ValidationResult(is_valid=False, message="I'm sorry, I could not interpret that as an appropriate " + "date. Please enter a date at least an hour out.") diff --git a/samples/44.prompt-users-for-input/config.py b/samples/44.prompt-users-for-input/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/44.prompt-users-for-input/config.py @@ -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", "") diff --git a/samples/44.prompt-users-for-input/data_models/__init__.py b/samples/44.prompt-users-for-input/data_models/__init__.py new file mode 100644 index 000000000..1ca181322 --- /dev/null +++ b/samples/44.prompt-users-for-input/data_models/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .conversation_flow import ConversationFlow, Question +from .user_profile import UserProfile + +__all__ = ["ConversationFlow", "Question", "UserProfile"] diff --git a/samples/44.prompt-users-for-input/data_models/conversation_flow.py b/samples/44.prompt-users-for-input/data_models/conversation_flow.py new file mode 100644 index 000000000..f40732419 --- /dev/null +++ b/samples/44.prompt-users-for-input/data_models/conversation_flow.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class Question(Enum): + NAME = 1 + AGE = 2 + DATE = 3 + NONE = 4 + + +class ConversationFlow: + def __init__( + self, + last_question_asked: Question = Question.NONE, + ): + self.last_question_asked = last_question_asked diff --git a/samples/44.prompt-users-for-input/data_models/user_profile.py b/samples/44.prompt-users-for-input/data_models/user_profile.py new file mode 100644 index 000000000..b1c40978e --- /dev/null +++ b/samples/44.prompt-users-for-input/data_models/user_profile.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class UserProfile: + def __init__(self, name: str = None, age: int = 0, date: str = None): + self.name = name + self.age = age + self.date = date diff --git a/samples/44.prompt-users-for-input/requirements.txt b/samples/44.prompt-users-for-input/requirements.txt new file mode 100644 index 000000000..5a3de5833 --- /dev/null +++ b/samples/44.prompt-users-for-input/requirements.txt @@ -0,0 +1,3 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 +recognizers-text>=1.0.2a1 From 75fab48c53f89344869bd1c57cc63f2e80a45aab Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Thu, 31 Oct 2019 08:43:38 -0500 Subject: [PATCH 020/616] Added 43.complex-dialog --- samples/43.complex-dialog/README.md | 30 +++++ samples/43.complex-dialog/app.py | 103 ++++++++++++++++++ samples/43.complex-dialog/bots/__init__.py | 7 ++ .../bots/dialog_and_welcome_bot.py | 37 +++++++ samples/43.complex-dialog/bots/dialog_bot.py | 41 +++++++ samples/43.complex-dialog/config.py | 15 +++ .../43.complex-dialog/data_models/__init__.py | 6 + .../data_models/user_profile.py | 11 ++ samples/43.complex-dialog/dialogs/__init__.py | 8 ++ .../43.complex-dialog/dialogs/main_dialog.py | 52 +++++++++ .../dialogs/review_selection_dialog.py | 83 ++++++++++++++ .../dialogs/top_level_dialog.py | 96 ++++++++++++++++ samples/43.complex-dialog/helpers/__init__.py | 6 + .../helpers/dialog_helper.py | 19 ++++ samples/43.complex-dialog/requirements.txt | 2 + 15 files changed, 516 insertions(+) create mode 100644 samples/43.complex-dialog/README.md create mode 100644 samples/43.complex-dialog/app.py create mode 100644 samples/43.complex-dialog/bots/__init__.py create mode 100644 samples/43.complex-dialog/bots/dialog_and_welcome_bot.py create mode 100644 samples/43.complex-dialog/bots/dialog_bot.py create mode 100644 samples/43.complex-dialog/config.py create mode 100644 samples/43.complex-dialog/data_models/__init__.py create mode 100644 samples/43.complex-dialog/data_models/user_profile.py create mode 100644 samples/43.complex-dialog/dialogs/__init__.py create mode 100644 samples/43.complex-dialog/dialogs/main_dialog.py create mode 100644 samples/43.complex-dialog/dialogs/review_selection_dialog.py create mode 100644 samples/43.complex-dialog/dialogs/top_level_dialog.py create mode 100644 samples/43.complex-dialog/helpers/__init__.py create mode 100644 samples/43.complex-dialog/helpers/dialog_helper.py create mode 100644 samples/43.complex-dialog/requirements.txt diff --git a/samples/43.complex-dialog/README.md b/samples/43.complex-dialog/README.md new file mode 100644 index 000000000..1605fcce5 --- /dev/null +++ b/samples/43.complex-dialog/README.md @@ -0,0 +1,30 @@ +# Complex dialog sample + +This sample creates a complex conversation with dialogs. + +## 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\43.complex-dialog` 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 + + +# 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) +- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/43.complex-dialog/app.py b/samples/43.complex-dialog/app.py new file mode 100644 index 000000000..9610c3e3c --- /dev/null +++ b/samples/43.complex-dialog/app.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +from datetime import datetime + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import DialogAndWelcomeBot + +# Create the loop and Flask app +from dialogs import MainDialog + +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) + + # Clear out state + await CONVERSATION_STATE.delete(context) + +# Set the error handler on the Adapter. +# In this case, we want an unbound function, so MethodType is not needed. +ADAPTER.on_turn_error = on_error + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create Dialog and Bot +DIALOG = MainDialog(USER_STATE) +BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +# 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 diff --git a/samples/43.complex-dialog/bots/__init__.py b/samples/43.complex-dialog/bots/__init__.py new file mode 100644 index 000000000..6925db302 --- /dev/null +++ b/samples/43.complex-dialog/bots/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot +from .dialog_and_welcome_bot import DialogAndWelcomeBot + +__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py b/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py new file mode 100644 index 000000000..2c2d91d92 --- /dev/null +++ b/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core import ( + ConversationState, + MessageFactory, + UserState, + TurnContext, +) +from botbuilder.dialogs import Dialog +from botbuilder.schema import ChannelAccount + +from .dialog_bot import DialogBot + + +class DialogAndWelcomeBot(DialogBot): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog, + ): + super(DialogAndWelcomeBot, self).__init__( + conversation_state, user_state, dialog + ) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + # Greet anyone that was not the target (recipient) of this message. + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity(MessageFactory.text( + f"Welcome to Complex Dialog Bot {member.name}. This bot provides a complex conversation, with " + f"multiple dialogs. Type anything to get started. ") + ) diff --git a/samples/43.complex-dialog/bots/dialog_bot.py b/samples/43.complex-dialog/bots/dialog_bot.py new file mode 100644 index 000000000..eb560a1be --- /dev/null +++ b/samples/43.complex-dialog/bots/dialog_bot.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog +from helpers.dialog_helper import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog, + ): + if conversation_state is None: + raise Exception( + "[DialogBot]: Missing parameter. conversation_state is required" + ) + if user_state is None: + raise Exception("[DialogBot]: Missing parameter. user_state is required") + if dialog is None: + raise Exception("[DialogBot]: Missing parameter. dialog is required") + + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have occurred during the turn. + await self.conversation_state.save_changes(turn_context, False) + await self.user_state.save_changes(turn_context, False) + + async def on_message_activity(self, turn_context: TurnContext): + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/43.complex-dialog/config.py b/samples/43.complex-dialog/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/43.complex-dialog/config.py @@ -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", "") diff --git a/samples/43.complex-dialog/data_models/__init__.py b/samples/43.complex-dialog/data_models/__init__.py new file mode 100644 index 000000000..35a5934d4 --- /dev/null +++ b/samples/43.complex-dialog/data_models/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .user_profile import UserProfile + +__all__ = ["UserProfile"] diff --git a/samples/43.complex-dialog/data_models/user_profile.py b/samples/43.complex-dialog/data_models/user_profile.py new file mode 100644 index 000000000..4ceb9a639 --- /dev/null +++ b/samples/43.complex-dialog/data_models/user_profile.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + + +class UserProfile: + def __init__(self, name: str = None, age: int = 0, companies_to_review: List[str] = None): + self.name: str = name + self.age: int = age + self.companies_to_review: List[str] = companies_to_review diff --git a/samples/43.complex-dialog/dialogs/__init__.py b/samples/43.complex-dialog/dialogs/__init__.py new file mode 100644 index 000000000..cde97fd80 --- /dev/null +++ b/samples/43.complex-dialog/dialogs/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .main_dialog import MainDialog +from .review_selection_dialog import ReviewSelectionDialog +from .top_level_dialog import TopLevelDialog + +__all__ = ["MainDialog", "ReviewSelectionDialog", "TopLevelDialog"] diff --git a/samples/43.complex-dialog/dialogs/main_dialog.py b/samples/43.complex-dialog/dialogs/main_dialog.py new file mode 100644 index 000000000..9fd04ce05 --- /dev/null +++ b/samples/43.complex-dialog/dialogs/main_dialog.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.core import MessageFactory, UserState + +from data_models import UserProfile +from dialogs.top_level_dialog import TopLevelDialog + + +class MainDialog(ComponentDialog): + def __init__( + self, user_state: UserState + ): + super(MainDialog, self).__init__(MainDialog.__name__) + + self.user_state = user_state + + self.add_dialog(TopLevelDialog(TopLevelDialog.__name__)) + self.add_dialog( + WaterfallDialog( + "WFDialog", [ + self.initial_step, + self.final_step + ] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def initial_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + return await step_context.begin_dialog(TopLevelDialog.__name__) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + user_info: UserProfile = step_context.result + + companies = "no companies" if len(user_info.companies_to_review) == 0 else " and ".join(user_info.companies_to_review) + status = f"You are signed up to review {companies}." + + await step_context.context.send_activity(MessageFactory.text(status)) + + # store the UserProfile + accessor = self.user_state.create_property("UserProfile") + await accessor.set(step_context.context, user_info) + + return await step_context.end_dialog() + diff --git a/samples/43.complex-dialog/dialogs/review_selection_dialog.py b/samples/43.complex-dialog/dialogs/review_selection_dialog.py new file mode 100644 index 000000000..1e6f0c747 --- /dev/null +++ b/samples/43.complex-dialog/dialogs/review_selection_dialog.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult, ComponentDialog +from botbuilder.dialogs.prompts import ChoicePrompt, PromptOptions +from botbuilder.dialogs.choices import Choice, FoundChoice +from botbuilder.core import MessageFactory + + +class ReviewSelectionDialog(ComponentDialog): + def __init__(self, dialog_id: str = None): + super(ReviewSelectionDialog, self).__init__(dialog_id or ReviewSelectionDialog.__name__) + + self.COMPANIES_SELECTED = "value-companiesSelected" + self.DONE_OPTION = "done" + + self.company_options = ["Adatum Corporation", + "Contoso Suites", + "Graphic Design Institute", + "Wide World Importers"] + + self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, [ + self.selection_step, + self.loop_step + ] + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def selection_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # step_context.options will contains the value passed in begin_dialog or replace_dialog. + # if this value wasn't provided then start with an emtpy selection list. This list will + # eventually be returned to the parent via end_dialog. + selected: [str] = step_context.options if step_context.options is not None else [] + step_context.values[self.COMPANIES_SELECTED] = selected + + if len(selected) == 0: + message = f"Please choose a company to review, or `{self.DONE_OPTION}` to finish." + else: + message = f"You have selected **{selected[0]}**. You can review an additional company, "\ + f"or choose `{self.DONE_OPTION}` to finish. " + + # create a list of options to choose, with already selected items removed. + options = self.company_options.copy() + options.append(self.DONE_OPTION) + if len(selected) > 0: + options.remove(selected[0]) + + # prompt with the list of choices + prompt_options = PromptOptions( + prompt=MessageFactory.text(message), + retry_prompt=MessageFactory.text("Please choose an option from the list."), + choices=self._to_choices(options) + ) + return await step_context.prompt(ChoicePrompt.__name__, prompt_options) + + def _to_choices(self, choices: [str]) -> List[Choice]: + choice_list: List[Choice] = [] + for c in choices: + choice_list.append(Choice(value=c)) + return choice_list + + async def loop_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + selected: List[str] = step_context.values[self.COMPANIES_SELECTED] + choice: FoundChoice = step_context.result + done = choice.value == self.DONE_OPTION + + # If they chose a company, add it to the list. + if not done: + selected.append(choice.value) + + # If they're done, exit and return their list. + if done or len(selected) >= 2: + return await step_context.end_dialog(selected) + + # Otherwise, repeat this dialog, passing in the selections from this iteration. + return await step_context.replace_dialog(ReviewSelectionDialog.__name__, selected) diff --git a/samples/43.complex-dialog/dialogs/top_level_dialog.py b/samples/43.complex-dialog/dialogs/top_level_dialog.py new file mode 100644 index 000000000..96992e080 --- /dev/null +++ b/samples/43.complex-dialog/dialogs/top_level_dialog.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import ( + WaterfallDialog, + DialogTurnResult, + WaterfallStepContext, + ComponentDialog +) +from botbuilder.dialogs.prompts import ( + PromptOptions, + TextPrompt, + NumberPrompt +) + +from data_models import UserProfile +from dialogs.review_selection_dialog import ReviewSelectionDialog + + +class TopLevelDialog(ComponentDialog): + def __init__(self, dialog_id: str = None): + super(TopLevelDialog, self).__init__( + dialog_id or TopLevelDialog.__name__ + ) + + # Key name to store this dialogs state info in the StepContext + self.USER_INFO = "value-userInfo" + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(NumberPrompt(NumberPrompt.__name__)) + + self.add_dialog(ReviewSelectionDialog(ReviewSelectionDialog.__name__)) + + self.add_dialog( + WaterfallDialog( + "WFDialog", [ + self.name_step, + self.age_step, + self.start_selection_step, + self.acknowledgement_step + ] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Create an object in which to collect the user's information within the dialog. + step_context.values[self.USER_INFO] = UserProfile() + + # Ask the user to enter their name. + prompt_options = PromptOptions( + prompt=MessageFactory.text("Please enter your name.") + ) + return await step_context.prompt(TextPrompt.__name__, prompt_options) + + async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Set the user's name to what they entered in response to the name prompt. + user_profile = step_context.values[self.USER_INFO] + user_profile.name = step_context.result + + # Ask the user to enter their age. + prompt_options = PromptOptions( + prompt=MessageFactory.text("Please enter your age.") + ) + return await step_context.prompt(NumberPrompt.__name__, prompt_options) + + async def start_selection_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Set the user's age to what they entered in response to the age prompt. + user_profile: UserProfile = step_context.values[self.USER_INFO] + user_profile.age = step_context.result + + if user_profile.age < 25: + # If they are too young, skip the review selection dialog, and pass an empty list to the next step. + await step_context.context.send_activity(MessageFactory.text( + "You must be 25 or older to participate.") + ) + + return await step_context.next([]) + else: + # Otherwise, start the review selection dialog. + return await step_context.begin_dialog(ReviewSelectionDialog.__name__) + + async def acknowledgement_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Set the user's company selection to what they entered in the review-selection dialog. + user_profile: UserProfile = step_context.values[self.USER_INFO] + user_profile.companies_to_review = step_context.result + + # Thank them for participating. + await step_context.context.send_activity( + MessageFactory.text(f"Thanks for participating, {user_profile.name}.") + ) + + # Exit the dialog, returning the collected user information. + return await step_context.end_dialog(user_profile) diff --git a/samples/43.complex-dialog/helpers/__init__.py b/samples/43.complex-dialog/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/43.complex-dialog/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/43.complex-dialog/helpers/dialog_helper.py b/samples/43.complex-dialog/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/43.complex-dialog/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/43.complex-dialog/requirements.txt b/samples/43.complex-dialog/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/43.complex-dialog/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From 3b330cc769a77a621705adf17225ffbd20cfe13a Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Fri, 1 Nov 2019 16:18:57 -0500 Subject: [PATCH 021/616] Pinned dependencies in all libraries --- libraries/botbuilder-ai/requirements.txt | 8 ++++---- libraries/botbuilder-applicationinsights/requirements.txt | 4 ++-- libraries/botbuilder-azure/setup.py | 8 ++++---- libraries/botbuilder-core/requirements.txt | 8 ++++---- libraries/botbuilder-dialogs/requirements.txt | 8 ++++---- libraries/botbuilder-schema/setup.py | 2 +- libraries/botbuilder-testing/requirements.txt | 2 +- libraries/botframework-connector/requirements.txt | 6 +++--- .../functional-tests/functionaltestbot/requirements.txt | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index d5b49e330..4dc295b54 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,6 +1,6 @@ -msrest>=0.6.6 +msrest==0.6.10 botbuilder-schema>=4.4.0b1 botbuilder-core>=4.4.0b1 -requests>=2.18.1 -aiounittest>=1.1.0 -azure-cognitiveservices-language-luis>=0.2.0 \ No newline at end of file +requests==2.22.0 +aiounittest==1.3.0 +azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/requirements.txt b/libraries/botbuilder-applicationinsights/requirements.txt index d4f4ea13b..9398bc588 100644 --- a/libraries/botbuilder-applicationinsights/requirements.txt +++ b/libraries/botbuilder-applicationinsights/requirements.txt @@ -1,3 +1,3 @@ -msrest>=0.6.6 +msrest==0.6.10 botbuilder-core>=4.4.0b1 -aiounittest>=1.1.0 \ No newline at end of file +aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index dd3a5e12a..4f0fe9630 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -5,13 +5,13 @@ from setuptools import setup REQUIRES = [ - "azure-cosmos>=3.0.0", - "azure-storage-blob>=2.1.0", + "azure-cosmos==3.1.2", + "azure-storage-blob==2.1.0", "botbuilder-schema>=4.4.0b1", "botframework-connector>=4.4.0b1", - "jsonpickle>=1.2", + "jsonpickle==1.2", ] -TEST_REQUIRES = ["aiounittest>=1.1.0"] +TEST_REQUIRES = ["aiounittest==1.3.0"] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-core/requirements.txt b/libraries/botbuilder-core/requirements.txt index 76ed88e0a..ba8bedbd4 100644 --- a/libraries/botbuilder-core/requirements.txt +++ b/libraries/botbuilder-core/requirements.txt @@ -1,7 +1,7 @@ -msrest>=0.6.6 +msrest==0.6.10 botframework-connector>=4.4.0b1 botbuilder-schema>=4.4.0b1 -requests>=2.18.1 +requests==2.22.0 PyJWT==1.5.3 -cryptography>=2.3.0 -aiounittest>=1.2.1 \ No newline at end of file +cryptography==2.8.0 +aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index 91e19a0e6..fa0c59445 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -1,8 +1,8 @@ -msrest>=0.6.6 +msrest==0.6.10 botframework-connector>=4.4.0b1 botbuilder-schema>=4.4.0b1 botbuilder-core>=4.4.0b1 -requests>=2.18.1 +requests==2.22.0 PyJWT==1.5.3 -cryptography>=2.3.0 -aiounittest>=1.1.0 \ No newline at end of file +cryptography==2.8 +aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-schema/setup.py b/libraries/botbuilder-schema/setup.py index 50af773a6..422295912 100644 --- a/libraries/botbuilder-schema/setup.py +++ b/libraries/botbuilder-schema/setup.py @@ -6,7 +6,7 @@ NAME = "botbuilder-schema" VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -REQUIRES = ["msrest>=0.6.6"] +REQUIRES = ["msrest==0.6.10"] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-testing/requirements.txt b/libraries/botbuilder-testing/requirements.txt index 9eb053052..21503f391 100644 --- a/libraries/botbuilder-testing/requirements.txt +++ b/libraries/botbuilder-testing/requirements.txt @@ -1,4 +1,4 @@ botbuilder-schema>=4.4.0b1 botbuilder-core>=4.4.0b1 botbuilder-dialogs>=4.4.0b1 -aiounittest>=1.1.0 +aiounittest==1.3.0 diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 123fc5791..a2a1fe1b5 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -1,5 +1,5 @@ -msrest>=0.6.6 +msrest==0.6.10 botbuilder-schema>=4.4.0b1 -requests>=2.18.1 +requests==2.22.0 PyJWT==1.5.3 -cryptography>=2.3.0 \ No newline at end of file +cryptography==2.8.0 \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index 1809dd813..a348b59af 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -2,4 +2,4 @@ # Licensed under the MIT License. botbuilder-core>=4.5.0.b4 -flask>=1.0.3 +flask==1.1.1 From 8c0f540bcf560b7368a62ca463fcca2d29958709 Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Fri, 1 Nov 2019 16:44:55 -0500 Subject: [PATCH 022/616] Pinned dependencies in libraries (missed some setup.py) --- libraries/botbuilder-ai/setup.py | 2 +- libraries/botbuilder-applicationinsights/setup.py | 8 ++++---- libraries/botbuilder-core/setup.py | 2 +- libraries/botbuilder-dialogs/setup.py | 4 ++-- libraries/botbuilder-schema/requirements.txt | 2 +- libraries/botbuilder-testing/setup.py | 2 +- libraries/botframework-connector/setup.py | 8 ++++---- libraries/functional-tests/functionaltestbot/setup.py | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index ba8c272df..1297ac6dc 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -8,7 +8,7 @@ "azure-cognitiveservices-language-luis==0.2.0", "botbuilder-schema>=4.4.0b1", "botbuilder-core>=4.4.0b1", - "aiohttp>=3.5.4", + "aiohttp==3.6.2", ] TESTS_REQUIRES = ["aiounittest>=1.1.0"] diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 71aba4f54..5dffcffc5 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -11,10 +11,10 @@ "botbuilder-core>=4.4.0b1", ] TESTS_REQUIRES = [ - "aiounittest>=1.1.0", - "django>=2.2", # For samples - "djangorestframework>=3.9.2", # For samples - "flask>=1.0.2", # For samples + "aiounittest==1.3.0", + "django==2.2.6", # For samples + "djangorestframework==3.10.3", # For samples + "flask==1.1.1", # For samples ] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index b3dff50fa..f7ab3ae09 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -8,7 +8,7 @@ REQUIRES = [ "botbuilder-schema>=4.4.0b1", "botframework-connector>=4.4.0b1", - "jsonpickle>=1.2", + "jsonpickle==1.2", ] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index af2cdbf80..1df9df6c2 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -10,13 +10,13 @@ "recognizers-text-number>=1.0.2a1", "recognizers-text>=1.0.2a1", "recognizers-text-choice>=1.0.2a1", - "babel>=2.7.0", + "babel==2.7.0", "botbuilder-schema>=4.4.0b1", "botframework-connector>=4.4.0b1", "botbuilder-core>=4.4.0b1", ] -TEST_REQUIRES = ["aiounittest>=1.1.0"] +TEST_REQUIRES = ["aiounittest==1.3.0"] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-schema/requirements.txt b/libraries/botbuilder-schema/requirements.txt index bd81a57e3..2969b6597 100644 --- a/libraries/botbuilder-schema/requirements.txt +++ b/libraries/botbuilder-schema/requirements.txt @@ -1 +1 @@ -msrest>=0.6.6 \ No newline at end of file +msrest==0.6.10 \ No newline at end of file diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py index 2bdff64d8..ab0a3f572 100644 --- a/libraries/botbuilder-testing/setup.py +++ b/libraries/botbuilder-testing/setup.py @@ -10,7 +10,7 @@ "botbuilder-dialogs>=4.4.0b1", ] -TESTS_REQUIRES = ["aiounittest>=1.1.0"] +TESTS_REQUIRES = ["aiounittest==1.3.0"] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 87e25d465..c7cf11edc 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -6,10 +6,10 @@ NAME = "botframework-connector" VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" REQUIRES = [ - "msrest>=0.6.6", - "requests>=2.8.1", - "cryptography>=2.3.0", - "PyJWT>=1.5.3", + "msrest==0.6.10", + "requests==2.22.0", + "cryptography==2.8.0", + "PyJWT==1.5.3", "botbuilder-schema>=4.4.0b1", ] diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py index 359052349..1378ac4b0 100644 --- a/libraries/functional-tests/functionaltestbot/setup.py +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -6,7 +6,7 @@ REQUIRES = [ "botbuilder-core>=4.5.0.b4", - "flask>=1.0.3", + "flask==1.1.1", ] root = os.path.abspath(os.path.dirname(__file__)) From 8c4cc4986bc0d5f70a5accae586be247f7af816b Mon Sep 17 00:00:00 2001 From: stevengum <14935595+stevengum@users.noreply.github.com> Date: Mon, 4 Nov 2019 15:38:56 -0800 Subject: [PATCH 023/616] modify echo to work out of the box w/ ARM template --- samples/02.echo-bot/app.py | 12 +- .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ 2 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json diff --git a/samples/02.echo-bot/app.py b/samples/02.echo-bot/app.py index 9762bafb9..e1de9d56a 100644 --- a/samples/02.echo-bot/app.py +++ b/samples/02.echo-bot/app.py @@ -14,12 +14,12 @@ # Create the loop and Flask app LOOP = asyncio.get_event_loop() -APP = Flask(__name__, instance_relative_config=True) -APP.config.from_object("config.DefaultConfig") +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"]) +SETTINGS = BotFrameworkAdapterSettings(app.config["APP_ID"], app.config["APP_PASSWORD"]) ADAPTER = BotFrameworkAdapter(SETTINGS) @@ -52,8 +52,8 @@ async def on_error(self, context: TurnContext, error: Exception): # Create the Bot BOT = EchoBot() -# Listen for incoming requests on /api/messages.s -@APP.route("/api/messages", methods=["POST"]) +# 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"]: @@ -78,6 +78,6 @@ def messages(): if __name__ == "__main__": try: - APP.run(debug=False, port=APP.config["PORT"]) # nosec debug + app.run(debug=False, port=app.config["PORT"]) # nosec debug except Exception as exception: raise exception diff --git a/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file From fbf8a1556709f669d8cceccc32b50b9c174f2fcf Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 Nov 2019 18:00:57 -0600 Subject: [PATCH 024/616] Added 47.inspection (#381) * Added 47.inspection, corrected README in 45.state-management * Changed the on_error function to be unbound for consistency. --- samples/45.state-management/README.md | 7 -- samples/47.inspection/README.md | 46 +++++++ samples/47.inspection/app.py | 115 ++++++++++++++++++ samples/47.inspection/bots/__init__.py | 6 + samples/47.inspection/bots/echo_bot.py | 50 ++++++++ samples/47.inspection/config.py | 15 +++ samples/47.inspection/data_models/__init__.py | 6 + .../47.inspection/data_models/custom_state.py | 7 ++ samples/47.inspection/requirements.txt | 2 + 9 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 samples/47.inspection/README.md create mode 100644 samples/47.inspection/app.py create mode 100644 samples/47.inspection/bots/__init__.py create mode 100644 samples/47.inspection/bots/echo_bot.py create mode 100644 samples/47.inspection/config.py create mode 100644 samples/47.inspection/data_models/__init__.py create mode 100644 samples/47.inspection/data_models/custom_state.py create mode 100644 samples/47.inspection/requirements.txt diff --git a/samples/45.state-management/README.md b/samples/45.state-management/README.md index 30ece10d8..f6ca355a4 100644 --- a/samples/45.state-management/README.md +++ b/samples/45.state-management/README.md @@ -9,14 +9,7 @@ The bot maintains user state to track the user's answers. ```bash git clone https://github.com/Microsoft/botbuilder-python.git ``` -- Run `pip install -r requirements.txt` to install all dependencies -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -### Visual studio code - Activate your desired virtual environment -- Open `botbuilder-python\samples\45.state-management` folder - Bring up a terminal, navigate to `botbuilder-python\samples\45.state-management` folder - In the terminal, type `pip install -r requirements.txt` - In the terminal, type `python app.py` diff --git a/samples/47.inspection/README.md b/samples/47.inspection/README.md new file mode 100644 index 000000000..6e2c42a08 --- /dev/null +++ b/samples/47.inspection/README.md @@ -0,0 +1,46 @@ +# Inspection Bot + +Bot Framework v4 Inspection Middleware sample. + +This bot demonstrates a feature called Inspection. This feature allows the Bot Framework Emulator to debug traffic into and out of the bot in addition to looking at the current state of the bot. This is done by having this data sent to the emulator using trace messages. + +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. Included in this sample are two counters maintained in User and Conversation state to demonstrate the ability to look at state. + +This runtime behavior is achieved by simply adding a middleware to the Adapter. In this sample you can find that being done in `app.py`. + +More details are available [here](https://github.com/microsoft/BotFramework-Emulator/blob/master/content/CHANNELS.md) + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Bring up a terminal, navigate to `botbuilder-python\samples\47.inspection` 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 version 4.5.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` + +### Special Instructions for Running Inspection + +- In the emulator, select Debug -> Start Debugging. +- Enter the endpoint url (http://localhost:8080)/api/messages, and select Connect. +- The result is a trace activity which contains a statement that looks like /INSPECT attach < identifier > +- Right click and copy that response. +- In the original Live Chat session paste the value. +- Now all the traffic will be replicated (as trace activities) to the Emulator Debug tab. + +# Further reading + +- [Getting started with the Bot Inspector](https://github.com/microsoft/BotFramework-Emulator/blob/master/content/CHANNELS.md) +- [Azure Bot Service Introduction](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?view=azure-bot-service-4.0) diff --git a/samples/47.inspection/app.py b/samples/47.inspection/app.py new file mode 100644 index 000000000..4e4bef778 --- /dev/null +++ b/samples/47.inspection/app.py @@ -0,0 +1,115 @@ +# 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 botbuilder.core.inspection import InspectionMiddleware, InspectionState +from botframework.connector.auth import MicrosoftAppCredentials +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import EchoBot + +# 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) + + # Clear out state + await CONVERSATION_STATE.delete(context) + +# Set the error handler on the Adapter. +# In this case, we want an unbound method, so MethodType is not needed. +ADAPTER.on_turn_error = on_error + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create InspectionMiddleware +INSPECTION_MIDDLEWARE = InspectionMiddleware( + inspection_state=InspectionState(MEMORY), + user_state=USER_STATE, + conversation_state=CONVERSATION_STATE, + credentials=MicrosoftAppCredentials( + app_id=APP.config["APP_ID"], + password=APP.config["APP_PASSWORD"] + ) +) +ADAPTER.use(INSPECTION_MIDDLEWARE) + +# Create Bot +BOT = EchoBot(CONVERSATION_STATE, USER_STATE) + + +# 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 diff --git a/samples/47.inspection/bots/__init__.py b/samples/47.inspection/bots/__init__.py new file mode 100644 index 000000000..f95fbbbad --- /dev/null +++ b/samples/47.inspection/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/samples/47.inspection/bots/echo_bot.py b/samples/47.inspection/bots/echo_bot.py new file mode 100644 index 000000000..fe4d8d099 --- /dev/null +++ b/samples/47.inspection/bots/echo_bot.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState, MessageFactory +from botbuilder.schema import ChannelAccount + +from data_models import CustomState + + +class EchoBot(ActivityHandler): + def __init__(self, conversation_state: ConversationState, user_state: UserState): + if conversation_state is None: + raise TypeError( + "[EchoBot]: Missing parameter. conversation_state is required but None was given" + ) + if user_state is None: + raise TypeError( + "[EchoBot]: Missing parameter. user_state is required but None was given" + ) + + self.conversation_state = conversation_state + self.user_state = user_state + + self.conversation_state_accessor = self.conversation_state.create_property("CustomState") + self.user_state_accessor = self.user_state.create_property("CustomState") + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + await self.conversation_state.save_changes(turn_context) + await self.user_state.save_changes(turn_context) + + async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + # Get the state properties from the turn context. + user_data = await self.user_state_accessor.get(turn_context, CustomState) + conversation_data = await self.conversation_state_accessor.get(turn_context, CustomState) + + await turn_context.send_activity(MessageFactory.text( + f"Echo: {turn_context.activity.text}, " + f"conversation state: {conversation_data.value}, " + f"user state: {user_data.value}")) + + user_data.value = user_data.value + 1 + conversation_data.value = conversation_data.value + 1 + diff --git a/samples/47.inspection/config.py b/samples/47.inspection/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/47.inspection/config.py @@ -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", "") diff --git a/samples/47.inspection/data_models/__init__.py b/samples/47.inspection/data_models/__init__.py new file mode 100644 index 000000000..f84d31d7b --- /dev/null +++ b/samples/47.inspection/data_models/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .custom_state import CustomState + +__all__ = ["CustomState"] diff --git a/samples/47.inspection/data_models/custom_state.py b/samples/47.inspection/data_models/custom_state.py new file mode 100644 index 000000000..96a405cd4 --- /dev/null +++ b/samples/47.inspection/data_models/custom_state.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class CustomState: + def __init__(self, value: int = 0): + self.value = value diff --git a/samples/47.inspection/requirements.txt b/samples/47.inspection/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/47.inspection/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From a1d9887734d4b82755977ebaa7ac5fcaed66a80e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 Nov 2019 18:32:38 -0600 Subject: [PATCH 025/616] ChoiceFactory.for_channel was erroneously returning a List instead of an Activity (#383) --- .../botbuilder/dialogs/choices/choice_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py index a9d17f16f..52bf778b3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py @@ -69,7 +69,7 @@ def for_channel( # If the titles are short and there are 3 or less choices we'll use an inline list. return ChoiceFactory.inline(choices, text, speak, options) # Show a numbered list. - return [choices, text, speak, options] + return ChoiceFactory.list_style(choices, text, speak, options) @staticmethod def inline( From 5193361f6a818c209b84a9d218a6e17f348cd4eb Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 Nov 2019 18:41:56 -0600 Subject: [PATCH 026/616] =?UTF-8?q?Refactored=20to=20unbound=20on=5Ferror?= =?UTF-8?q?=20methods=20when=20accessing=20outer=20app.py=20va=E2=80=A6=20?= =?UTF-8?q?(#385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored to unbound on_error methods when accessing outer app.py variables. * Removed unused imports --- samples/05.multi-turn-prompt/app.py | 5 +++-- samples/06.using-cards/app.py | 7 ++++--- samples/21.corebot-app-insights/app.py | 8 ++++---- samples/44.prompt-users-for-input/app.py | 7 ++++--- samples/45.state-management/app.py | 7 ++++--- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/samples/05.multi-turn-prompt/app.py b/samples/05.multi-turn-prompt/app.py index 2a358711d..790c2019c 100644 --- a/samples/05.multi-turn-prompt/app.py +++ b/samples/05.multi-turn-prompt/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -59,7 +58,9 @@ async def on_error(context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +# Set the error handler on the Adapter. +# In this case, we want an unbound method, so MethodType is not needed. +ADAPTER.on_turn_error = on_error # Create MemoryStorage, UserState and ConversationState MEMORY = MemoryStorage() diff --git a/samples/06.using-cards/app.py b/samples/06.using-cards/app.py index 611090cb6..fe0c69b56 100644 --- a/samples/06.using-cards/app.py +++ b/samples/06.using-cards/app.py @@ -7,7 +7,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -35,7 +34,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -61,7 +60,9 @@ async def on_error(self, context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +# Set the error handler on the Adapter. +# In this case, we want an unbound method, so MethodType is not needed. +ADAPTER.on_turn_error = on_error # Create MemoryStorage, UserState and ConversationState MEMORY = MemoryStorage() diff --git a/samples/21.corebot-app-insights/app.py b/samples/21.corebot-app-insights/app.py index 9cc6896be..5011c21f9 100644 --- a/samples/21.corebot-app-insights/app.py +++ b/samples/21.corebot-app-insights/app.py @@ -14,7 +14,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -45,7 +44,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -69,10 +68,11 @@ async def on_error(self, context: TurnContext, error: Exception): await context.send_activity(trace_activity) # Clear out state - nonlocal self await CONVERSATION_STATE.delete(context) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +# Set the error handler on the Adapter. +# In this case, we want an unbound method, so MethodType is not needed. +ADAPTER.on_turn_error = on_error # Create MemoryStorage, UserState and ConversationState MEMORY = MemoryStorage() diff --git a/samples/44.prompt-users-for-input/app.py b/samples/44.prompt-users-for-input/app.py index 9e598f407..79b861b59 100644 --- a/samples/44.prompt-users-for-input/app.py +++ b/samples/44.prompt-users-for-input/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -31,7 +30,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -57,7 +56,9 @@ async def on_error(self, context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +# Set the error handler on the Adapter. +# In this case, we want an unbound method, so MethodType is not needed. +ADAPTER.on_turn_error = on_error # Create MemoryStorage and state MEMORY = MemoryStorage() diff --git a/samples/45.state-management/app.py b/samples/45.state-management/app.py index 1268ffcd2..4609c2881 100644 --- a/samples/45.state-management/app.py +++ b/samples/45.state-management/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -31,7 +30,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -57,7 +56,9 @@ async def on_error(self, context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +# Set the error handler on the Adapter. +# In this case, we want an unbound method, so MethodType is not needed. +ADAPTER.on_turn_error = on_error # Create MemoryStorage and state MEMORY = MemoryStorage() From 0f524c323896268a4dc5e07dbaae085eb00c77e0 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 Nov 2019 18:49:00 -0600 Subject: [PATCH 027/616] Added 16.proactive-messages (#413) --- samples/16.proactive-messages/README.md | 66 ++++++++++ samples/16.proactive-messages/app.py | 119 ++++++++++++++++++ .../16.proactive-messages/bots/__init__.py | 6 + .../bots/proactive_bot.py | 41 ++++++ samples/16.proactive-messages/config.py | 15 +++ .../16.proactive-messages/requirements.txt | 2 + 6 files changed, 249 insertions(+) create mode 100644 samples/16.proactive-messages/README.md create mode 100644 samples/16.proactive-messages/app.py create mode 100644 samples/16.proactive-messages/bots/__init__.py create mode 100644 samples/16.proactive-messages/bots/proactive_bot.py create mode 100644 samples/16.proactive-messages/config.py create mode 100644 samples/16.proactive-messages/requirements.txt diff --git a/samples/16.proactive-messages/README.md b/samples/16.proactive-messages/README.md new file mode 100644 index 000000000..109fb4085 --- /dev/null +++ b/samples/16.proactive-messages/README.md @@ -0,0 +1,66 @@ +# Proactive messages + +Bot Framework v4 proactive messages bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to send proactive messages to users by capturing a conversation reference, then using it later to initialize outbound messages. + +## Concepts introduced in this sample + +Typically, each message that a bot sends to the user directly relates to the user's prior input. In some cases, a bot may need to send the user a message that is not directly related to the current topic of conversation. These types of messages are called proactive messages. + +Proactive messages can be useful in a variety of scenarios. If a bot sets a timer or reminder, it will need to notify the user when the time arrives. Or, if a bot receives a notification from an external system, it may need to communicate that information to the user immediately. For example, if the user has previously asked the bot to monitor the price of a product, the bot can alert the user if the price of the product has dropped by 20%. Or, if a bot requires some time to compile a response to the user's question, it may inform the user of the delay and allow the conversation to continue in the meantime. When the bot finishes compiling the response to the question, it will share that information with the user. + +This project has a notify endpoint that will trigger the proactive messages to be sent to +all users who have previously messaged the bot. + +## 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\16.proactive-messages` 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 + +With the Bot Framework Emulator connected to your running bot, the sample will now respond to an HTTP GET that will trigger a proactive message. The proactive message can be triggered from the command line using `curl` or similar tooling, or can be triggered by opening a browser windows and nagivating to `http://localhost:3978/api/notify`. + +### Using curl + +- Send a get request to `http://localhost:3978/api/notify` to proactively message users from the bot. + + ```bash + curl get http://localhost:3978/api/notify + ``` + +- Using the Bot Framework Emulator, notice a message was proactively sent to the user from the bot. + +### Using the Browser + +- Launch a web browser +- Navigate to `http://localhost:3978/api/notify` +- Using the Bot Framework Emulator, notice a message was proactively sent to the user from the bot. + +## Proactive Messages + +In addition to responding to incoming messages, bots are frequently called on to send "proactive" messages based on activity, scheduled tasks, or external events. + +In order to send a proactive message using Bot Framework, the bot must first capture a conversation reference from an incoming message using `TurnContext.get_conversation_reference()`. This reference can be stored for later use. + +To send proactive messages, acquire a conversation reference, then use `adapter.continue_conversation()` to create a TurnContext object that will allow the bot to deliver the new outgoing message. + +## 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) +- [Send proactive messages](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-proactive-message?view=azure-bot-service-4.0&tabs=js) diff --git a/samples/16.proactive-messages/app.py b/samples/16.proactive-messages/app.py new file mode 100644 index 000000000..8abfdaeda --- /dev/null +++ b/samples/16.proactive-messages/app.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +import uuid +from datetime import datetime +from types import MethodType +from typing import Dict + +from flask import Flask, request, Response +from botbuilder.core import BotFrameworkAdapterSettings, TurnContext, BotFrameworkAdapter +from botbuilder.schema import Activity, ActivityTypes, ConversationReference + +from bots import ProactiveBot + +# 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(self, 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 = MethodType(on_error, ADAPTER) + +# Create a shared dictionary. The Bot will add conversation references when users +# join the conversation and send messages. +CONVERSATION_REFERENCES: Dict[str, ConversationReference] = dict() + +# If the channel is the Emulator, and authentication is not in use, the AppId will be null. +# We generate a random AppId for this case only. This is not required for production, since +# the AppId will have a value. +APP_ID = SETTINGS.app_id if SETTINGS.app_id else uuid.uuid4() + +# Create the Bot +BOT = ProactiveBot(CONVERSATION_REFERENCES) + +# 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 + + +# Listen for requests on /api/notify, and send a messages to all conversation members. +@APP.route("/api/notify") +def notify(): + try: + task = LOOP.create_task( + _send_proactive_message() + ) + LOOP.run_until_complete(task) + + return Response(status=201, response="Proactive messages have been sent") + except Exception as exception: + raise exception + + +# Send a message to all conversation members. +# This uses the shared Dictionary that the Bot adds conversation references to. +async def _send_proactive_message(): + for conversation_reference in CONVERSATION_REFERENCES.values(): + return await ADAPTER.continue_conversation( + APP_ID, + conversation_reference, + lambda turn_context: turn_context.send_activity("proactive hello") + ) + + +if __name__ == "__main__": + try: + APP.run(debug=False, port=APP.config["PORT"]) # nosec debug + except Exception as exception: + raise exception diff --git a/samples/16.proactive-messages/bots/__init__.py b/samples/16.proactive-messages/bots/__init__.py new file mode 100644 index 000000000..72c8ccc0c --- /dev/null +++ b/samples/16.proactive-messages/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .proactive_bot import ProactiveBot + +__all__ = ["ProactiveBot"] diff --git a/samples/16.proactive-messages/bots/proactive_bot.py b/samples/16.proactive-messages/bots/proactive_bot.py new file mode 100644 index 000000000..79cc2df71 --- /dev/null +++ b/samples/16.proactive-messages/bots/proactive_bot.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from botbuilder.core import ActivityHandler, TurnContext +from botbuilder.schema import ChannelAccount, ConversationReference, Activity + + +class ProactiveBot(ActivityHandler): + def __init__( + self, conversation_references: Dict[str, ConversationReference] + ): + self.conversation_references = conversation_references + + async def on_conversation_update_activity(self, turn_context: TurnContext): + self._add_conversation_reference(turn_context.activity) + return await super().on_conversation_update_activity(turn_context) + + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Welcome to the Proactive Bot sample. Navigate to " + "http://localhost:3978/api/notify to proactively message everyone " + "who has previously messaged this bot.") + + async def on_message_activity(self, turn_context: TurnContext): + self._add_conversation_reference(turn_context.activity) + return await turn_context.send_activity(f"You sent: {turn_context.activity.text}") + + def _add_conversation_reference(self, activity: Activity): + """ + This populates the shared Dictionary that holds conversation references. In this sample, + this dictionary is used to send a message to members when /api/notify is hit. + :param activity: + :return: + """ + conversation_reference = TurnContext.get_conversation_reference(activity) + self.conversation_references[conversation_reference.user.id] = conversation_reference diff --git a/samples/16.proactive-messages/config.py b/samples/16.proactive-messages/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/16.proactive-messages/config.py @@ -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", "") diff --git a/samples/16.proactive-messages/requirements.txt b/samples/16.proactive-messages/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/16.proactive-messages/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From e3708f9e30c91a17f9c80356fa4abf934c2f6c7f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 Nov 2019 18:55:42 -0600 Subject: [PATCH 028/616] Added 19.custom-dialogs (#411) --- samples/19.custom-dialogs/README.md | 48 ++++++ samples/19.custom-dialogs/app.py | 99 +++++++++++++ samples/19.custom-dialogs/bots/__init__.py | 6 + samples/19.custom-dialogs/bots/dialog_bot.py | 29 ++++ samples/19.custom-dialogs/config.py | 15 ++ samples/19.custom-dialogs/dialogs/__init__.py | 7 + .../19.custom-dialogs/dialogs/root_dialog.py | 138 ++++++++++++++++++ .../19.custom-dialogs/dialogs/slot_details.py | 21 +++ .../dialogs/slot_filling_dialog.py | 89 +++++++++++ samples/19.custom-dialogs/helpers/__init__.py | 6 + .../helpers/dialog_helper.py | 19 +++ samples/19.custom-dialogs/requirements.txt | 2 + 12 files changed, 479 insertions(+) create mode 100644 samples/19.custom-dialogs/README.md create mode 100644 samples/19.custom-dialogs/app.py create mode 100644 samples/19.custom-dialogs/bots/__init__.py create mode 100644 samples/19.custom-dialogs/bots/dialog_bot.py create mode 100644 samples/19.custom-dialogs/config.py create mode 100644 samples/19.custom-dialogs/dialogs/__init__.py create mode 100644 samples/19.custom-dialogs/dialogs/root_dialog.py create mode 100644 samples/19.custom-dialogs/dialogs/slot_details.py create mode 100644 samples/19.custom-dialogs/dialogs/slot_filling_dialog.py create mode 100644 samples/19.custom-dialogs/helpers/__init__.py create mode 100644 samples/19.custom-dialogs/helpers/dialog_helper.py create mode 100644 samples/19.custom-dialogs/requirements.txt diff --git a/samples/19.custom-dialogs/README.md b/samples/19.custom-dialogs/README.md new file mode 100644 index 000000000..14874d971 --- /dev/null +++ b/samples/19.custom-dialogs/README.md @@ -0,0 +1,48 @@ +# Custom Dialogs + +Bot Framework v4 custom dialogs bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to sub-class the `Dialog` class to create different bot control mechanism like simple slot filling. + +BotFramework provides a built-in base class called `Dialog`. By subclassing `Dialog`, developers can create new ways to define and control dialog flows used by the bot. + +## 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\19.custom-dialogs` 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 + +## Custom Dialogs + +BotFramework provides a built-in base class called `Dialog`. By subclassing Dialog, developers +can create new ways to define and control dialog flows used by the bot. By adhering to the +features of this class, developers will create custom dialogs that can be used side-by-side +with other dialog types, as well as built-in or custom prompts. + +This example demonstrates a custom Dialog class called `SlotFillingDialog`, which takes a +series of "slots" which define a value the bot needs to collect from the user, as well +as the prompt it should use. The bot will iterate through all of the slots until they are +all full, at which point the dialog completes. + +# 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) +- [Dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Dialog class reference](https://docs.microsoft.com/en-us/javascript/api/botbuilder-dialogs/dialog) +- [Manage complex conversation flows with dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-dialog-manage-complex-conversation-flow?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) diff --git a/samples/19.custom-dialogs/app.py b/samples/19.custom-dialogs/app.py new file mode 100644 index 000000000..880dd8a85 --- /dev/null +++ b/samples/19.custom-dialogs/app.py @@ -0,0 +1,99 @@ +# 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 ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import DialogBot + +# Create the loop and Flask app +from dialogs.root_dialog import RootDialog + +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(self, 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 = MethodType(on_error, ADAPTER) + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create Dialog and Bot +DIALOG = RootDialog(USER_STATE) +BOT = DialogBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +# 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 diff --git a/samples/19.custom-dialogs/bots/__init__.py b/samples/19.custom-dialogs/bots/__init__.py new file mode 100644 index 000000000..306aca22c --- /dev/null +++ b/samples/19.custom-dialogs/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot + +__all__ = ["DialogBot"] diff --git a/samples/19.custom-dialogs/bots/dialog_bot.py b/samples/19.custom-dialogs/bots/dialog_bot.py new file mode 100644 index 000000000..b9648661c --- /dev/null +++ b/samples/19.custom-dialogs/bots/dialog_bot.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState +from botbuilder.dialogs import Dialog + +from helpers.dialog_helper import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have occurred during the turn. + await self.conversation_state.save_changes(turn_context) + await self.user_state.save_changes(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + # Run the Dialog with the new message Activity. + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/19.custom-dialogs/config.py b/samples/19.custom-dialogs/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/19.custom-dialogs/config.py @@ -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", "") diff --git a/samples/19.custom-dialogs/dialogs/__init__.py b/samples/19.custom-dialogs/dialogs/__init__.py new file mode 100644 index 000000000..83d4d61d3 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .slot_filling_dialog import SlotFillingDialog +from .root_dialog import RootDialog + +__all__ = ["RootDialog", "SlotFillingDialog"] diff --git a/samples/19.custom-dialogs/dialogs/root_dialog.py b/samples/19.custom-dialogs/dialogs/root_dialog.py new file mode 100644 index 000000000..e7ab55ec8 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/root_dialog.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + NumberPrompt, PromptValidatorContext) +from botbuilder.dialogs.prompts import TextPrompt +from botbuilder.core import MessageFactory, UserState +from recognizers_text import Culture + +from dialogs import SlotFillingDialog +from dialogs.slot_details import SlotDetails + + +class RootDialog(ComponentDialog): + def __init__( + self, user_state: UserState + ): + super(RootDialog, self).__init__(RootDialog.__name__) + + self.user_state_accessor = user_state.create_property("result") + + # Rather than explicitly coding a Waterfall we have only to declare what properties we want collected. + # In this example we will want two text prompts to run, one for the first name and one for the last + fullname_slots = [ + SlotDetails( + name="first", + dialog_id="text", + prompt="Please enter your first name." + ), + SlotDetails( + name="last", + dialog_id="text", + prompt="Please enter your last name." + ) + ] + + # This defines an address dialog that collects street, city and zip properties. + address_slots = [ + SlotDetails( + name="street", + dialog_id="text", + prompt="Please enter the street address." + ), + SlotDetails( + name="city", + dialog_id="text", + prompt="Please enter the city." + ), + SlotDetails( + name="zip", + dialog_id="text", + prompt="Please enter the zip." + ) + ] + + # Dialogs can be nested and the slot filling dialog makes use of that. In this example some of the child + # dialogs are slot filling dialogs themselves. + slots = [ + SlotDetails( + name="fullname", + dialog_id="fullname", + ), + SlotDetails( + name="age", + dialog_id="number", + prompt="Please enter your age." + ), + SlotDetails( + name="shoesize", + dialog_id="shoesize", + prompt="Please enter your shoe size.", + retry_prompt="You must enter a size between 0 and 16. Half sizes are acceptable." + ), + SlotDetails( + name="address", + dialog_id="address" + ) + ] + + # Add the various dialogs that will be used to the DialogSet. + self.add_dialog(SlotFillingDialog("address", address_slots)) + self.add_dialog(SlotFillingDialog("fullname", fullname_slots)) + self.add_dialog(TextPrompt("text")) + self.add_dialog(NumberPrompt("number", default_locale=Culture.English)) + self.add_dialog(NumberPrompt("shoesize", RootDialog.shoe_size_validator, default_locale=Culture.English)) + self.add_dialog(SlotFillingDialog("slot-dialog", slots)) + + # Defines a simple two step Waterfall to test the slot dialog. + self.add_dialog( + WaterfallDialog( + "waterfall", [self.start_dialog, self.process_result] + ) + ) + + # The initial child Dialog to run. + self.initial_dialog_id = "waterfall" + + async def start_dialog(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Start the child dialog. This will run the top slot dialog than will complete when all the properties are + # gathered. + return await step_context.begin_dialog("slot-dialog") + + async def process_result(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # To demonstrate that the slot dialog collected all the properties we will echo them back to the user. + if type(step_context.result) is dict and len(step_context.result) > 0: + fullname: Dict[str, object] = step_context.result["fullname"] + shoe_size: float = step_context.result["shoesize"] + address: dict = step_context.result["address"] + + # store the response on UserState + obj: dict = await self.user_state_accessor.get(step_context.context, dict) + obj["data"] = {} + obj["data"]["fullname"] = f"{fullname.get('first')} {fullname.get('last')}" + obj["data"]["shoesize"] = f"{shoe_size}" + obj["data"]["address"] = f"{address['street']}, {address['city']}, {address['zip']}" + + # show user the values + await step_context.context.send_activity(MessageFactory.text(obj["data"]["fullname"])) + await step_context.context.send_activity(MessageFactory.text(obj["data"]["shoesize"])) + await step_context.context.send_activity(MessageFactory.text(obj["data"]["address"])) + + return await step_context.end_dialog() + + @staticmethod + async def shoe_size_validator(prompt_context: PromptValidatorContext) -> bool: + shoe_size = round(prompt_context.recognized.value, 1) + + # show sizes can range from 0 to 16, whole or half sizes only + if 0 <= shoe_size <= 16 and (shoe_size * 2) % 1 == 0: + prompt_context.recognized.value = shoe_size + return True + return False diff --git a/samples/19.custom-dialogs/dialogs/slot_details.py b/samples/19.custom-dialogs/dialogs/slot_details.py new file mode 100644 index 000000000..3478f8b55 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/slot_details.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import PromptOptions + + +class SlotDetails: + def __init__(self, + name: str, + dialog_id: str, + options: PromptOptions = None, + prompt: str = None, + retry_prompt: str = None + ): + self.name = name + self.dialog_id = dialog_id + self.options = options if options else PromptOptions( + prompt=MessageFactory.text(prompt), + retry_prompt=None if retry_prompt is None else MessageFactory.text(retry_prompt) + ) diff --git a/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py new file mode 100644 index 000000000..7f7043055 --- /dev/null +++ b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List, Dict + +from botbuilder.dialogs import ( + DialogContext, + DialogTurnResult, + Dialog, DialogInstance, DialogReason) +from botbuilder.schema import ActivityTypes + +from dialogs.slot_details import SlotDetails + +""" +This is an example of implementing a custom Dialog class. This is similar to the Waterfall dialog in the +framework; however, it is based on a Dictionary rather than a sequential set of functions. The dialog is defined by a +list of 'slots', each slot represents a property we want to gather and the dialog we will be using to collect it. +Often the property is simply an atomic piece of data such as a number or a date. But sometimes the property is itself +a complex object, in which case we can use the slot dialog to collect that compound property. +""" + + +class SlotFillingDialog(Dialog): + def __init__(self, dialog_id: str, slots: List[SlotDetails]): + super(SlotFillingDialog, self).__init__(dialog_id) + + # Custom dialogs might define their own custom state. Similarly to the Waterfall dialog we will have a set of + # values in the ConversationState. However, rather than persisting an index we will persist the last property + # we prompted for. This way when we resume this code following a prompt we will have remembered what property + # we were filling. + self.SLOT_NAME = "slot" + self.PERSISTED_VALUES = "values" + + # The list of slots defines the properties to collect and the dialogs to use to collect them. + self.slots = slots + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + if dialog_context.context.activity.type != ActivityTypes.message: + return await dialog_context.end_dialog({}) + return await self._run_prompt(dialog_context) + + async def continue_dialog(self, dialog_context: DialogContext, options: object = None): + if dialog_context.context.activity.type != ActivityTypes.message: + return Dialog.end_of_turn + return await self._run_prompt(dialog_context) + + async def resume_dialog(self, dialog_context: DialogContext, reason: DialogReason, result: object): + slot_name = dialog_context.active_dialog.state[self.SLOT_NAME] + values = self._get_persisted_values(dialog_context.active_dialog) + values[slot_name] = result + + return await self._run_prompt(dialog_context) + + async def _run_prompt(self, dialog_context: DialogContext) -> DialogTurnResult: + """ + This helper function contains the core logic of this dialog. The main idea is to compare the state we have + gathered with the list of slots we have been asked to fill. When we find an empty slot we execute the + corresponding prompt. + :param dialog_context: + :return: + """ + state = self._get_persisted_values(dialog_context.active_dialog) + + # Run through the list of slots until we find one that hasn't been filled yet. + unfilled_slot = None + for slot_detail in self.slots: + if slot_detail.name not in state: + unfilled_slot = slot_detail + break + + # If we have an unfilled slot we will try to fill it + if unfilled_slot: + # The name of the slot we will be prompting to fill. + dialog_context.active_dialog.state[self.SLOT_NAME] = unfilled_slot.name + + # Run the child dialog + return await dialog_context.begin_dialog(unfilled_slot.dialog_id, unfilled_slot.options) + else: + # No more slots to fill so end the dialog. + return await dialog_context.end_dialog(state) + + def _get_persisted_values(self, dialog_instance: DialogInstance) -> Dict[str, object]: + obj = dialog_instance.state.get(self.PERSISTED_VALUES) + + if not obj: + obj = {} + dialog_instance.state[self.PERSISTED_VALUES] = obj + + return obj diff --git a/samples/19.custom-dialogs/helpers/__init__.py b/samples/19.custom-dialogs/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/19.custom-dialogs/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/19.custom-dialogs/helpers/dialog_helper.py b/samples/19.custom-dialogs/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/19.custom-dialogs/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/19.custom-dialogs/requirements.txt b/samples/19.custom-dialogs/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/19.custom-dialogs/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From 5c256fa0c0bc644e6d75975def8a74e389616344 Mon Sep 17 00:00:00 2001 From: Michael Richardson <40401643+mdrichardson@users.noreply.github.com> Date: Tue, 5 Nov 2019 10:27:02 -0800 Subject: [PATCH 029/616] Fix ChoicePrompt ListStyle.none when set via PromptOptions (#373) * fix ChoicePrompt none style when set via options * black compat --- .../botbuilder/dialogs/prompts/choice_prompt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py index fdcd77bbc..3332f3994 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py @@ -121,7 +121,9 @@ async def on_prompt( if self.choice_options else ChoicePrompt._default_choice_options[culture] ) - choice_style = options.style if options.style else self.style + choice_style = ( + 0 if options.style == 0 else options.style if options.style else self.style + ) if is_retry and options.retry_prompt is not None: prompt = self.append_choices( From d6d6213a776037a7a464ee81b2de954281ac0797 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 5 Nov 2019 13:12:39 -0600 Subject: [PATCH 030/616] Added 18.bot-authentication (#419) --- samples/18.bot-authentication/README.md | 56 ++++ samples/18.bot-authentication/app.py | 102 ++++++++ .../18.bot-authentication/bots/__init__.py | 7 + .../18.bot-authentication/bots/auth_bot.py | 46 ++++ .../18.bot-authentication/bots/dialog_bot.py | 43 ++++ samples/18.bot-authentication/config.py | 16 ++ .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ .../18.bot-authentication/dialogs/__init__.py | 7 + .../dialogs/logout_dialog.py | 27 ++ .../dialogs/main_dialog.py | 83 ++++++ .../18.bot-authentication/helpers/__init__.py | 6 + .../helpers/dialog_helper.py | 19 ++ .../18.bot-authentication/requirements.txt | 2 + 13 files changed, 656 insertions(+) create mode 100644 samples/18.bot-authentication/README.md create mode 100644 samples/18.bot-authentication/app.py create mode 100644 samples/18.bot-authentication/bots/__init__.py create mode 100644 samples/18.bot-authentication/bots/auth_bot.py create mode 100644 samples/18.bot-authentication/bots/dialog_bot.py create mode 100644 samples/18.bot-authentication/config.py create mode 100644 samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/18.bot-authentication/dialogs/__init__.py create mode 100644 samples/18.bot-authentication/dialogs/logout_dialog.py create mode 100644 samples/18.bot-authentication/dialogs/main_dialog.py create mode 100644 samples/18.bot-authentication/helpers/__init__.py create mode 100644 samples/18.bot-authentication/helpers/dialog_helper.py create mode 100644 samples/18.bot-authentication/requirements.txt diff --git a/samples/18.bot-authentication/README.md b/samples/18.bot-authentication/README.md new file mode 100644 index 000000000..2902756f5 --- /dev/null +++ b/samples/18.bot-authentication/README.md @@ -0,0 +1,56 @@ +# Bot Authentication + +Bot Framework v4 bot authentication sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use authentication in your bot using OAuth. + +The sample uses the bot authentication capabilities in [Azure Bot Service](https://docs.botframework.com), providing features to make it easier to develop a bot that authenticates users to various identity providers such as Azure AD (Azure Active Directory), GitHub, Uber, etc. + +NOTE: Microsoft Teams currently differs slightly in the way auth is integrated with the bot. Refer to sample 46.teams-auth. + +## 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\18.bot-authentication` folder +- In the terminal, type `pip install -r requirements.txt` +- Deploy your bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) +- [Add Authentication to your bot via Azure Bot Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-authentication?view=azure-bot-service-4.0&tabs=csharp) +- Modify `APP_ID`, `APP_PASSWORD`, and `CONNECTION_NAME` in `config.py` + +After Authentication has been configured via Azure Bot Service, you can test the bot. + +- In the terminal, type `python app.py` + +## 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` +- Enter the app id and password + +## Authentication + +This sample uses bot authentication capabilities in Azure Bot Service, providing features to make it easier to develop a bot that authenticates users to various identity providers such as Azure AD (Azure Active Directory), GitHub, Uber, etc. These updates also take steps towards an improved user experience by eliminating the magic code verification for some clients. + +## 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) +- [Azure Portal](https://portal.azure.com) +- [Add Authentication to Your Bot Via Azure Bot Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-authentication?view=azure-bot-service-4.0&tabs=csharp) +- [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) diff --git a/samples/18.bot-authentication/app.py b/samples/18.bot-authentication/app.py new file mode 100644 index 000000000..70d9e8334 --- /dev/null +++ b/samples/18.bot-authentication/app.py @@ -0,0 +1,102 @@ +# 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 ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import AuthBot + +# Create the loop and Flask app +from dialogs import MainDialog + +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(self, 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 = MethodType(on_error, ADAPTER) + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) + +# Create dialog +DIALOG = MainDialog(app.config["CONNECTION_NAME"]) + +# Create Bot +BOT = AuthBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +# 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 diff --git a/samples/18.bot-authentication/bots/__init__.py b/samples/18.bot-authentication/bots/__init__.py new file mode 100644 index 000000000..d6506ffcb --- /dev/null +++ b/samples/18.bot-authentication/bots/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot +from .auth_bot import AuthBot + +__all__ = ["DialogBot", "AuthBot"] diff --git a/samples/18.bot-authentication/bots/auth_bot.py b/samples/18.bot-authentication/bots/auth_bot.py new file mode 100644 index 000000000..c1d1d936f --- /dev/null +++ b/samples/18.bot-authentication/bots/auth_bot.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core import ( + ConversationState, + UserState, + TurnContext, +) +from botbuilder.dialogs import Dialog +from botbuilder.schema import ChannelAccount + +from helpers.dialog_helper import DialogHelper +from .dialog_bot import DialogBot + + +class AuthBot(DialogBot): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog, + ): + super(AuthBot, self).__init__( + conversation_state, user_state, dialog + ) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + # Greet anyone that was not the target (recipient) of this message. + # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Welcome to AuthenticationBot. Type anything to get logged in. Type " + "'logout' to sign-out.") + + async def on_token_response_event( + self, turn_context: TurnContext + ): + # Run the Dialog with the new Token Response Event Activity. + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/18.bot-authentication/bots/dialog_bot.py b/samples/18.bot-authentication/bots/dialog_bot.py new file mode 100644 index 000000000..fc563d2ec --- /dev/null +++ b/samples/18.bot-authentication/bots/dialog_bot.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog +from helpers.dialog_helper import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog, + ): + if conversation_state is None: + raise Exception( + "[DialogBot]: Missing parameter. conversation_state is required" + ) + if user_state is None: + raise Exception("[DialogBot]: Missing parameter. user_state is required") + if dialog is None: + raise Exception("[DialogBot]: Missing parameter. dialog is required") + + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have occurred during the turn. + await self.conversation_state.save_changes(turn_context, False) + await self.user_state.save_changes(turn_context, False) + + async def on_message_activity(self, turn_context: TurnContext): + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/samples/18.bot-authentication/config.py b/samples/18.bot-authentication/config.py new file mode 100644 index 000000000..0acc113a3 --- /dev/null +++ b/samples/18.bot-authentication/config.py @@ -0,0 +1,16 @@ +#!/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", "") + CONNECTION_NAME = os.environ.get("ConnectionName", "") diff --git a/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json b/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/18.bot-authentication/dialogs/__init__.py b/samples/18.bot-authentication/dialogs/__init__.py new file mode 100644 index 000000000..ab5189cd5 --- /dev/null +++ b/samples/18.bot-authentication/dialogs/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .logout_dialog import LogoutDialog +from .main_dialog import MainDialog + +__all__ = ["LogoutDialog", "MainDialog"] diff --git a/samples/18.bot-authentication/dialogs/logout_dialog.py b/samples/18.bot-authentication/dialogs/logout_dialog.py new file mode 100644 index 000000000..b8d420a40 --- /dev/null +++ b/samples/18.bot-authentication/dialogs/logout_dialog.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import DialogTurnResult, ComponentDialog, DialogContext +from botbuilder.core import BotFrameworkAdapter +from botbuilder.schema import ActivityTypes + + +class LogoutDialog(ComponentDialog): + def __init__(self, dialog_id: str, connection_name: str): + super(LogoutDialog, self).__init__(dialog_id) + + self.connection_name = connection_name + + async def on_begin_dialog(self, inner_dc: DialogContext, options: object) -> DialogTurnResult: + return await inner_dc.begin_dialog(self.initial_dialog_id, options) + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + return await inner_dc.continue_dialog() + + async def _interrupt(self, inner_dc: DialogContext): + if inner_dc.context.activity.type == ActivityTypes.message: + text = inner_dc.context.activity.text.lower() + if text == "logout": + bot_adapter: BotFrameworkAdapter = inner_dc.context.adapter + await bot_adapter.sign_out_user(inner_dc.context, self.connection_name) + return await inner_dc.cancel_all_dialogs() diff --git a/samples/18.bot-authentication/dialogs/main_dialog.py b/samples/18.bot-authentication/dialogs/main_dialog.py new file mode 100644 index 000000000..3e7a80287 --- /dev/null +++ b/samples/18.bot-authentication/dialogs/main_dialog.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import ( + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + PromptOptions) +from botbuilder.dialogs.prompts import OAuthPrompt, OAuthPromptSettings, ConfirmPrompt + +from dialogs import LogoutDialog + + +class MainDialog(LogoutDialog): + def __init__( + self, connection_name: str + ): + super(MainDialog, self).__init__(MainDialog.__name__, connection_name) + + self.add_dialog( + OAuthPrompt( + OAuthPrompt.__name__, + OAuthPromptSettings( + connection_name=connection_name, + text="Please Sign In", + title="Sign In", + timeout=300000 + ) + ) + ) + + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + + self.add_dialog( + WaterfallDialog( + "WFDialog", [ + self.prompt_step, + self.login_step, + self.display_token_phase1, + self.display_token_phase2 + ] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + return await step_context.begin_dialog(OAuthPrompt.__name__) + + async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Get the token from the previous step. Note that we could also have gotten the + # token directly from the prompt itself. There is an example of this in the next method. + if step_context.result: + await step_context.context.send_activity("You are now logged in.") + return await step_context.prompt(ConfirmPrompt.__name__, PromptOptions( + prompt=MessageFactory.text("Would you like to view your token?") + )) + + await step_context.context.send_activity("Login was not successful please try again.") + return await step_context.end_dialog() + + async def display_token_phase1(self, step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("Thank you.") + + if step_context.result: + # Call the prompt again because we need the token. The reasons for this are: + # 1. If the user is already logged in we do not need to store the token locally in the bot and worry + # about refreshing it. We can always just call the prompt again to get the token. + # 2. We never know how long it will take a user to respond. By the time the + # user responds the token may have expired. The user would then be prompted to login again. + # + # There is no reason to store the token locally in the bot because we can always just call + # the OAuth prompt to get the token or get a new token if needed. + return await step_context.begin_dialog(OAuthPrompt.__name__) + + return await step_context.end_dialog() + + async def display_token_phase2(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if step_context.result: + await step_context.context.send_activity(f"Here is your token {step_context.result['token']}") + + return await step_context.end_dialog() \ No newline at end of file diff --git a/samples/18.bot-authentication/helpers/__init__.py b/samples/18.bot-authentication/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/18.bot-authentication/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/18.bot-authentication/helpers/dialog_helper.py b/samples/18.bot-authentication/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/18.bot-authentication/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/18.bot-authentication/requirements.txt b/samples/18.bot-authentication/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/18.bot-authentication/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From ceafbb6eb77c922614d6191d20eb80df0396f1ae Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Thu, 7 Nov 2019 15:55:07 -0600 Subject: [PATCH 031/616] Partial 15.handling-attachments --- samples/15.handling-attachments/README.md | 30 +++ samples/15.handling-attachments/app.py | 83 ++++++ .../15.handling-attachments/bots/__init__.py | 6 + .../bots/attachments_bot.py | 150 +++++++++++ samples/15.handling-attachments/config.py | 15 ++ .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ .../15.handling-attachments/requirements.txt | 3 + .../resources/architecture-resize.png | Bin 0 -> 241516 bytes 8 files changed, 529 insertions(+) create mode 100644 samples/15.handling-attachments/README.md create mode 100644 samples/15.handling-attachments/app.py create mode 100644 samples/15.handling-attachments/bots/__init__.py create mode 100644 samples/15.handling-attachments/bots/attachments_bot.py create mode 100644 samples/15.handling-attachments/config.py create mode 100644 samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/15.handling-attachments/requirements.txt create mode 100644 samples/15.handling-attachments/resources/architecture-resize.png diff --git a/samples/15.handling-attachments/README.md b/samples/15.handling-attachments/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/samples/15.handling-attachments/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/samples/15.handling-attachments/app.py b/samples/15.handling-attachments/app.py new file mode 100644 index 000000000..e91d29d84 --- /dev/null +++ b/samples/15.handling-attachments/app.py @@ -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(self, 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 = MethodType(on_error, ADAPTER) + +# 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 diff --git a/samples/15.handling-attachments/bots/__init__.py b/samples/15.handling-attachments/bots/__init__.py new file mode 100644 index 000000000..28e703782 --- /dev/null +++ b/samples/15.handling-attachments/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .attachments_bot import AttachmentsBot + +__all__ = ["AttachmentsBot"] diff --git a/samples/15.handling-attachments/bots/attachments_bot.py b/samples/15.handling-attachments/bots/attachments_bot.py new file mode 100644 index 000000000..9de9195ee --- /dev/null +++ b/samples/15.handling-attachments/bots/attachments_bot.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +import urllib.parse +import urllib.request +import base64 + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext, CardFactory +from botbuilder.schema import ChannelAccount, HeroCard, CardAction, ActivityTypes, Attachment, AttachmentData, Activity, \ + ActionTypes +import json + + +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): + 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): + for attachment in turn_context.activity.attachments: + attachment_info = await self._download_attachment_and_write(attachment) + 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: + url = attachment.content_url + + local_filename = os.path.join(os.getcwd(), attachment.name) + + try: + response = urllib.request.urlopen("http://www.python.org") + headers = response.info() + if headers["content-type"] == "application/json": + data = json.load(response.data) + with open(local_filename, "w") as out_file: + out_file.write(data) + + return { + "filename": attachment.name, + "local_path": local_filename + } + else: + return None + except: + return None + + 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): + 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: + 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()) + + return Attachment( + name="architecture-resize.png", + type="image/png", + content_url=f"data:image/png;base64,{base64_image}" + ) + + async def _get_upload_attachment(self, turn_context: TurnContext) -> 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, + thumbnail_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/${urllib.parse.urlencode(response.id)}/views/original" + + return Attachment( + name="architecture-resize.png", + type="image/png", + content_url=attachment_uri + ) + + def _get_internet_attachment(self) -> Attachment: + return Attachment( + name="Resources\architecture-resize.png", + content_type="image/png", + content_url="https://docs.microsoft.com/en-us/bot-framework/media/how-it-works/architecture-resize.png" + ) diff --git a/samples/15.handling-attachments/config.py b/samples/15.handling-attachments/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/15.handling-attachments/config.py @@ -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", "") diff --git a/samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json b/samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/15.handling-attachments/requirements.txt b/samples/15.handling-attachments/requirements.txt new file mode 100644 index 000000000..eca52e268 --- /dev/null +++ b/samples/15.handling-attachments/requirements.txt @@ -0,0 +1,3 @@ +jsonpickle +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/samples/15.handling-attachments/resources/architecture-resize.png b/samples/15.handling-attachments/resources/architecture-resize.png new file mode 100644 index 0000000000000000000000000000000000000000..43419ed44e0aa6ab88b50239b8e0fdfeb4087186 GIT binary patch literal 241516 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

Bq>K7N*zY|DnT>$V&M1|VU&Ud0Hix-|jc zi;CTIhWQJYvr8HW2p*mKCoEh{$sjGzE`=-SA>zVg0sgP9I|sf9OItw zdYr#>mMjg)7wSJ?6een3g{O#igC3-fLr8dac^ZKZ2L})D=|ck370(PE;W%f32{EBz z@SA^j1=`7eY5eec!`VKN+)*4xD1{4OURhaLUteEZn>NpV%cX}{LwGU%4%C5%fRE5! zKL%|%s}F=*M=+cSCAxStB-KBds&NXW634AgSAG;3vR#)EMKm*h>-ODOu|!6aWHwzz zMI~Q{q;b(+w zN&y!FTqOE+1@Kn$Xd;`O0%MuD9OdZCI~@VFq15b7B^7xK<1tPI0cQdd`3*iTqq zK9(8a^u!7_oHb^e>ltp#L5J_Ls{*@B%-7Em1&Q#}Aqa@X<}69$oVatU@AG)$F`&8x z$U#W%H;#WSqJ)DnqZ`@87I5kKgYTqGhOe!q3$3Qhs?tKb1P=U2jUjFoil1J@pRQG; zB_S9GjTwN-;K(Zn?JH5Hm5LiS1qiFH?o?(&@<3O@HPp0~43)1?kq;gpW6ho*P~$&| z>BS2>lY?)-M+Y?;}p1o7G?i2%6f@kWuZ5^tR>WqeRc{DiT@Z;)8cn8T9rj zrff9ti1lzHLdUXoKXh!*S1g{5HxTiE`$ThX9XWiR&iuCfWe3InjWr|KVg4!Zgk;z; z;CZ)5!z_CUz~NIQ#u9RqMBSU1n6S1vAFMwP0XaZJGr0I?#&V)jtE;b~0^MxnA#su* z;BqjUvAmqv-^p&XlCX^*kkahDlE$Ay@Ca{BS81Io&z5Ix6<~o^yl^!2T&>#{%IAi} ziuQ?#btw2gx!{)8Q&AyBNdV3b>79g1vE#k6xD3mrNsBjmxIdzRfQ&3BCm;9UX3Fp zj?d%ck^dW{QY@TYI+&UVSL`FwOeRTX!Qvof;PmYBoY(oS$;B3dUhhN`MXA2NerV_? zB0L@{S`uTj7I6qNfx!Isx()NmE>+k-Ej5!)gNv5ReFgHicL%ee`?Oe(K;Cad%kq*o zPg`4bG1g*#7pN#S`Fguc9-e*WJ{VMw!gbzuo(>-9_xHYs5ZXHrzIsiBLKHX9`g#^* zftQ z7aeO+K|y!3TOaNB^4Eu_M)P`61RPLJcQVM-S1DPUu6my zmX)0)U(9L!~!uPsgj8}vVnDBuR&Sl&8`S9Q0o)6*km7F^{d1(k-7QDwCfgXQd_fw^@F&_iNA(h~(qn-Y4$nsN&|D^yk zmq+{K!|F--oa88Uxn{AVrmCu@y1KfuGS54m2}%vGoA28CTD_t0Wy2vIZ+p8!bCYcJ zgm6WG>e$qnR->1zzF*&ks26mo;QND#qwE7X@`il_S1dj^9~+xxBdYV&V5A8qeYwtG zYxW9Ii~ZX-WFmo$g-vz=#^+a`craf`y?WEJz5YUDT35S{;o^pcG6iHLq;fUd6CIR9 z;yr8YN;+CPGP0srHbQXj3zv3YUfz6H$NIg=YIvv3Nj1~3^jFi$G4EC9KO}f;^!sL` z@gB}?Cn?9E=lLy3RF!pRsc8q`wHMOD>YeK=FCN_ie2 z@-GYIY+-rJ&+xn3vS25dK}Y-m?fv;;u2OBsiJ`o%N^jQTVN|J2HZw2H_Zu8JY>PS@ zTovQwIf-EkH1GI!giu~;1_vkUYNMX4|5~fd%S_92>B2MN5=lbAo@s-5->GfD4p|(+ zlh01dajriks-dCLf@#N)IWvxc_utxDi8@UIRY9d;{K4U2t$r7+a3YsQ1vErk2#Z$r z_f6b4IUYf5Z0tXunNr^C_IK3lZ*$fXMFJZ>00$*x%@5+0xqXNEg@wo{xU@P=?st+* z^-95S+YV)x&rwmGq=g2z>owll?K-6@!!*TSCK^WlE^}AiP6$HLxE1K-YmJU>!`X`S z#e?k?)klsDV-}S*QAL{P&rUH8cR4jm>WYBpUBC{hku!wp`js- zHC`<%-$z;Y)MaJrHJYhO2Cd#R-L=zhaqA4|H-x?y^?Q4XKvuQ33I5N)#Wa|$Cj<_WU6Et)bsJeYSgr&0I(EiW*q-l+aGeB3la>;NjE;_nA8BTe{-yO<0 z@`eDDKD{RELjL)@uFIO^^x8C`2g#YO^oM}b%X9(zkk15P>_K_ z!{-DS($H9?QK{4=)H&VY61Rqu7M3Vk}~9ZLKi~fE3nPUtjU+ftEH!O-~N0Z-GZTB4Lht*o*hH8}<`F6?Nr&#wy`In=U!`g})r zador&%Ttc6nIWB0S=BLd>}+9XHpAhS&+yyt@^JwAIp_!YFBaSw2hyZ9J{In^^#vOi zJOC?zTbB{0VIV9Hf1`(p(CJU&j$qIjJy|>*hRUfG%_&YsTKqm^%lp#G_RGipOtDGP zES{mh=x-~we+31*-CMblkxs(Ys;11D0^9Y@>t%UQ&yR_9ZQ*|GS!&j<1_s~3x|jqE z(NVni9|s2y570zD9xpqfDbkQB&^zS`33FRp`i*lC3}8XtPm(w%>%&0ZrR&jLK?52e z46t(Obp9AxQDL>m-Y^7YmOfS?5ETA6pI_eBZmuX4{%m1UFlqV~s=&->#lynGW6gY` z;qP(iAXM3p;dA|o{N(eQ;v!qQAc~9@1EiKoNh(b&Tyx+ar@YT)Dm!q4(OI`%O@559Ws7lMqesSQPK*gwY3J3^r zaOf(=6M6eNI3UHui~!NB>b&jW%k`F?o)(R|6|G5gY6~A@u_DtAQBRbj&9Gn@u z`fRwDNO&7(G!+%`Ww&DYHg(-zM=o>^#hXZv?sy2|g%)1ka!`7}RMmODKn4R~Y7`$w zParch$Hv9s-~0#HPRzh=wFDGb78Q4j{?_box^D@1Hkf&3Vqb$G(@=xv=rbk7xx2c~ zS^Xfnm4_C7n3EuC`I}R(O`pH%BhVvVlQ4GZlz}jL-jLWIJecfIaJ>KIA^*2I*V?Ld zt{fg~(I0J^_c#4yr$45ETj%QlvY;*RCfptdxkb6LdHE`Fcv-;LsARVoX-0$=ikI_@ zK3B~8K2+3e6;luH$u;;XMA&LI$Lq_>=%|B}vawHp8*)5!sJNTY6HWM`A@N>s!2ng3 z0tyu%LkKF+3rr?cxaa^2J^OkR@B8Bo&*1tWO^S3u-G&{fsk#lDiL#ZuySue=Ao*#h z{K?s=@BS30JO^hORAJy185{aZM0dBo;y;{>4B#buzFY_QmI~^#ZT1HVlMk_zlSMhk zLVbhz2;QBmM;3VVxW3w)T`w+0Iu#dDad&@i&1n{k2>z`e2SM}Wy8C_WW)?&jFdI68 z8)q#lqZ^c7Q&}miFhkrp)n{AJMkoMrhEeM$?Sy(t7V9 z=+^A5D53*F6h9&Jm&Z^8XP3Z(-k&a@@P>_@FV-3*czD24I5VSdIapbF&kx3yYp3OB zX%HWGW?(GxG>R4i)Mn?l%p0|rtMy63(dFn=d7JGus#0)HD{wNjScP#~QFwb+AIC=W zd7vfVL2VhMB7vBNUGyYw5JL!rWUC~0UXv0xk` za1Pvz3@u+8FW1u%#N~k*aH+~!< zvf6fd%`#lM2r|ve)Pswbw*B?sw5y{hH!~AL&E&84F83u7wlWQ6pQ1&>sv&EfbV-N% zZ+;d5LYkc;Ec9tYSZo-mbsio!-&aVO_@?B8y?Fp-60C^FcfkOW-;JNWJ12}o+5#+L zn0gs_Rd+|#`tkkI(b1&lBV*ikQk)>z4^U6>lDpxeFsZfjC98??@%j1rzSH+@!4FU$ z6ch=?hJjs>F=n9N&Q4*_GiqB?B~ll8=GmfbIpmx2G+(NxLQso!x8XTg00CywaA`_ol| z)W|3y6hG*rhO9!Uc~jr3KQp!SeXr?kkgAsiQ1)9NGM*LMC9>yXS=}Z=cp>yX*!>7J zo?S3hyIgDe-U$FVBo&kR`K!}p;E3Uw(V&};n_aX-b=x&`nN3{wG9`SqX)!>!Tsh z!Ql})R$BbXbO6Hm!}F6r0aRSq`XxOLqumeia(vGgpN_G457Vzk_)f82$RV(O$i=>GC%=Pv4l{Lt> zH+Vi z6Nu)3Tn(2Ijv6F>!bBM-N{Ws?vnNby_+Y=o?J_YY=B_mx`g*(laLYT5Q4f;kj zwR7u*>(=*qv&DApE*JtfYH0rSfw$+Q{q)4BXC^C|x((XvrB;qkqZWN+c!Fv;2We#R z0G)y~J}F6bxUZgyy(8qOFTbG`Q_GqDZ~fm6ZFFo5jtdV*mYRx+o~C4UIW0A{q`dqU9xgDr=qu5b zLV$gZAIcDU9?Ydwg=?(Qrr!|x4Ko&^P#XojfWnWT5=6mVQWVflTw2{S((l7g~`OB$|jh`Qp@0W7U z53hUj6S+2di}=_W$d9k_$h$B?pYqNIFYS^h9eTr=cHO6sPQI%(ufPU7D}gIFZzw1z zIdT5*4R20+{VunhiTx!@Hp8hPQyDp_#BATblP531w-=T8W4kn7Ub_3;Fd_(wR?{(M zY7`#?=JA=P9IZ}E62XT|bcmyybeHw!U%-k-euwbD*A4WH@clh}CO^ydeVRC2Klb+6n22pQJ1y;^05qJ{t+B6xd!rx0)7{y|3D! zA4Y-lcK$$FSy_&Jm?~x20=1{7=K-(LEZO_hCF{v!zJG6#Aq$RLN(u!Ztc(Jh%Tq5Maebs(-3 zie5UtPo>v-=>0^l)~ttXmn>2bT1Zb#&CD|Z;dX`Ee<$*pUD>?6^{S*B&-J*Aa6MmM zT3!Nbc+Bz1w2v>X)r1#lK1BW-ga`<6>_j#YySl2n5CBXBV7)e~NYTdc`#tT@gsdQ7 z{`A>?v)yq&2mb7wjI_L9#kDpdfJCW@nU^XKeHyCh)YQ2#=;guH)Y?ge`hZEN-R1oA z8J@IA)%x$uc}99BD%>h$2*u6C07;POfL9IFg(kBlSnAonbVH@!rMn-j?m0{kiF64{(6k?oGN-9M0AX!Q*I8Scw-GVj!p6{CiQOriA z8g;tTQeh5tRaNw~k_vZzXxT;YgF-!OiwZKZmP3I5=T<|1(_zuME&a zb6HAzp5?(}l{9+&Cg4d?t5RpuY6xbEPM$c(zo0i)k(XDm=87FMrlh1aH@65t32g>O z{yLow^WFZRBrO%viRlm`Q1r+9RGD&6zUu1cQdXz3isBW^-M7o@U3vtw1X@}+JLaaV!B( z3=b>W<=hCNR->Dqwl*TH*I>uza7#tk>>i` z)1c84SG89`(8v&<-QF!5jknWsQc`2Lx3_I=++Q$fvpXQ+FEM!6ATyv|82GtQwYds{ zA0Qi~CuI^u-Y&0q7&J5$v()il6*yX-pWx~a%n0oeQeYcD6$Qe-1V46>DZ2#NDQ*Ma zuY)j$1_*G74AhlLrR4e7<`JIBU0fWcfcdqVdlMKHh;Rs{QwP8A?7unK+4(s*IM^vl zN=u;*y2*u|PDun0vH>nU{Nm`oD|&cD9y`K+wBX@(6m~P3-C8DKF@21LuO@AhXiDA| z;slkeR2}PCcJ!(>+bBs$NEjKri(9#PIFMu=vX+kA)~c#(M(@uMho72Rcd!-9RSDxh zcRqFUyv{QI{nI85%*Z})Xm`8Vm|Iz&%3t8z?z&Bo$di6^^tzqIp-*E< zgTjzPl@%zad;NTD=V!7gyK^(Gn0~lGR6A8 ze+w$8EgKze-9{u8kc^lKpchCMCJV5$miS0$o!bu&)%H#B44CInmw5qFxC9t&MxWa8 z@{o`riuIGx(IHWoI1J4Zj@;okxa?r$m|1e2-|4X9{&^tFufx2{+uql{AwPc-Z^6UE zDYX1evI!WN32sDNjtvV0DB{f8EG?zP3A~N=|CH(VuB)h3w{CKPqh_U5*3?#)M@j`n z?6999?2^yQ%X;mf@zYk)0`J!%m7-=~C;_CK6b*1>rmg!H9-7|Y?&rVJ z(T)3k>=UJ_sfwW8-8tD<+gQ1TDiZtqf#*O*VjO&QgG2X@8??6?-Sg`R!35D`jttM8 zxKIP@yU&fk@)St}At62UB~lr|e`JFF3^g~;+ISvVJ`V0PRN9M-EhcS(nHx>x5vK0Pu z5DM4=aL>;x{hk`JobKU;y{N)evlcEBehh@mCVD8jA+hWboslI?@bdoi6+q7B ztk)Mw#E1{{zh&li-9$xC+7=#-jpeH$n!|fULj{p=P)1gB2X5SDTk>zNECHP(aCgp? zmFL|`T;4w2Og&$3gZ1J_VUP{qOy8*FMi}8_C}!|ySPriVU0s`gc?x98mb!;zJ)nt~fehelQAHs@UaQd@aZ$pi$*=oTrDwb4j}a)B zZ$yNYqN=>xaw3Jynf3Yf^n?^>23cfe=XovfO07;_Zfd@Y|)q)1M_D48C9YAbW8Eb?^2{ z04-&6(Wh)Lk^gjEWwl`*|In|ApE+s=P{9M{b^2YQQ->9c|9sz(qbCk*F`d`uDG(@c z`=R-sb`yU56#v}ChZH|ODN*Cm`ehJSc+)1n_V`OWU1OwYvB@qRVs zcpGdYizs~64x!8FJ=YKAK|EFj!jV+(Hbu+wfdHi@U7}s#FP0}b>r(v#?!aNyealh8 zN{b>Rqqw2E*lkzjPgk`%-pjf^5z7h!ia)t=Y_TCRh{6jexMx@>6vjf5;U*@=QJtRq zNVG?Z5>3jXnc(}W!U6#BDbS@sX+Yj9z-^H zKsLC+(%U;5FJ5E(!?>!j6sq>8N4cq=-L%5joyAy%RY7I0s0H)G^Wz`x?CgulA(_$ULIA=vG$$P0gtN2C z%=Wi%t%0NSbL;s=r_Zn6CK0nmG=31hvqw~e45+|-t7$zX${>CT=O(>73q@tFRqye(Y%FJfnsMTLV z68J-yCk0nIHETy1%weLBC`O%^jSD7|myY!^miHPAi^2!QLvAfYjs33+k7yAqb!mh* zhH(5un0(nIB0@LX-et@7*TH^V*nf_Lu?6G7dPYu`3G9f>7eEsheOqyHuy>1X1ZKnl zFDa>qE)rK$#u~RFMbLbDyv&#jN+mIs5%^aiOeUGhF{JH>q9U2(&$ZAeL;k`wYz(+f+_WN~Jdd z#Uoy(H#ep<1mpy#ZvmLzyS=ZcD1xy;^G0-9?D*qm!j`paFzdojSXQsDu9htq*{mVb z_oZmWASE?rjm?G+R3!^so{_{MB2F=?Io?lB%GYdBVP1f40C(x}HduFKu{q?e-``!& zzhWX_nOcOWM^O-zKR^|kj@4!^-8k6^oVjy?3AO4;A1Ya^u`JoNwH$?~0=;Nc>{xV0 z4tKdnCG-39@v)eIZYTnGadA{6*n^%X*m|e2#F0D?QYxmrA>)AN#4##?kDH=2gSSs3 z2NU`uhtgTD(tt04qCSwb{o0PJ$V}IDF}RSWCHC3-9Wz7(q%f5kFoq=ZLBY=h6`(eE zgSH5&3j1|*a6n#G3P_KRy&^1eikw8ij;3tWHE&rnHZEM4j}T=HRj#?bvbJD8!NEBN z!qBWw?ULg1n`1%mf@V8VTVDL>++4yMbvy!^4UglTMzV+r!ZbcY6W|Wqi9wH-%Bwokn70l37!D?q`XL-40$H3b1oE0f0Jr#&+(UMil z!om)(sd=%4c$82ewb~ibmWYo;CCKD1>^pFAYT^V~n4q=!&$n5V!?T?TQ8Hv6RMcb$ z#HVAg29h0%hi+*97+@0Nn%23wClCLaOrH^trYDlO0hJlZke{q*;NZjv_jgVi_^~U; z&mO*w>|||-zmG?sqChF|xqV=0(djS)D8~hOC&Ns~3f~%NNw4Y_&W4A@eJ#3JL3r31 z)m^qzr({y;^D@c`YfE{8ZU{bhdNZSW0Jm;OSzVR~!=M+94T*b&iKrgL!oNE3Rs*;AUe=6^3_=V7#H(r_S3B-}bOT8zWptHJyl^my;xOlnt7D zSt5g}V#OA=z|GBni703K$UvM*tz6mr3^mhP(0$ckk4KsiK4uyK3eA7?>{)R4`Z|cU z3A7mjMOka>26C3U<9ixa-1tS)cJV4?C@_%%VpcMfr?Nz1ePK&=)g_@X2j%cFs}fGO z5eF}8xKW*b`fyO;Qyd546V$i~*iUSMBd}5aK_WPk!3d(_Cdh9H2+vUs*w5d=Q@bE} z(BvMorI8GEsY6#t^+}x1D zamyC&r+Kftp8i$gvj1>@di{VZnlmO{wxOnDz=SK367>vyXllC1#&@`gp=?zCDLd|w z;-3xCFUUAnZo6^m;>2}vQBCi}xQhehiixG6s0d=LUPE%^lEqM402-0_a&|VpX2*Ry zX6i?XFE#mN$7R~H2*jeIrbc>Z66qT$Atj^l@GhS!M?Q@S{k^$k!-27G1P_NsR$3a2 z1p`x)WR+fB*OvZ)eAGh0(#f~0uYAc;7zc$8gR*pT(pMCW3Ihhh8TEL`_^-_p?u}?- zQW5|lB$ZPp;lW3E@`EuB`Y2Mm#xYD@4y=-jjKOtnzGSZ8M{64R3wBj1)Av~1Z-j1X zBPB%^s{m7#Q8M-R^#E!0a@#sWy6^y=C=el#sE^Jm4Zb%ANkpH-ThQ*^GJIQ55@ zAOyxN@EZ@P={R+RXrOqVH6$HfJU!FKtV+A%h=+^m_36jFSXL%HmyI?}>_ImkNn2?> z&?8DdI5adEUe0Dbj`0Fb!$!-_PVqA_8A4fvHYE}52Z&)T@NxRsUM^{*#6nFk(mz2} z$}bO{9%X1gQ-Ai0!`n@rgMrPeedIE?#tZDr(5cWANWPtYVuCdtO;y?0YHDhHkEdr2 zop4wTUWQ!NC55>z-8!bAp1Q&D@kORl(89u4eRxfi;F9`;ESTr#l^5oZ<@08YS+Euv zKsJs3uBp-fWoqJshgD_cq^jy{ux{=E)qrq;o`WTviGFtB4HC}Cik95tUpE#uMdynY zA+bN5FM);Kty}IB$jZ(Rj(KT)S#53QrljvRZAd?mb`oB7Rhyxp0HcQ$_7T}gNd8$0xvX=;gz&-FWRI-tST|!qs$Vj_t>5}b7AmLDG*kBI>528g zMv4nAJGJ)8E^BJsaJCP}|DOmC7a#85Ncpmep?cJ@jZxgi>a;Z9%ez();oT^$Hwo#gOi?r zx^ENU5rlRb=4aIe`!uipf&#z&CrX;&U|y5tRnp5oZ^>?WH8M0T&6rLZEnRTaiVtP! zqbNVycI+M^iO$Z7eGDj=>m`%kYIw~p18TQGYanA?H3aYA{w)EYhsSa3%rEu5n!nOD z%XkkC;!#V@myI*iSUdLi-w!5#r9lMPWgRvYd{Oz83~Dpe4&y+0S@Q=uXmhK^IEX5Fzb)-5*W|}9R%>~aw!so zC}Ccldz?xZdbg;y9JIr&disNmQ7IC$v+qFiKHqzU-*HZhj*h(0dAMQlCeKJ^97dF604p9xQn%kH4=~hP??veu zbl<+6=lM2wJucED%?UltBQUl|P!ieO+HQ1zHoA*;K5u!4Aze^yo$Bvk>(eOX)W6V& z(+?Lu-=aI^}>00&wCO-W-~m zl@yl-oTla`W@lwd2zJcc6Yo5Gblq6?GawlU%Vr}QIpuo0F-^R6LKH~6j}zG!Lfz-^ zBy%*P9iX$J3~H6r+&57q-&<#aPzdFr#N+fHQbhv(dd5IeoMdokMImM#sz2|p&OwvgR zDPV=ZJoMNf_usGO7p`(cdIc19TS(6~oo3q#d(F|qyTg4n@24y8?;vC${%r0A>8Ilo zl4&_rqboH^S7~DGbl58)ncg+x7KxE?SMmO(K>3T)%hRi7?a6wvV!%`XsYm|L-I~VU zS72WUI#QawkFk@R705?+Zf10BEb7Rh7#f__)h@6Z49uhO)yGfO1-o8TS2MoH@AD1O zeTxDGh6Y{Rc|s~{3J$q-<{VNKKUdM1`oyP{{Pqfc{#ZJkE=az}k$bQKSDz?*P82Rz z27C`55Mp{fL{4QCp)boeNwy&JTNvk+5kWJk&Oz7@Ma~p8oJJ%hr1HRtn}mrA#P<`N z_@N&xDn5YU*SBjnV|EGl7PeS`RMntd6OPTOySqy^{3c@tS`SnM7F~+V6;tt5>#yLo zSh77G9}a@Jy7}O*>B2QFC_}*8niSvPwg| zJLVG?$7n7AeG!XE#B#mp$!InyV^l6}m4(Q$F-J)jT)BNG?$+NpRx}w}+^puSo$=eh zLGN){S$i>Dy!jNJyXF?Rw*||NOp3^64U8QTbO)n0JYbxv`i_5WTLpGHwRJU`P-6|8 zKceh$Vt1|z35pQ^07N8v9Y^FxAq^KO_7Z`P8rWj8a&mG4XyF!Bcv-M8NP<&n9}fn( z;wHu>Ch}g>M{QZq)Q};7hSnRH8NWi%ztXUA&bqqzaZKs_npxEgx&=hi2JIemzB(;O z2S zy2%D|rM2_=5D-UE$#~Bi0u*IKHI$iy59CP{sQa%SkI?9OI1smlZW_o z>ndUNTf~81)9Pjd5#nO(FpwY}{p5`spTzM@&W4iZhFmwXkLP}VKm2Y6h*FZ( z^!xvSS0TwmhnzeN6Y#oy-6}vB_`a4%)y<5{ZvXM~RaW+>h(!sehj=H1p_&An2GAAz z)`q?ET@4^_5!KE@@;yE@W@PNnOzqg|DC)ce^)7+$k9(j|PeE3i13S8Grm5@ws$)|x zn3Al~S4JcUZwpF9&Oemz^T1@ca`G^QNbo!9Ai3?lMt|?<_VuGmgAty{SAePWrr+Ru zYCBmYmvjCD={4WLn$GGnm$RE$4!V@h0mx({w?fzwX zzOf_7h!KL`CGnP{vh50eiM3|ZG><74;lK5k*o zvXt=G@Q9<*3UaR#JuguX6Bfdf*F9fVHOPEy-jQK4WIMOv+_=~-VFOE>YoJ#^TNN$= zLC6&yHF2st!f^+A0n*D6=KIH2vz9Z+Rr^E}WK%T+J9+u$CF7m`U9h3SxH&7$MTdw&JnSUHCD6rFO zPwqODVT_B0#df|9pNn@v0X#alrfaW$<@wETxef2TG3c}(0P9&HA2VUyZ|LIUq8f=m zeZ8lX?(au^6!%|~ENdgw8AsH4F=LCs6{FI?~%Z$0^L zkjZ5%5AI)s=_|P@DUD?H?uMpY{JAC|OLcmQ9K6GS_z$jd7OZs!CnOAFUmMOEv+#;E zPELbn?jhHt2$7s|jR<8Nqu+zgXAJBLUs5JpA%_r>7aSV(a%W85EPi#KOXukq{Q{8%os& z>!rOsz0AqXmQ5yKTwbQ6C<888+>pe?>`uGQOp9FK=ITQ0qjeU>dP_)76c{1o8DPqX z(8jh#*G(pdM8^&pC+;Sm0x?17!5>G~H~|9y0IQASP;-_|t*fh$kMM+^FS}*Aewpdh z@u(~;EK94aC%|l$RuwRDI5;#cywX=9l{^?yokpIOGybpn6-2vGyu69zkaq_v~~)Au?6dcaU!8wC5M)o37rKEX8SV# z*=x$Yx3}M_OPF0)_ZwvJF$C2QX|52PHUtQDs@3l&iNkR|zWi6@e70bYk-H#Y1*+)_ zh!fLkDGqE+OUpt+7GIN-lZ#7B6CML%A%&hpO5fV6U$_c>jmhUg>XOZpefe)2vk&w* zoN48g`4)gm1}}kPJ!@y7`D>C(OG`fpm!X+ZMc9yFj=+ha@yuE#cWz`m?9V{A8y|IC zPZW1nqpRYr((-rtnm1ZGS^qNz3=fJO9~|kw{My71zpX)+?mc$qj;I*#ufQTkjUXk1 z%>fHlly_5atvy!J*3Qi2a3=(MkkzClCBvgp`-=EkxcLD*Z!x#e<(5Bkrt_7paARnh zx2x3SCN8PAVNg*ob)<`5xE!^$ag)a8&tvB6Svocu$x69kfR)PPlG3Tk3=#Q!LIeon z-;PQ~FE|2u8@k5-3<2MqV5Ct4Mgv+vE%g=HD9iu$Vgeh1N<@+jrLE8+0#zfw?R69_ zUJ@C3u-)T&E{e?G>2z3mh8x)V0frRE+2aO3@idVbHb?p+sShl}{%`sy4!H&RVG!k` zH~-*@wCasuH}a!9UBIX7dGG$MvWoIfJ#JohdNzI@S)7l+cayRi8*bnAjHE*Dt3zg! z&Zb=)(8`#yYSSA%M>XmCZd`j2}NLx_`jMlr;m(K<&y&v2CW+( zpPx}p?ko~ggsam)OI)8H+cxb0w~@>`lW1sY99cER%+F&~gzt~Qj)=PNJErdsrtdrC z@0U1a{truGBS*`c+dr!@5wVeRQO|Ea4Y#lrvhtMEI}Ti7;=|RLtRB*^w~n?P@U`;l zUQio9^ZlU0YHc5(xj1-u+A9lv7WkiwEzCvnfc@Q6Y=VdE0l-d-!J)thXfucQ8vblA z406iB0dO!RH5>2uA@dtylmlGjCe6_~XJWbvpgVE#w!Yp+VEgdN#m#kQ@{B)7xh5k$ z-K9gf@I*4=pmR-Ds2JG0R<-2DO=fxG;^n2C$;F5y)}<@pSo(M{Bv9+}V;hE>M90Wa zAVNt$Eatv!4q+qc}cQ?7ascAOrs^1Sscf{Ic{|v}?R= z#YdUDfGF3((OGnCBve8<00n0aK_Zz6H7;+pYRuZ#8^Kqo$9Z=$bETy6jnzW1_%wLC1z)jN7((3Q+4=_Ox zKwC!T(BRUV2uAQ#w5^^WQ-Zd)I(NFb^URz(AR>sfW@#kOrfK;KpDi+}8Ut_!@;-AR z`UrqSK+@@>q%7}e(P`^SBH(7}VI9gdn#0=LQ{ey1ed>7`&V6pU6|)n;Z1;R^xL4A}HK zELm0q`9ju=)z>PN!QJ^2mdwnBefiy6s;>sH?|ORVfdo9q&yY4CGbLuosJ2byqx{9- zMh?_~SmLXN8N$)Q#5n5&%DO43-VvFd9x23(UdJW_I^_()B>6#O@p@m@vo+SXnUzTq zx!cIuOLK9xkC`WkEE68|+j+y&=esM}l3cl%qgc3;lQuE{= zsbj0~FSll}F{WvA?@XOI*>5%$NP`}&>zmI8J{5M8W?^JWemDBlYocq-p^X1dLD!`O zr`b#iHOIr=Wu98SjAyB*=Uewi@*rF<2f;*`)Wly7$@3({_}8w;q8zajIhlM8YU~tg z>b#bXpMU=%TSIVDcR)m3ua?Qj>L)@UXNYLU72`t`k7k0+q{4Jd7311R4KfgPv78}f zyE=E|xPx{}3~BsHPfN=sVh~@r$W#jPBl8h8~b37w1BO}3imFs9`qKsGo zhW(7$JrO9XfIgwd@(fcTZvO)Q=a;pnhhc;3i4t6D72)9Ng_Wh3(vFw@n^o_X-7E9L z!gIgAh-Je`1NOtS|Y;>oo%mf<`QB||K7L8b=)v% zbNN)Qh;AWt!b5VS1lY2_!5Uj*wsm=)b{dujPE`4h@A9UMKA;gV|2)FVh{Q)1h0!Oh z*F}~ft%)H)4w0Z><>v;nnS-kWaIp2rNK^>!P@-dhxv%b{B2AK~lpPov+#J*0ybHDe z`t)-5@ulAGWG*{4dy61jl}eExnC9(-eR2hc<&<~qUr(3;SIoPrnOB9=3}ji4-DDr{R_tEX`GpC{h~lE-5zt3w z>a6F+#w`q$f;k?*`dZJ!+ZD%z+pn3?F)@_@O!F+VN(G#Ig=3K8rIztc7oZ7COh_EL zejiJ7_M;KDV|;C4VFm2Eoe@qDW_RVtEI{LupP#)2V**0V6Fg`-N*y?c1|6d91fwJo zO3T-i&0Ci#RIH{jdN3hw_xBPmZUL5Gd`w@Lr|%tdPtFI|!b0qb_mO;uJT-Escokuu zjwSmD)kZFAj*hm5T{Jt~?{@u-8!<7!s?m_eKWa3A$7*e9sf|y#M&zMSMG29PgP6{nawsbd+>wcoGI#hjDMsa;qnY?i;+U3C};U18?i)a+5Q+ z9PdagwF;+QZ!X&}uJwRx9xAKBW0nTOIO(l|jKYp%2C&RdB?-M2pni;FWpdQQ4++0)w_)&ZR|zJuFJY0 zRJl`gYi?~Fg~6T{^r4YuB_)_)A=~+UHi;|S$H#vmEy5-yQ(~R{=G9)_qS|*xi)bjQ zU)FwlnKp`w^5TW_4ERuxrwSXs>p(%_GA+YHxu*=|AiW*|TJ$6iq&G7&DbzLa+iHRd~I zMW#M8wc1|BgrV;noLmC*?;5+}9RbH8-Gk`5>&VK=?$c;KS=tB{s3`P@zPq|YhW%A!Z~fgW@XtUD6)`Ro9bIi} zYiMgkSC>kO#^w}z%yHfMjK0_T(hCx)n@Hk=j+$B{-cevd34c76;0d?g8=J`drzuv zDjs8nq2&H;*h+cKia{I1>9ZC_K1jmdkWt#(|)vx{exqa0zYYs-@)!{mTf3W&y2=;GJwH}U~ zmk^>Q0dfUAJlvrRyd^qN%oS9vY$Ru90^9T6?otELypM!ma)17vS6~tSH+&+EPFXQ> z80wF%srWXsq4bSRAZH8rv0bhPiQTJ+x&MKxuhrL924;t&eb zCcJ}f^%SSp;HKsISz0@7UH{A7S=U)63RX=0=K7}N;#d&NQ72rc&T{fsbF+XWJ9g15 z(1nfAnpn#@esI2`sTf9KsXWO=7vY2KVO%zWCTA&%fKu)xPeYyO`CuV#ZA!1{@42}$ z-C8saOo*GQsp(6nuXtrvax%0!l!hUBoj{pVA&j%n-f2vRpo?jq%ny_ASBlvp98Imc zZ4uHDMdjs63UU;z3xPd-y;;^@>Qfc7p-5c58XG^x=F4xa=B9hCq5TtrVfCl2Vs zhiUjpPTC{frCJ5~7XC?Gv`%+DiJfLAAw!NQdE{DoN!t~f~p!4?pHUG&H>9emu&QS zr@ji(F!T(3`lYk}c&GQJm4sK#dqo2Pg7QIYF1C4O{~9c*@YrUWnjY`WX47R94^lXR z=-*SMp{|aaGwRLutUY!{lDv&ZJDvDAIFNou8rtzW?Tnml(nXSdu3LJ0 zcjrDyS*=v5{5_1JvM3#KDBaSEAW5!^5-RuTJb)u(MYwInASI>eT#T{4ptNOoYY_O| zNxys+#5#S*eW&^%VVg;_>`!2s7~tFt7&+ukv6rr(!3`D1(TO98J)69b@(=eFVAa>| zu$rdbD63@8Vd|tNBqDHV($C2Fr`;7@aHJGrq2=a2GKL9HK@m_Uj~_If@GRGKy;mS2 zetpfJK4!z^KiJ}V7mDxjHf$8Lzt4XO9a^S*GE>A0?)kH=?P}ZC1_gn>4STVXFWMa< z9aRXh1Ueir3O2M(HW2JG-X?wTlB>%QtkL(8q*GQiO$!SQ#a7nkJdCj)4L;CBM$n+YPqtMTVlXq&d|24O60A&OHoNibBO~Q5RaI zvW!s>H0Aod?`J5q?#|Bji3uY|HOeAfq-uMTkN6~1BqXoDdU$&NnPE7EjEMn1JOsl- zn>w_jq@;B-GQ#0(Lm6g0dZzBuDqpP<=BzY;#rFZDwJi5N5zt$f{#K{!+5+H*w~UNI zt2`+qg}Tbl&MxAm5BHt@`ztN3b{1a+ZNO5uGHCn@Tu}TRM5s~=Jt177HDxl??*k>4&CE_XjSZfZ$Kwzm+ zB7?n&3|m-8$BfIATs2CkR99brvexkeIbbN9hWdHf%w3AdV%R6}fbZXEQnRpmj+me( z4r{oy^babqvY?}uOhn{kt8o{I5a?f5$5l9;KX=e+nVG2xfj}g#0F(gQ!`f4y6y=_C zYC4fsFEut5Ey&!{)4$hTivc^EOFXImr~j?ZO09*Rs}|j%qP)C8gRSD1BmWfT&xmac z%N#6mv~oLt!BM_QqZFM)2$qnFm$xOaE}bjM3`?CZl~7z^!a3O zzQc#?l?OIT?PBWR#&7Z~BM6R6P?+^qY->kX0dEd2?$+Nx8%#hR`3A0mdb)GnwM9Q$ zu#<;{krC>%{@chv3$$>qmeEyFKF}?iT3kem(vx=zTd@rb$F|jOFb6gnPGs`~l!6^B zMcTMxdeN2|52zGAnD4zIXAXqi#tSw1)wTW{sS*aGvdCefWzgA5l)(e4ot&BsTIx(%Z(ANRN9_e@<79It!P-4)KD<70dOBgR z{PCo4>8Ij=*@S%*d@B@ddPNl*aeU5{<+{Zc#Q6X=BrLWNxDd0eqt9{iN|v@dEkPi;uAdh zF@3bs_Q1sV&PCJ1%2EQ^VmQfLH}u7L24NP>l+g78ardjFq}%h= z{j%W^Jaj3F;-fA2(OTBrOiRZr6uT!8PV3|(KU};dHTqRX;?B;_@65~q zPH|}ysvqufQAPxw@HGkaHZ?6ZH7y-h*7di(F*k`dvNN~GvXSuKFuY2G7FvE9ZtPry z?%fL{7u~*{ZNXV~57&i8?g#v{5)?61k9& zHwg&|w%Q#OOoF>pCJN18Jq|TXr7%BgX$@~@)OtXbE6q($W6|LzC1USx;eJy`luQS* z$AdFWGW)>AKY`5vzr~YABP-)eRF>nN!-r;z|AXJW^n(D;jzdcr(gjjzHt!hn&?^Nv zDnUWy8BBd-O9?i7C^5a7LFTM4XqAShA5h!Txh|jK!%!;iP@mm+!?_72y)3z??3^EqAdlnbj=J%kS=`y!8T= zI&Ido25HBM>2^ZYv~rzpzLC)NXdh}8j?8NrWXf~~$A?GDUuPJo^K#O)>6RzT_QQI5 zJAW|ql%P!aSLndtNfTt77~kkrWGbnwWTIyjS?kKyF8lr5ZYUB3^IO&0TENOp%(5BnQH~xp2NeYrxJQ6)ct^7h^jjz>DB21I z!0-K|H_YN!J2iBUO8I1}f^;+^*zOD`vEuY+hAqBQ`G6Z2laTOQ4LaQX2Q5X~!bX)O z7A8%I5xSP9rrGvZEJr^Xii@+ee-EgS0}Dr{2{kgT#Oa|Jf@BvtmW?Lz?dOn?kT62R z-xYfDaQ8X6Qj?R}9>Co+fw1tX8~tKtMl+nq9FaY>etLd>URHK!uM#8|5f;TvyE z8f?;1xe7hE^@lP(S{iPgJaxDJx0i6;pa4C+ar)+1_;pVlm|A}y9U_d`bY8>{Zv$26 z0Yx7EJQ5I$8nqSA5C$6SV&fq8$Ks-3)02_GuaRN`x~BO^m;C0A?8;bZh4Fa)z21VT?Bz zxzAGCbjE$HVkjfW=lk{Ca=w&J!&sDu7S zJ8y5FxV4oh|MmukU+}BPn%C}1bxc=;Ygr3GhrG+FUZ9?Ewh#~yxj*fm+}H@6^6z-w zt{lK1dIkS0UzHyFcp@AIIG{o!Lx)C2P|7ca(Op`+taU$e?d4#J`TG8ZQN&=T^w&JR zc#5u`WRF{r;_5jG*7OaA;o-V9B6t6l!Xojqp3TiQgpIPtpY^$`O;|_>1x?*rH&lkp zm069_lp*0)xpRdrM4(Y`B7Ff%A|L`FZP4b}`57n}?tX;nhy^QjKNV{D)1V6Aw35?f zJXY7$%?#9%d?z2+UcYwR-gK;;#D6A%hJA~$bbH%qY3%s>4yGPJ;1n3S)54AJ{91oZ zOP=$yv-7Zc8@?*dj!2Ft+Q}+ zJh$19hQ60zjMEvxWy_`D3=j_v%`eP<7q$cE%aX$@uHdIjKuBy_-Kn5VE z=UvVx56WCJKYl|Ye+1OWgTio(G`=j`7lEAjZv^B~Q_(KJr)e+4 zHfOwk_wK!!FKN?ygp9IXR<~_fRG6ZYY}3dAw3t(0Ki#DO^Gr%>jYs!9tR##YE&6E< zv+qkDhCbt18MiVN-^wklr5Mbc2ufTVN{J(VQXfa_AsdeTe7}*9v~qz6TF!yN(=!aqG(a0wkqRu{9=DtbjP#K`?FA1fRhnjY z%)C?fd0Db+uf#xkGsB?Cl|1E9HID!Z$QpUt% zC-wC8!F~6e2%3zU1b3-rR8!UP@pe<$)O<&uj}7yG^APSbyFl2%G9@gu-_Nn*@t<}? zdj}V8`q2`=@0Ek@@Cj^nfl06x*kuS(dLHYyDE>DHVZa#{#3DG#i;qbGF99lCz>4j~ z!&8hzjy7>zdTe@3CcniGg8#Wrj4c6waRS&TGm5NZ`7#>6|BX-3|9^%rcq-FlBL#Ph z+XC~@{Jb_#n&Pw6|BRNn?SF=P4GXYQ2*ElD3tdHmgX27B;7UVF8{6`~|1Ek6!}|8+ z4eVWHwQhMchAuNZawRY-0v`H*UDQ@0Ek4TPp$pivKm_cKU^$3Ds>8qy?3L%Q;{W-Q z%YWyI$FvN+uE62@{=U?}R?AHZ1{P|9^Z#5;^hxy2?`b+cU5~FG#4dsvT?mDSJl;O* z8VCDpPtP}Y?a!%l{5U9zA@-k5erk)Z-SzrB?)n@*{{?jppbfzJH~jxI8jj4>OBqKb z8Q^#=0@<6Gq-q#yWg8|Ijpy?nS>gwcX-i1WH$9w;)zfRi6H1u5mc-FbO>plI%N@r6PcTU+G;Az9^b8}ZV9m0N4$;%CQ7r%MK)AH1r zLnSEkFk)U^qcc)66w0i3{IoFceP8 z0g?g{eY+RiySsgTG|?e`Vn+6ETLUqY(gB%c*+83d3xM6rHD>bO-r|mqQ~u4107J9h zwCi2v(tSTuH-nJwxRn_6UoBDaezEKy9epUB-FmB}&(e^WN6oAmczujN5R~!U`dSJvO%yfZ)!3oM%Ev( zO{w3w7MPh+KEc)Ya&U8-O&t9A@xvcGbk_Rlge-b6&pv#38)%+Q%itBh6m~Nt3@X6E zqz4WPXu)IpkY~>w(Dn5Tli~%B<^XvKJ_6<}KHxkO5v3(3`&t8tA#T{0koOrn51LKIo63IpfSSb$8s^+XmK@?M>;B?bGjcQu62KUOC%A%&rgRxb zzJ|p&$@*XKzoY!0i#zX|U{O**2ta7BIPdm-Jy{uQm*F$v5op`cI4FKz6-kgY;~?l# z3X&pCN=v5JY4K2e_W>utg9T~A^L!?$rTsg??aQ&94?mBH7l4|Egdnzz5+WH&B__t( z&R2=>us|ssWwt*@iMXxpUX>X(ISx*l5g)sAtA2KVxIVEHUD1tBb69h63pkT< zx|o>AmMi8W?4DhBc^Ezq{VNZTZBBcS z6GrKC8$c#Cp%aWw0?MT49&KPz#b(x3(W=dE7ZH!;Qb*$>@d!8@N#TTf@`{U#Gp}_o z2%er&I{^Rw{>86_wxHs;Bm3x$C~y%IO!kw18Pr<=dfbtZSVbQ&UqLH&vHtTPtg&~I zZ+ZHO>{?k!NSY2JuksVP0T$`t4)_H>FaZR`tQ!5USH`A2n6AGn)u-bV z5IFN4s>l{~NKt~$ykWbIJ2)>V5RUm&hj>eNr5*!_F?h5{TzBiG;PJo!tw@TEMkXz|L_<{ z1Bm1Mo4J|(!O{N+!oa}%t3)1NPyO-dKH#x$65@C=j;1mb5L_(MK%&pW8XavD7B-9m z$u<7QQwLY->TwW4ZYnA#bKltEBN0fQ!%`s`Iy$O2tN6z^grl322N0xuwR|^v$088!^VN|>gC4&|ku~$)Y)H@kKStM>Df+P8hPF_tIT)HxEzXNigM#Pf8HF-peihT^5n8 z0-TM*+p&XYG&JuV91tM_Cia#xN5JB#2Ib2tp=a%o=v@`dD!h0=7pWV?Vj$RRI5etF_7G7Xv{pH-J!@^hF9F`sw*3kx(nHKi%2>(OHxto`9{?H3*LI9EG;Wqn+MxIL`KsO(H7U#_+&K92iSYLn!;Yy4o{JPXM zhznRfzi=e|dRYw$vBr+`SXgmr!6f(m46?C_nX)^s%|a3Hqvp#r%awS!`Dr3Unq7x? zKx;*LbU>p>!zB$BQn0pLTwI>E|AIxQd^CU7WbDBxjeT`%67?@H1BXd-2M601ZPv1W zioMu(d~}$Vx+5-?m)@jyq2tyOPyd&j!-y9!o1J*wlxtTTpZ%d(H+~J%4&(|Va9}sq zgpVH&4iimXXLp`p8l?Otkuz~{rCExD;#D~(-);me7CsJz*(K|X0%jE32BHXQHWsLP zUzijH+6<-sJunM3@C4-6>=a1bhfS-+TI0=(hbWF95AHa0evqE!7MrKYLs@TUi?UTCqcb#>T(L5E10F_n;LmhkHL*Av59TN4PR zPK6#IDh)xM9Hd6od5o1Yb$3%epdW%h;N4x5>vA=s{wt1^eaD;2>#LhV0tu4u0LQ~PaDL2qa;Kg+KfGnH-5o!RB_m; z^jW(eF0^P>s!FyTgOlU89%}pI?DX^Vn){%@N!3ZG_QYm}&e^2@Y~`E0pwwf-dk0Kw z#+2Azn!zS%)I6ZaK9UIvMf24Vq)FeS%^Qp^@JUxzDwor){(+MqyPyIykB7>y3tw0& z`qMQtMP0d&@AQlVtPISX_Ix?fQhaXAxbO1K&EG(sV3__viB|ilbv19I9BQ&TCe54y?cX zWN7Ok%Mje1K_VFy5*n^7M*)4(l`V>4`zAs93jUFcP_b|}AoZ1%GSDqTSdw@x>cZv@ z)IqJWK6NmAc2;9t%G7s4N8%;B+kb=85Y1kMFrwzu)8B}+?`V~v*thL}0Z4r-p z=kB;CiHp;JmJD$7Y~Oe00nxg2t}0^!8Vbt5wqxlm(taKkQuIPQi9KH<{3%|UON2Bh z7Z-5(RjSYvqrs>1g4z)VEMl*Y7?18-Tqny_mb6($*=T+INGLH6en+|Xmn%K)gcV&YZ zlwvp=YHAt(T`kGCp^@RC1=E)Aqey%n%IClu%KFBhH&uZum&!bjL4<`zknZG{bGA%{ z3{7X#531Lo;Ww$eD}s@$S`1B^G;9tJ?VYxQi@@!|V~;%(XQ_*@cO)f?8Np%qNGVe| z$uuOlNwdrRIsu!kf5ZN}<8lQ*EAzUArlz%(j{B`E0#6tNu&Ncf3mXP^BQiXTb=pvv z7x@jxXQsj-CEecNytSX)?5*}BMc1gDwWdW*!Lu}1P(WeNaI#w~ToTzoI0`fwJotL? zuVnJCQe!JPU>~l}0;*K0nVBvE8996-A6^@?z~N^b4fvA?0SX-`z82O7Ylb15fT~gx z%_}G*f7G6pZF%0THPLbvpjIFSN0+vnRiBFp-c`4_+v&BHcafh`@Gwe%mvuq{k&Dn7 z*mJfi`y6`uzK0@(Y1{mqG$p{_3)U6?F=G=arlu_wK6QjW*g!R!gdu3} zj}8v1p8lp4p?)8+DKWWrJMB!L$DOk6kuEL7awnS&j_r*bgA+0gRvxa&Zr20Q*HG#Y z08@Pb*piBojZ9@Ka8q&5UWxH_1?y$btT}QaNSFEOw!olKg!y`=R4q3si!@5Ba84lI zrO~rLP5J3=R_lc;Ov}*S?BuxeEpDwS2HRe}DdGiC@+Gn8r6$K8Ub_)RWw`C?Pft%bH|-o1-@E6PKofJALh>NJV7Q&m;sm+; zC9g5te?wm!f{Rqd#VEsHIDK^WO*HFHI!Ic&!Rz`?p0(o<0MDN5G*@*G3;e!v>kbuH@``DcwL2$+&szXC?@--CnRY6rEH#|sj{mtFLihig4M z7nizGG$g)^3icEQmN+EXR-h4PIce=pz()}X#$S{?dkEl3JCe)2jD9kHk0Fe)O5L6X z&at9vO&2~dH$H1$Z!)OfqhTo$5fA`sU@9@XMQTTXVaN$ej#*kOAlkeO42;c-pUAn8%$+P58qR#^LI(&{#u#c)y=r^7e%V0x zaXY%2ON}H+xv-iYg$CP-ErD0(UXkDbCGoYx3z4GZ`+)TeDG)kbLgp?rpFKS?9BFX- z9Gobb*!S*#{Co?}GNTf$+Ss~T-=WVH^ELE;$!B%#*n6Mv|D=LPpZ}8h_y`IzE9YBr z$|4V-Y$JT5?`+^jF6J2s$sb89Iy-aNCm?{pN#>|Tp`p9At;S5M)(%o*pH6d51zO-O z+cz#}*)Dk>?tLnq)z{EybUyG>7CGy2%CXkan2RNo91T*+<(`=n{r9kno)3g3lwwkc znAhg---diy5+xx^8q?@Bt_P_F{3O?~MbaJu!A`ak1irEsV%&QWfduKe;k@WC?K1rh z6dH1k^tpp|7h$YuWXpCOdmNn8yk8bCf$vrvUG#27ebyP(G?R%2JE4hVfJ*FX(sG00 ztL9rh6ye8!3qLd;ki7=t)>2v@|4caG1@@Y1CMF4=^=ty>@H%jxo0^*m8XQMnk41?* zySY_~rtcU0I|Mk6z7kxrZ2(}EY(oS$=nc;t@z`iAo3qpQidjwQBG|bsFU+R~`elb{C zIkMQ}(p7Zc#!?n?xLI-K<>CVAftnieg=VJugIX#VSPbhHX1f4#2r`lWYLu>Q;1d&{ zKeX-d@Baskv$;L$dg0^M0$;oDA_aghfb=Px7)($fbK<%N3P{I-n$W|FyZG4hGo z*{eYy9p@thDQBDx#&Lsnc5CgYkr!4rHn0tTZ!!K2H?6NH4?cZ7JXc*hhqDWh7f1;rZH?(RC_d~n3K$E&18Qv(y^z$-7K95<5M>tb&? zv3pj-pAO*mhll#KaoTp@m(8fmeL5`PWzPf1O}BqDPN^m`Rj!b2)HFfnC7x8X22aJD zwMp-fU%wE4ug4;bBk%#drRbl+X!xY@j zz`#5_G8DuUtB4Hyh}Lp66x^zh1Li;h#!vt*Jr)Ee#w(9*!3t=B3|ed?xllle{mQD0 zPjCvaU@q^rruh2bG?6M^I9*#)BZe!#OLf_{$G^ZUEKIV>j$E%xf``{H_>!v**3T{L zR}aehYJ;X6wF{oPqtFP~)k{aDWMn`kO-@$`D)~(WV9urK4Fo<1a>qjK66K#?PHgNh z+{V%zT-+dsN3N@0N3&XVbf!i)7a(X31bB?&+$TeV3U+pWCvMj19MJfDrgo?vwAx33 zlPsR{x|*FsOJk;$PLE3)Hm>gGr=)BoCJmJHD{0n`OhNbY-BB+uOx@KqF|{e{=x|lS z7sfvgctU4RWxpapd#|@d&@s*=`;A35#dYeB3{*#5S^Qv7DlEKo-bw4l>dY1&!8FC$ zy{E;Zp!W-))U>}uE?J?2p2I`~0E$cJy0;lNbdx6I{N6u4`YhmK>0;7B2F#qBn3&n} z@1@Pk>6E{zTWoy$yxs~L;Z^d5ybu?cMuXiIP7(CF*zf21f98q)RhS3rsYOJ*rwbH; zeq?LDSDy2|weMb8Wo2avrRFU<-M==F7Ce3+j+e(u-~|Qs4WKm__Bfm;dp_;xxNGnc^xOlbJ;?k~ z*LR~&-cSf)S>vuCurs7Uy0n*ro&RqCdfd?G>O))?Ld&mh!`BLMCZ8l>B9zc!{R095 zG_1c-i||kZLgweE0)KxAzNDoiE1^ZmkNxCco6Pjod($P0%C>X5T5-`r!b0V&XHe}M zC40HqzW&M6rc^j-jZQ33XLmvxwg58E2$*oyi?R8VB+3(pO@&BVnK-G$9lM(4Ep%$^ zCjmA&Sphdef`8KSJnMOX&c}OOnGU#1oJ*WDKG%DvsWNLYa?>!R z3~vh<98=jt5fX``D>21?JX(DSPBK(uD5#W0|M-SMHl)73UV)aVWaDMjrl;u&X^(h_5LSV^ zr2r_pL7wcN+cSrz-~4;;d$Mg{Ai+PcbdvwZe{O_2kX zutJQp38A4na&n-4Qe50_nObOR`Mj)Gn?wM!q!489wA!TqQh+3?U#G?VDmH((Q6Cse z1?~4vQ+~&tIaJHzvW|^1kt;(exCra3s-j0X2245jh(SOsm(bL_+a+-9fJbP`D&%#@ zkKA0Im3sD$Y-)^fe3Ceqx_XECk;TPSSXoMEO-tF*S&3~$X-R8GHAO`d4VXoy^^0)x zrEmJyY^h^)^}Jbp4(scFeN@3f%cVkIC72PNQ&J+p&i=@rq*g8nu_sIG1yZfiks%r^ zi+Rw0L%s7A3S166iIT;8izEes zL_oFs@S%Gbxznmf8#KpAVMo1*BW-MfV0LfS>no}*bAWhd1*6YaOn*ueJI!bW+`mb9 zkqR?}k9cfrYiqj^%2bM$mDR302sQibf7+-6v+NhMQ`^AXoaAV+FZrG+t9T_;ng+wFD8Z~AF#0vxa zH+LI$E}A=u%xMRBEX4T4a^Iu$s63UFb9K)M2R?R zkLLFV0?p?3C~wXPii*Wv#br23QRetbz^@#!7=(L;_}Eyrj~zw_UN^sa-v(j54tN8X z`w3*|v9SDXZ585S$&}#7iwA2{_K9Uq)PS7a@IWVQK$ncHoVk-bsd7>9lQ)2(CZ;xT zXSQfn=m-Mec|=HY5q(rx7#(u;q79(^R2w^(nSpKqV+vn)%C!q^MrKl)x~gH^P;<(QgTiLJCIx(H?6K2#?MLBR#z8}70Pw?TH=$bDfxQvZ`BZ6M1!MC=1%+%D?PZ~EP>+0$jpzQuF z(t213uywjedwYA^2pdGG)!OM98KbncgrHsD!~jKM!gkKMG^V@L{WqJk9c|?od8$CTeBetx|MCP>w zNE?p)lamwO8`l8@eC&e=I{jJ*g(ThTXC0evE@^+688h@7tALUCpN5gK0` zU}gkD#gXCR?wcQA3or)@cKdCHj0yg)eWu@;%1~pqI67@yz&}5UKG)XY zxmn9i0S=AlCsivqcz=CjPv$7{HNfA7Wj<=tJPt2P%K$ylCo3y?dt^dzpDsvT{SqtiBkghW|F$YdB!V zWoLJ|JKruYF2)mp>oQUy$N0zSEGx4y7orBOO~-^reU?CpPxZQXtKU)9uRID13c{sL zZh8NG#+f}?R2+{WYi#W9-XtcYAPIgYf_M8j@-=4fj*h}Yi?w!8X0`2;^xcgdH2pn0 z%dAray4~jm*TnydG{jlFRL#0^5qfO+I2?08zk^Qiq zCI%7Zw zs|}NYAmq)zCe~+Aas~M}4c1a{RoWnyi2#21p9*2193HGv|GCBxL|EuV3@x%4M7RJI zLa|7yAn0NWON*Rw%5%0Gk5Da_I zzw!Veq3bI4NwH)t&|(l1Mb@VWb{RzznW~u69zu&ftF#9ChIiab-b#pht)Gl2&-AX5WWqK`VRILrl^^`GtO9{UJxwr_#|GhZ&v{A56V@W#T$i*?bd!@mc0Mf-%6hPku z5o*a48*YdqsC^{#S9E9f)|s@D3;oDmEAvoj6Gfc4fvMkpBAJcv1;JZ1wK9!eEd@nARYMk!B2SN_0-hV(KOy} zX~2DGXEw3}HOl1C9r+$^BfL)IYS2aklL-QBmDi{wTN0v`D>%$HEPdff0gA*?b5M1T z_TC>7gDZ0S^eIRC?elZ#e|8yeiSbLLlVFf3>2KDXUq zJnS+3J@vp#pr@y|PC1O}_gbQGx=G&_I6%N`3c}1y*m==T`GD{QIK<};$~6!2b2%qQ z041rctc<7}2Rs7gzON_>Ymi=~!P`s0HI#^NPW0^M>=`VcQczF;{_NPi?Al}q7=h@t zKmsi+9`D<3GO!zq7Jaj&#|gNHLPA1z*a2Ne2!RsS+Qe8`-KHGf#k`D0d^&G&;|5JZ z8l%fYs|ZfA8dv~YTZ#WalCCi>(*JA6&9<>^#%3E^ZMN;&Y)!Vg+1PBmHe<7G+j{Qb z|Cx95V(K$g(5fioYd!t%iU`t^c3=IwrhJbX4HVqXHp00Pj z9?sI>Mz7rYl8}Lw+RUI=y?E}LxC$H$Rd511b|xlbe^e$tRG|d{S3+-ZK8>H8zFcM; z2Fd>NKrQIPll9lA^*bA+EFBF^cuWj3@a2Gixo%5BL{v0;4B=0j83go%Uy>Su2LtSG z0IjCdCE8S0`0@dyjH*#%CU^mxGHx~6-`~HtS6GP#xIS8?f!(ZC$$=kTv+c}t*l`~o z9SzM(LrZ%?=|ce3gy*hShN@TXCP&9b7{dkiTb0ui@;|m7*fcW-$KbxBFxY=bOY4GP zddi3iz;oc#S2!pUe76L2Fs(TMojB`U_FFyE1wTf@2f=`jbrZRUdd|uP1nSmdbmJmy zZEdB;rUR-wTn=t-ZUd_IGmL6Kr}@Q27?Al7Jp=6`C0Tj-Kv`8lln4mSOP+z}Iynwy zO8)h0zZBr!Xx%dd_H_^ci)ojjS8&wif19V7D!0e)3I*y}?dkmkxL|;Xy|AzVV29rs z4s`piT`_l!fNv7>02l-Uww(tVBtBQ$;k!fkJnL>I<-n=o4~X4e&%9qJnm4=-*C7FB znVgLGZfmK^NidQ>rbXR|34q-4x?dUm_z^m9g>)(Uzdb}5^#4SwY8nF%d7wa!iAkX3 zdaEoi?{{$i-*X1GvSTYDq2d*-|H+9}CJtmP`PKoz@RpU81ypT&Klt-eabm&%WGSjf1M@(_HSPRn^7E(iY;E=hF=J{*db;PY&?O*Nqx)r)&aV4; zp^M)uhLgRwRgO>)5VvA3N&K<2OvOC}_(5#=6Pc>?h6F2B^HzDBXRf>^xziRvBo9!0 zWI_Uq0XGgsq9DH4pl12Zenvc3NhsTU^OL-b+aQLN69!P3QDf?rk7RUpPo;kBFny3S zFhs9Ku37l)AKC*R!8mIuo6`*bq^WQ%gff&ECWJ4z&A9*7kDhnb$3G#Tp3xnPfCB^A zZpcVT@x{<_0MeY9$ww6ep+(QhsZJiRL*L}MceM?AT?7GLXK#-OElLycgMc1xafm@T zBla_@tbBZLAb=Sp-;@c}ZV}^IG;IOcRQ=n0Ti*owTKk!2pdsDZ&;t}nQ$M+AD9D8M zfe_Pp)?=YGZvha{;s7T(H7goaYvxp{Q0@~18vYM3F$1n+FYw7RQw8#6m8fx2PTpMa z>hr^&xJj**z=XW;?29~L*)3bC7TA49w2ltznC4f2PDG@uqSD~~d;?tEI^(*+wmd*J zql3hlYHeU(@OZIa|08Da-~fG&#q0oZg8A35F6rog}^ z+k0SmaVBnu%%7%su|G?4>;2c}{Jsq3B05e0ehnK znK}eG1;B_1bdY8a@cs_7I3QYEdw`Fy`(?iXA$&taL*|9xYrwc5*r*nlV;B?tyA30D z()6hdK&kA0_kK0EC+U;tit9D_u1U|iEjJ4s-So-HgBX?q46GJ$oQ^awejT zl)Lb+ODp`t{vG~&++zl{r%xIGcks2g9tfwkKEOM^f_=FFl2<-^VMzQ8Vbo|e9G%7m zpj!3(0>P^yH!lSp_lD`JI{+KO8=~M?U?piP03+03Be{Ru((H>zSCZ;KN``j*e{p^1 z;d$A3y?8Cb(SPej0RfZ<32brmf2K;uX|pAwKfJvVJ>QQPW?9kjup?D9Rqx;Aecz@& zUqGOjTb$9+G09ppj6K62o%^iMmsE_gy#b zWV(zQMdVGiu@!Z1*c$FiHMWC(=`Lot*5osgR-bmdG>RV26~K1q2bQ%gxb{_f zb=OL*G0an^lMOFat1uW0+{F|S)Z_SFoeOaHTulIG2RTzX9}X#WKB=V1qfruHTV)HT z)hcwJ`RMJfrXK}$vkI-wsg?i-lyY$lk-}69SmaC)`sE~PQPthI4-4AqJ5Fp{;1zf_bLV{C&N+n zX7SPe%X+&TkWG;mhKL)}D@2NWwJZ4Mmh}le<>P**g{rA;FXXmse!I`U%Uz&FV(7?N=U|cPp z9vg=aZ#|jZ+x2^?|Jm)w{hI|jE9+Mx*hBDNWbXk zC4yMRl$p+SCd*;rCLiNzk?Z7HyNx<$XJ&-8>anA%A zF{&TVWYwXkhNM(8H$OZJ_stt|=6MIgq~v8~zbOOThre|^H)?#Z`;v8x3EMuHe)N%b z6Ctxm_PDVnpj&Sc<2w_;5L1yujvhIF`#oME6YobPhqUdoJQVW9`N1H7f}3AfL{NSM z=9k&~)|RB-HVBPWkP z{vZ^G&k!RkBO@Iax$mUOhHvVfdoK_2+}tx2c<*B>b3}*q1w9ShNolYe*dMm}3FGap zT-^Y%(f145tP8*XUCQg%wLPSfAneTAGcxY+h~!BidN+1HqkPJx?aTTTfPnF@A0ki? z!BMSOq2}Ozfe;XUgY&yWMzEKLs9CrAXK!PWxKlLZDnCZ^_} z+p(V+`U~ep}B>ffD!bp7Z?G;&4OA`5?>9u8K)Ew6Om5EuwbAZSzCY5+P}U ze#84w3oS<+-jz$c=k!dV@iUPLe|h>?n`A>PF5CX1Clc-MF-zcSB}dRyx{k!-ZMu%n zK8%sqLInJjJvRv{n`?TFmx3KoOr{b{4jI&u7l`3`6an+tQK`s`qhG$y`))D<2Tq{d z=0=YZb-W}8ULx1f#K}GQ;59p2BF9Sq3?y}Zkt%R|s;p$4da?k=fqQFbY_WSZMa|5hny_4Y;G-*FI4#00&vvqqK zq6g@H7oaO};3$LbWX@OmqZVmuWhGrQ8!zr(>aY9wtCNYBipqJQ8OXod1bw~(v+0kx zeWc{aXK(xbk#M+V>HS;uS@@pglz_%A_vv8SuW9Oc!rUfltU_6Nid!MU|DhV*7^E(x zd7y^N4-yY-+Du1ZMNUB#cm`UIQJgC)tQ(sBhB1;%ZhVshMRty&+0_c3<+-2*lZ^Oe zd-R+|R&KUp&U9)*fzXN8Os5cJ7%3Th@nk4!;m;?7#RvH&A|6*OZ!NrDbd$pati6Ll z*rgMi=WEgS*ler%wzs`kR(R&_Nvv!GMoj#9uQrFvvF_LNoXzD+6#1pVwYC2MKh(Vt z0G09V5GWa|WdF_kQfTMxcy-+U+`ls9+xc+~TD}|a=6|34{0IeDB{>^!H?vImC`6}R z>&$EGOa!>l<;G>^79EvdXGl~8=>Fq}mnz2=`T!l0{|uFF9?nRiZQ6CJ5c;tVZdYtL(qvo>hrhTo=rkM! z>%ZyX(oj5x$e2JZAwxkphmM?$BsR(^zOvq`e?Flo+@`@QKAB(|Wjgj; z>UX$bPl2uxx`c!-vXE2;E!adL$(*=opx!6~S?XsEdB1%N{+s>^jtO8zMPS$5>H)s$ zsE4gHPKu@EQW8eaNt>sHISR{!8}&x9JNIovp@aN-g2~dXv`Ms;@wkG75JBauhPcY7 z5Gs0FWfV67{uHF&rTV%L{b4hDF6>4ngu-D@Fzbp7U|tKAeaj4{%NT$%RUZO$J=^WQ zeJBVz$(!(?F^U~J&$u+Eu6z4Aze~TpS%1}(_V;{p;r&N|RA^~HinyqoG5Ft20L z(Q*}fe%>n5=+Vy07OcGc7Oni7W^c2WABzO`N81pn^J=5pYv-^V^jul3#T%N8U(kQt z^)Ev_jsW|=2m=kS!{RJXYDho0@@WebmgP*eZ(wJ@bT`!ZmgG}WS^Fy{Jb+(B%LR6m z$CtJg?ge+hlz-lq>jS|T_7~;Fw!HzHoo_QC(QW_+{asUrCu`8)`&I|QyO9S7*ml67 zsC{KC>n3Xap<)_b;pcyCqn~u1OOlbn`5- zBUkQm%0OvuhG6`tC^(?oc@1m zdz%hH+4mn#Puq0WTvY{_$waJ0>bS{e@ystje=hT)^iY@G`O7cs)g5PSV4$Om9CgBl z1t8U)YEFwzd2SqMeN1_pKgvyAEF*9TVa9F1F+j~oUXD<Finwf;*+AuA`bqq$Z|m6K%D$MODE+3X zsi_c(*g5$um-=JTl7{-vv(^5?r}xe2p`h)0TYG7%>gBbfj%E*mG`ddp=0&v2)Nu+# z#{x%2K}Uzf>_);wdqhmqd@G+oo*xvJA~|-p1obftP|paBnBbyja1F+i#G2Vk+z}@1 z$xxAMwdhn;QDO7-_xH=MmP7X~*>J#;rjE&H=}r&=(8(GyGB(~K-mY?!XWaFdK=`J0Rp&!PHCUL8T6tqvS9Z=9 zuB}xhIS5YNCu~B9{>f6sg1Y+pZwVM?y<()O{iy#Ng9nLhYtllY(#hDm>Q+9k$kbL0 zA&}jpbNv*jS~oK{`v(8lM7{{Q6f%oL*~KF`>l?-_i_yGcoJRmfy6~~JN%@uX02GZ_aT5>X%(K$H9JZUbxeHXQ4%!+r@`-Ag#cw{0T6%7p? zEyJ2t(XZqt=(XMXruV;)LJ`K`G!;~W>WW(o&fD(2PzdSi>G>QRBuJITgM!24GG%@b z{bIe?tnaL7;-_bJ+9+~THtuuZ$MGXtN~Q@2P?aVN*XGeeCZvC;+1YCdDo%Lsersa{ zZGsO=d6@Fkr3^`wzZdoO+@}!p)L72G{Ni=J5VS{S@haU?@UbqTC8wp=2%o)32;}wE zX>BX4>pi}H)+wGz_zJ)>Ox1RZe=rI}_lH@WroU%Q2n!33&fdFpf+@)30ovl0rSFKw zO*|2!1OlIyr+lc-DR~-`+1y%b?(0!%7LCQVz*CEPY%0-QbjY2@i=p_$}msP;JxWU)V8*^Ja4y* zv-912B+ln1>!<%oX(CJT^BOQjm|xWYTpVK(yxI!f zrIO*Q934a)GdL&4H(79%dE!lYRT#OEu6kEwe7ILkRziU2o-%(fp zoYgqQlKh%)^i@P94r#@jX{G*Sb$^qn$}*X{A5gA-?N3^mT$t#7@96=BN=@&F|P7CN+1xZONA}CUH8)CFK5IYAGH5 z9=oogRC61eqsHmXl{;yUrzM2SmP#493z0`cNTo}R@1sf5L0W(a;V-1am)qLFt+V== z-1V?s29y19wal5yq~BSdCq8ssNNYc~IkKl$`+I+&XBc{#ex@&1@y z7;Re+!i6#tB1Z-0gib)f3_d32z^ZA7-ep=_BG{JvtTTWy8w26Y+ea+>h#I8L5(Tw+ zdA)!*vqKKNZ|U51$$!ir0*Ic^;P-Nskr}_Ps)mJvaQF~nm#)_cOdGMwLvB(;Ch;os z0q4`IaQ2O?uE}C)mov^c(5P4+JhLl8ZdB%hae66xe(S-?J9!!q1K+inZb&1tSIf-v ziB_E#5W!6S9mmeXz@lTxex%J#Bw$;|(D!RrbNz+D0rbZ;7zM`qiW;SKLQi9fQ zv_l(6ECU>5I}WSb8FP5q#R5o{yW4B(!p&lI5n?>nU14PRYL zXHu3yudA6`y=b|OS^c!%-Jz49404sN2*h>Hr6dF>LX5=Ff7$IoO$V)<-Cm#N_pdQ3 zc=WCZJ4;4#f9w;7|Jr%+%392D4fK4YrnY!{x(0Y+kr5H( zumBLbY|~V)t&JN344IISkN^_@17sH@O7$YbvyH872pzn87DUW0Fsj;j?ENOH2;iC` ziUUJK2*Em3Tu>0qNk?aAHa0eV=2(kVeqvjuOO5l{skd2EHcoHWskgV}%^M?`CIL(9 zJ6^7#w`u1wAR8xd$ANV#{@yc@9Sz}(%OZXquq6SmJ*$g*J}OjgT9k{bMs$LB?|dr5iya`(J`csR3^8Rm=fYShq_7wAtzI9g$5dn?Mmj@!31$yc417!NCunJ zOA8v8=orIn!73VT~-Hka3j~uhIwS$CrdP}=3;adZ2R?Tygu}Q5RrlK9i`CV zp5-8P)w-M~+(xAc`~YD@mQhmFL&z93h$u{hfe{Q$;GIx8XZQ8>6bk?Cb+@2i7N{kG z;187SD_+-?KHt_G6tbt4lhXKF0DLtb#)9yFS_g)lysl>&-M&7Wns^;g;F}-dCjNb{C$s$*`gHu_ zL@K`Jmjxn^sef%bv1-))KJ?A=_1AZG)Xy1areRh}7%{+HYKlt(^ zT~r44#Ezy#>`hK^O?ZG58qXA!zgXSnBo1tdI#yd-aYLhKb7>~17V>~q82&Qw^ca^z%B>yx-)4F@YA*P^&hs{QSdNWSxJ9%i$`ZsrIw z6g3PyM1>Zxpv_>joDmU(58la~p@yniwn6g!GN(YfVW6U~4+vD`c^_qQ;>-yF9obD) z+X?Lj!DSd4ZSskXXfrD8fB_6GOu@AK-O)7QarGaV-qh5TV>7A{*+Tu08&bwGgQ*hz zu|doOQ(rl~XoldN0-ghjukTUs&aR@Xsq& z7uGf;5Q-EXM)Kg{=I)Y)wy!)K0OQU@#?PBuCjaU#z=f2l>%~>v@>k-};e9L7UF{d} zzC&GBNi8IFJ1D;bhfE9&8w6R;}$t%JmqCPRAa z^TMM?JooE%Fm^7BT63R4zOjs}7D%T48dYF-Vv)4_0=zk?sE&|`-N z2mk&1S6s|KhY%8G640Xj`t>V!uK}>4cWpe5j2hatw6G}s^9QiB=2vV6_&R{71(+&I z7l|MM`VCQn()|`qUCjlf+N9*haX{u&! z2z#qW9BK>!k^T6EG0}$5IzoCt?_YPWf=LG*OoZ^e4}xMoEJuYrl*Vgz%fC_ z&5mO4+v+#9PGe(j&G;f+x&*p!wBK;5P_|yq?-iANP~;4nCSxKdy74kjzbdmzjJG0E zg&LGNWE!}ztCnZ$G@TZ3THQ7Wf1?VaDteIFJcislK#BfVn>XXs)(Kp&pYF!bhnv6V z@G1mQJ-(dO`#^lS#T75F@Jytvix&#Tr4TYCKJ=3JT1eT5fxxEF8%zNkCtCO=l&5U0 z>P74CxH6Q9LZqnSVkL~f*QA08fn25NG0lDJI6-X%Uh-q6qxhr1tkAot4# zQ>7Y0Wvp!tV1Ti*vf?d3qF;Z_u^$lu{{##~fz95a3J%44`K?w{Mr`K`j#KX6MbltK z*y|4J-soi`-)F0*>*=Lpp6Yd+IMW~~j&E`;-(@lB$%G_>WGQ;o)ID@Rz_uuuy}cU@ zq*Ayf`^3R=5H!$NQIOT8mwC%iPk3zlg2LhweA+9cybn5l&g_!sN@GO~AFQ+$r*Pzn zpb_mYXM$Al5?S09fQ*9i8v044{b2D*9%IjqC_#Sv$ES{I<924>x0a|R4QdFE8j1%+ z5xf*$N+w!DOmex0;3zF5q*XT(GpM?=wT^~?B@864T*80RjiwVkiLt1ZMa4h9m5h&cFD~$H=BUEc~v>%(F`JFA-K()w`O-5hxMOS16Xvz{*Z?n6~n2 zIT!7r7G;g5uROY1yVKA{0;R0EoP>yEO+Hm%(G&zw z7rej$9xu(SQ+0^5Qdjc1L%YECe>>}rgJ9$D0Ff#Pf~n$(E5=O$ybpJrCpg=u+b<%S zcyR>-Mh-wyBDL{*x)hXA*tTo?P20J+sEB zLN%4es!c>;f?EY=@7gps$GPksHB>QsbUuQ|U0)oFv-`IAy{-04GEnW(NZfcRT0H_9 z+!_{U(7CdGk8_leTjyf1=0B9`i3=PPG1aMz548Gm^Cg_z$a$lsaOwMo#w@f{0!Ls~ zQ`NFny>bvWP})!)P_FIIK6>a#1a6$%&|IZxoA4Al?*O?DL958xSk6!ed6=u zaH07_s`ZSyKqAt_=Qiw-tbdRivQl7USs{?w=5BcHF6|a`dzy+L>m7zK7Fam571N-0 zt08NWiV9nc-Ero?=Ayl}@$W^+(Lb!yomkNOL<6BZpTuILSi*b*su_h)w#8$J`-UKb zk@jdK|L$D7Ly+GvzvaDvxgQA2I=1Slm8+J_0%K4>L(=Cl^+*pKK30wR3=Kg-nE;+0 zC=KM|dM}%%%KrXN_D|U8J1RatJojb#jyfr-Pyx5mK-lqY$>G7l=zhs%yn~?x-cq56 zw@M-D*Gw5CjBzP61|m9e^F@HDQNDN8s?+%3pNI%MUCdkC^)I3u08n8jLpl7qp>3P z(U~`ui%U!UwjJRTy@Ny7ydDOU!G^H`#r0`N0k>Se2DxAm6q^BF%EwzGQnium4L1Cl47j8KX2g zDIc=+c30La3UQHxu=i+~u>@Xm?Tv-1Pkm}}Vx6PtK*jnt1r&M95AP~e9B`8)JPp4# zSeG298buu)M7L0qc;r-j*8-Uzvf};z{Ky5qbA8rKK073qvrMuXzHmn;wXMd#hR@6?(nbQ*o?yQ-A3veWoHLd4Jpe98rRQMY6a8Mpqm zvUqqFjB6HTCRVlR+|?KVD~O{a<{{2Lq(}4LC3BEcP}pga`g+JPp5FeQixO{0z97`? zn=jux>%ZGLupqoMiL{fDCbw>#*U3X`mn>{l!%4M+K#0#QYnuh^l)1kWp?}Nz2l5f- z;Nlg(Ow0G|Lg9zaTS1@y*F^F|_63GPSP_VYE@a9FJCpYN3kGp-vI&y_T^{svEUxqV1!~wPaET(&hO5v9bHdWXy9a(j$lYC> zn-hwLS`Im2p`tRs*&6}!VgL)1Ab2u+j}m)eMS}VMr4S&G%EV{FC;5sPzqQW5!Uq*& zE&*Ho3@J9+>L5=gyyLD#&HYV}%^v|QdHy;AxlgY}G2|~4xo}BwYq9Wd0bcswAF>_J zH>FUeR@49xDqqjG?;#_-MnBaTXywZYSuh< zOpeFn1`liqXk=N?025+EHe7-eYuZt|^bBpQve8@M@6}%_L~h{cq-3#@&zt|+1mzCP zCIZ(Z?=-ctcSDtUMMN5lONWS96Q{~zvlXI5!GtZVa|4k;i}~1v2ntdqJX%g>d`6;N z9imSCVg*BW1^Y6+r%y?1eP?NfG(J877P;UzP}m0qp6M6ttgNgIPP>puXj5a<>5UEK zdv=vmQcqx}Q;I)jlr;Su4LIPFS@t!c)gPdVz|o*anB4q(5>ukK67c@FTQe;b@NnVcv7fgH zm=Y-|G!^0u-#oQw1T-e>y6b8!wGLIka{1{j#B-m+;Grvw^x&!gS^{}hF+W@v;<~G~ z`Ub46mHSQ7t$pgCJ?prr2oxrSSl${VHMt0>-;Cc*fXJi%{hwcC1NTZByMG}b)$uVY zl&Il)NAS5)odWh9FB(K&bSCpkC*z!28i|Kn$MC4h!5n=L$O8so8WoER>yDqByYxS9 z<=V&kHBCak*wex0i`vmvS_BP!{rXLfwlxk<;=i2&5^98rO_{d}KFRkwpB8Bmr2l4_p36LrE=g4#(?xY?Tcnn_?ZZ?U<&jhPP0*xgnX|N;v;!1 zA?mEcR#WuO@`$^?4M=&gG95iSlW`!IShn$pI$%cL-GrlWLGN{~$|DBzdm3D?x0M{z= z1)^!gf*H>lDSARcCtZMric-ZF8;@YR`L0rNP_R}}5X@*ao8N1pS|5cRfouCQFO*nX zMrJP-`5WN6s8+4V4+Nz+EhIoj0I6YzhlipQ&{!~Y>-M1uW|KKBcfv(H+Z6NocP9xh zbj@Awb$MN$E9)qX?mL=@D6qjnV6$o|m7X{9SLG4mUR#IjM&hU-Sg=}*Y=-(`VQtN6 zZ};w5D6DS?s#Y2zjxut#Ye#Sj;*RnpF`Bu)YwZ)3XVj}L655*h$;>RTH`ay1Wb_k3 z15`yaX;QG_RP4z1CoewFvY|Ic!oA0=2O*TMIpHbHZ0u?_dZO@9e`d{o6q`CMB!N}* zmsT%zOqz0hlg~zDwW8nVbl;*dn`YxAp|N0KbY894f|S*ian)Np>Ij>YK}1;7ge|XR z{JdWt%0PIG?S43*(|c;JYU?ok)bsISANxn%N-p3=Pf-r5ZoJg0!%~ktMdE+wLFRX8Y4mDwu9OuyzuoZC~j%_=&{|vrAinvmWYu~=VIsL0PlyUT303>mf2s)x!Lqd zYeu^E6Kiz8-U%h~@p*#Hcb!75}X^Wr|x-FMH=WvMu=G<@?u6h=! zfJ9J8yU5K#8Y6-1qOZ`wg|06tTCD~mx{8X*+?EmzT9`lB9AdlMB{DKHF{ka?mR7w? z$*iiXs<@aK0MkBtvjEC>aP~YIO65N>2zJ2jr_mYiYY_N;TAsYkvLkbl+yzZLpo+C2 zdOjy;Hk_1?K`+b?&baxDlbwU@M3y5{B?>>aJc00Y7=|abR#aJY_2`y4KK%GNoOv(Y z+^?@bgNFeBgf|Mic@-hKT?2wgOw!p|N5!PnFL7C;WI{ntEt;-RX=k4D*H2^X_OV@z z@conDEdzx?2CYvOTC9^ZdXXdlR?3dq(tJKtODR}9G+MMOV-S(l;-D@XL|(9nWzysM zS@Dd>SHgsnHk*kQs5NFXC;H8-IthS~_PPoDZ#*gN1CEYpaEo#i?BE-t!#%D`j zmElvvdj3+CtsF5Lk~FjsbC%Y!`i;(VERQuk)pKrbV-p3WZ2*)a_j_cMq+BKH zD!h%M5V2!n|DxM}u>YW-L4ivS4k?mvH(9%H{!wV!cdOnl2-?T^Ns}6R7X@qpkM;<-RgCa)Om zFdXL^_xr|=U>4}=&s{Fn+F!SAI3IlqU6yYAT+a`tg$hC2G85UqUb~)33yRllkNgsM zd|mL%POkPEG#>@C1&q5sX#{MOEV(;CcUX(2<6X{+pBqh9tC^ILVrbHZEUFe~M-KHQ zPm!Kt$b;FfsZcT8JgW%m^nz_4=EgrE_pgPVQTLMLr?({@K4l^!1P_{k^0+gtG9~6v4c)~Bq>}qefTUM2{*iy8 zzNua7p&sAAyRYX*2G0iyXWss4*Ll{J!Gwgs-5m=#OdWWnE_4OhrU3(!H)jU$O)_D3 zcXunRG8I~+7BpC~SiBAiJPXD=tw27Eb&7l^7L{lgUHh`rv~qVY&*H!>lpn$=#cxEk z?eu5sE`|a;mv9N()HP5eX}j`>E|jFisSuD#T>Vl3Izukqa?@DLpP!bF!_Y?u2wwK5 zmKPW#l$fw&Xs|th??E?b5JrBP@~_l!+0YOsb={>s??>7N;~pJ*Vf9-e#Sj6Veexby zGV>TR@!y@w`dy}-vnF5@-gAB5$d_x2V=~4wX*WAu7l%IojBVQ5*oI9KvhQ{cnK8UN zEp&+6ul>_-GkB)r6?9+U-D)eM?ju*Cnu;Od7skg#WKW}~U~@Q2YWP#HTrv?(%$<%I zF27hLf}6;XPpcByB&+N?@QdAfUP&*s(e+U+cH*Q-Yn4tqi_%B^UHo~i?Zhz>VE>BM z)qF2CcYv^_=c@V8muDwj+ILy{G1+ajVf@K?S!+IHKKajZ;7NpO+s$Rr6u37z9k_n{J|+RCe!QA8mYIi(Wc;*VJyqelljfF3mcTymvQP zrn(cwgakel==)T?ThL6(rc8lPL@l1*aS^?CztminFv(0HZPOfIx9;YS4m$AA(`Zu1 zw^tlQFj!m28_aNl-5fi$ro%sKu4>c7YV(uhcyBTG(D2E%Pn{opiGQw!e&csNeR$lY z*1HYts?x$HWVvWtHRHcp%p6PoC!jCdyRoAWx@pb)KrkaA0(3ZqEk6h8Fn$gdMOCBnfcV!%o`L|I3k3v`*e6^8ps=8~p^YV`R8gXxcH7k-kX-xW#veDP&I5fr*_LNwe~o;*3Oe0ejQ#5@ z1#@smuQRxos%4O;5*}WlvDke~G=q5PR2?J6?ybw5iWVD#eJAUs zm(4+4DAl^};Ta4`>vrW-wfnFYDamKfTMj)l}W*H3HU)_uV_M z3+=a2(9pVbstVTHvVs%9n@?IKkW8iXsH!XJBo0-~aXC)re0$wJ-F!P6{PfM@cuyFf zYxoz030qM9r_Q2LL4mJkc3B1T`I^fnF6St|;K^3__qH+Q%9AP^?GHjF7Wsged&!A&(U}4Xn>kZAZbd!`BdK31~K#derO0s;Jy0zXR2)X zVeI1Dso<_{i!!}BemefBul9(-noX*x-^$>FTbD-rt3hl3`R#%Hc)bP=T z&ztLnpMp9pr~Rh~-o8VsuU&6FhDWF)CW*GX_}Q6rjl>+H^_Akm6Tz=SUQ?M)%UGZ8 z-{`p!sFJy=;;8!v_1spUPPr#C^Nwz0%V|PUOl1GiYJ3qXi6f|ziWo35jicv+@*a@J zGDqYhs{8kE?oVnD4X;)C%4mdU1cba&5IgZWC&wZhgl&vw>WGPjMPa9{LbAgowXqGi zqXKv#Q%{j$pUK#gLTB+{j@F#fSi> z96O9Dm259Tm|D(SH>eA;wwK}i8Auw#PPjJhZV!xGq%wG{462Y|C*|2*y8K2e_BP&qzpsTA;qu^)uZ^kTOdWrESx;0k zKzwA-#q{d_Q&o57P^rfyNKC{b#Bi9AtYx%MnE1TmPo1YnK_c)vpYT{*qgD$tUgJlS z%Mm!uwp*C;I@9zAk?T><9DIf0LtZVVOOfd^{-c2xVCZNoq+xP4pnVxNi3e#o%JF{U zzy;F|7ijoJjWr9Q~ zBgO1W7oksFMhcO>5Lc=5n%&mbX!5^zE%Q~Zk3}26XA=1fWx@jZx+mgT1SY(=iAo#~ z&|Kspsz+i;2T*h}E*1j@eDBxo#G&EJ(yhXb*v8h0t47#kCdaIYkyu0K5TQi=3cq=L z)GWNw$wس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%0DY0^mU%clPUS%y? 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

g*#|TO|2COZQ}3mdoNanQ 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

J7{GK^izu_n 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¥Br&n`R;Z;fYM@^$5C`pPIsD_}$y(oA2FL=n^ z09}I~E-f50v%EUHI(u|`pD;nnISFLy?~KV5`J(+P76qE%#LDR{P|e!h++1JJt@l|? zO-(^T!OSdoVqdL*fE2j!c2IVTuCA_5dVqAXu^v7?enlfwRi&|Gqke@i%5Wz9Wx_Ja z+yz!-+nFt&6%oT%2kQuedBjA}jyx2JOgJ>_|K#$t^q;NQ37j6X5P<|M&e6f)Ys=aN z+HYRZKPae>*==oY)z!`)xRSSck>{K0>af;3jR*FA4Q)s5^P-7)JP9UTFK zb+&=xk+Xkp$ONz(+W=t5W52xP#2XKkclv#AH4F0pLO^=`Kz$zEK*!II`|G1#>ccrO zUA<)RDT)29)Y2!0#O1ASb>mDchc}LKBkjgeblJo#y5;eC5DxUP8cp%^l}J#{Ty`|2 z&wKhIy8(~1k(Fx#dXO8rwJ`CQd7mpoHXLl;89m!#ROQBq>fAm z-niE_!_v8C>zM+vP~@&*S{bcRMkL?_0C(kc+WdSc5{wGZr;-ypJ8P9*bciYK?_rZ- zIziRRDV~EHGB|~wQtLA7J@-0?lopXx=36|u&KDC)FSJt>_pf@u=$>)HyOiPBQ#XOb zW_7i6KN2rFDV@XaZ!O=Q$nzu0Yxl&Z zKO6c)S6^rxPn@!E=7K|L*}|TS_ioTN4ZF)uN4@suuxiiQxt zP)JjqUpS-Qe_cd>tjxO^KZRg12ye8Xdy3i5jh7#5f?s!EF?M%a zSLpkW{>IsA`c_+9bC;&#@ZI^_r)rk3XzMKKYZz}_3%7Wc}v9P^3Z>e!is11V7BB1P&-R2nhM}qJ!G}Me%~5lRE9|JT*SUB8e-T zb(o@15DvH(j^Gwuv;gIPq~TqA`t9bZ-(_L#uFOp}E}R#&f5)&B>Quo62L}U?RS3^s z#5-2s+<9lqiW`r%55xgL%?^-M@BjeR48Z+IQ>%Ysu;F*7htL_f4)t86euRj+FxA*O zlfSvnt?MAD^zlkNqj^ZtQ`LzfeaRnvvqA%tYO5%3S zD(b|qd$Al%wBF*pUEm`%F3ri{G%n`-)qcIkeeS4^LHNaOt-m}JnMALB}Wu-j|BNy8?=jeP>QkW&8{vozVK|(dKUHRCFXk0O#Yu2 z31pBB&JVc*wPeFs+PZO0muKy`=xXl7%7?$Xz?^3KQU~gi~@=*SK)Hgh4T z3Jx~vU6HnX@*{(T!y~eqrPAh8mq*lkRVg1!Z5^#k%E~U`)cdPcs`(|yEhL7{YjB9x zJPPXVJ-bF*&f3oBsTKnu>zhwg8!~7scAl=8RXM-E*FK@4xrlWbojeYFcJp$Ivaz>+STTBQc39(&8OY5e z51Rb&5l_oQx66&gQ^d;jxr$6{6}w2~--1~rZphH!Ag+inSX8vD_gdjMD;XJCw%M); zl~g2dcH}XnFsvF-Z1vwhl6hQv`zmh76^)HOqr`X_^6sw9k2Z8VeGn_c#Tw$6bjA2> zd0pqIG|wcjI&>%C-W{e+a&GvkOjpEwo(sW3Vfqh9&Hf>TMa1iA!m;)3P?oRN#Y8`Z zwbspLVj1iBYs39-xIZ||2AnJce3IoHQFtO`ij`Z0H&TikAYXVj*|fsulCt)?^r-^* zjGq(3mn71O-Z@kKy+$50_8x1^ZnvX{3(QRbyLXL*zM-zA;aQTl4K^=~#?GL-Q{4kd zpl>rdUb&LdX@WjyOma9eBMR2G6{~)=i@UoOKXoYF=-!j#Ps4v#8XQ-e%}W3uNjQ454o5caZ+wc zPILL%FSc*$nyP6v^XPp=Ze}!NXfg5%$DX73TA5F zM^$KX{n3-w>i&(WGUMX}B46X7nN66+4E+X}RAFxboK*R3+p8T23jh&!VBGDwQYK$i z!}`DUg=0OSAlh|8sKMjhaIp;tXkaCHQpdjbt=nkOCjbE;baTO!)u_cEcGSLEo4PLJ zMld0Ky+q-R!R0Gb$FXTkQ^TmF^e>-i^RX8IXB;b4ho-Jc5 zUTU_^RZH=T26YXM7XYhXX?ADm2i*0P(ouhI(g%eZ>bS!zXS_snp!i(3Jo=Rsij0z) z8d_5WuN^j$9Y5-4K0}YeH8d>*Kuw6r*bI@2VCd0>5?CE&xXlqjHH#?Bwh3yc=En*6 z=HZf0s}m^M+hS+1T-n>~2@rFo=ZaJ!o?}pn!Yd>bySRv=a()}BZHgfFdn``fllLc7 zb7~+GaA_Rtnc@6J`HQt|si+X{)TV;AxNKYCrJEt#?KLQDh&p}4gh|MI`S508Zhm&I zK__-PbT_YLaXjWrY$nRWVe?0VPF!_hfZZphu3K;382!9kZ~Wtukzv`9&(_vD5_LUt zef?Cu{o4E!2-#=vG^eF9q*2?qn*kEP6K8Mc);O#iq#jlvk9BOjnFnyv%KL2&fQ@3w z*k4&$vF79SgVHVMCL;rm<*WQE8kj7oQ#+!Moit?#5~w}v?G@KPMOg0coG+fUi5r4k zap|*Q?>xUl`2nK{BrHEkRfcSA$_&2*HbsdS!zL8dNG7rdHIpT`2^mnq#E?}YSu_N> zz^VRG`a5pkQ9n+lAXD;Bm5Mm*jkv=@Mw zLo$EF_vv<*!`?6G{ia*0RdIjsroh3supdbozL!^4Y{o9}tCW_hxU8bIg4X^n9Iq#& zuwT#kc-+;{LXOto_0m`7tTV(!n`@*&0RC{?FVG~cS!Eagg;zr4J}j>z8*6M+cmGX6dWbootLadwTtt zV^@D6=D{S=$*3;>fE&S{;IaKZe-wLj+`v`aG~pwgGZ*3AUmUb8~Ua&jz;Nyx4yRc7$;=Fqyb7475Ysz1qG6LlCaqa+rZC-jiiR2 z!qw?D^!4?J`G$r7%?lY|izIWMI}Ox~OWpYmKp>El*oRjCF}BpPwGC_)%&l)FfCh^) zN*FrV2%`ZOMG=I(d@Fg~ABjavtj12Wg#O>jN&Pl&9+7)X;4!~w<+pR~F~6AEXbuim zCix2uA-~%6BmqypP@FvYhMn^jiX1(gafglOU3Hm+m1C5h_@LPP<-Zug3HFF1znQ4Z zCEY9X^C)Fc8V?b$$LW=4OoxVE^gM{HTs zHP|+SzU&mTJWQkkx-5gvp#v;$x{MD>M9W5VlNIxaaqNE-_wnxE$A3(R%|ADd#8)t8v-H4taQ>x`%uv zo0qrB#bbVv^K5H+1BMNVIhfeGdb{l-mUW}ob0tl1)5~#NC<@VPt;251xu5>p(e%;w zfIO}xTZw=Hk9ZE-Sd;IeMqG$%bfVBo!wH}M&K@LRECsxc+)+GS58b+@qwVDnc?(4c z-Pmco3qBVDTw9-;J$Ks<-9ML;zbNz;%AZZwkZEYB@+_iBQVZv-TqUE1Mr0}0Z|#pY zE7z)SdW$(MBO+o*_^M2RQiLRy4K3}HZ8jLm_snC~5t)$J>G|F{a!~wpRbD%Q))f>` zQ^5iDO1?7l0I}}?S$6t+zMbUv*jf7^)YP#Mp%#juZwh%uhZcvwC>3coLd06p>*_S_ zcda*UsoI6g1LjyBfci5RsA&f2I$~!ZiZ{@&I~<92@^GOR>f(Zv%%b8920UJmHT+>L(k0Lo%Ju%A@8i4n_|t1Zk>70k;mJ^_{Qt&oyUf^-)RU_uqC|# zG4j)Zc-uR_F9HB8+>YxfZu}7Cuga8Zf?ihub59&2_5QIIk->h_M~nYB%itsg6#oAn zdfy&+LAa-O$}RsdiUdDK7NM+_QZG6ReeOuyHtHvCPQ@~4qCJU4PMY4b_HTX83Zom} z48{@~`yz;GL?sXN(Z2Bo%yH zjBy@otC*szO(Re%F#qNimMjfGBCVzOxxc3xaY*5ESN#?%f!=7mDiPm1Th?mQL>B;* zlYX(VEGTAy$DJzs)?HS5-uyIQye-wQ%SeaE$FXmCJ((Lbp3bj1Hh5|Un~TTxPy$2j z^>3a42YF)b2ip)6LEJPuwPMH7I?P$7FtyTLA(EsR&U>83>-&5UFlg1sOF#U(n@4t+ zTRbYqEYtvWkk-aikhpRo!sJmnptPiCOBos!RJ2bS9&S=hVt%2N?_6yI9sZL%dlg;d z49%k+K6AmDMB_=M_@>f+6S)>)*RVJ}iMiP<6j%GvCW2F*;kLYlP6jhQKEZEodZqn( zaa&2~W=HMf4M@D7I}I=-cX4lQn(VWs^AQPr7J6a?uO{uStNUFuO{ElC9;awBpKl@V zqQ(^w`VBW9|GIj&BIA^^=jEH57xY5a-w66khL^C#?F-%ydSrZiEH2FOz0yNj*JI_E zf_d7~PsI--hdb z<%fTkjv?1qf)_GaZb7<+(&q^RWBT(XF)DzVznBiyK)z;(Gbgsuo=my=KgU_sHsi|K z>Fbv*eNnEC1Sd^4>0p3L)onf;%Eqo9R`w2uUDb9`YFA(Vr6{CdW^%ry=#LC%vtmwX z6Qj?b<2SIg)3=k4lq^5`8%>$RgKH9OoFFe5s99bh;O-KstOTTiH-Yu_^~1xm9Y%*} zL=w@@PvGNMHd06Fz2YG!5zG%NOgfW@Lo&#L7PAhYAWL#vI80oLrGe zEQ|iiIr?-5K6kuo%=~h>F|p(KyYG!s@7^J#idPihF)LQ%$~H#^d7w3ZL@4u+pg5x9 zGZa~ELwWYp5KVqw6h0q2-#RILy4(nQPmz3eHCr`yxnzDG8K&s_1qg<0FjO+ zODQOs`t)5|91*g<89;PHqgK#BQ~if-KqKJ|&{Yc*_S9bhWXf`*bMDBlDmIBCjbhqj zyN|akFe3M)s<^y-t;z)A%hi9|KanL5SBaaLVjS-vgV_<+EtG!bM<49XnEYhIf48Fl zMsof9mb(3>llV3#xWV%0Mec79q{fex!GGzLVUcXsmt!8Q?)#_LmP+#mi+z>7Hm2Cj zI$swI8k7h*^cAWO5yCkE#?sfgVWq`WFo+hlLwswDt4(|dfrch_ge%BDK;S)=n<^+S zJ_IBWa%j;(2N!Xa#d&#o)zvCGU|v8Zc6irC!nnm)2WUM5BFU~@;Dm~PVD`yfA=I!# zyZq@?kfR4t73~<0r4P+Q6wg8ielbZPRh<1LE@{U2o8RXs4^F1KKf;lhycVcUUp!Q! zK^c+nI${eK6g*NE0l1>1)vZhS(G%g7{=U=PL+M1L&p#V~v z-Tt11z!)Up!%09cZ(G{=hn{6;1hnmMssVACs)oiKFkA}Uj;eo{rbuIFU~W$J6*~l250WzQB2!S|qr&}1Cgf8Oe@Q9O zJCf}8@L;&(0FK$sw_{`1#Y>P_^gwOlJeCq_AA&66oX4g31~ z{E$Fq4>Et#9{&M=$5iR-#Uvrya6m+Z)ccR38fobRl24Y~77i5~WOpthm+Ofxh@eed zsc-zUg*h#z=!`gjEqPrp`aJIIXqVUZcWt1BTt-ygh|&7TkSl5iRxHvawz*SDXpv&X z2}cd;Z;3z7ZSu-BS{1Q655AkiX}KO47~oj;M5p1E979pj(RqnJqo11)6WV@AC0%(y zfh6_CA&gW4ZP@zr=g*hHbpdeb@pg}f9YILIAiU0r4D3W3adKN*TQut<(Jl`+r$8d$ zVvU8~^zq_x4vTlm??=oKcF^zVr+ZRc?WViEVlXGVT}%Zg(0tsFdl-owz3kwH#-S_y z?9b@f8Tt++Y<4F{G(9%N7Px?oV@oH5y5h*~aJgcczunVTfnt_@kq;9Pu$fDO>D=Z~}|YovIxu zqP)_dI7k7o1fynceZ3q&XMnMF|I*#+1^_D&5aH0h%N-{WO($MvWbb$GiA0mxR&v=mO=Wt;OZ|=Bjn4r5`+>; zG4@V}-$|?Ss6s!8u`MWCR+D<~8bI`g_>hw?g8e9qQ%P z{NL|9Q!wy~>En}GyQ!d5wrf^iJwXdt5486R9Dpg zchmCwO3=h?D9ivfo_ogrJzG^C>}RNwUt58<@hc)Tuuq6{JU@Z_{*QTEk~K>T(2QdGBFiwAS65f(=0dReg0}D8 zKcEOF+s3RVJ13F9QBcoUe-sA4XmXkky*DPgK%d%4-Ju6vpzQA6z%sJDf0hy!jqCdyP^~Z@9~&ca=+2-0zjO~)z>Ei(>2Rb*QfI(P!B)Jt>do1LIS;X8ugX7PJ9uq-UE zvsAc_T#g5U-J-jiA4_qsF5OSN^KKB_gtV7#C2f z<5|E!Z+^r<%FmXLPQ|PmEQ4&8LvsUy*tTrx01%66K?6TGsBXe)=>px}f;Gnk?h2!! z9q8LrA+9t)*X+vIeCo~yOPAA+rPL~NQ^)do(}L(j-E0nB$#>Bo?O5@zq2Fa$Ij1NS z{{LN6li`b*8MWolUPDEYzvMuF0SpawDk0zwrk3#XXa0;q+_~v=?A=f^i+hNg(%v^% zNl%DGxi%yQdcP>R1FDJ2K=Dgm4V?cVi1B+LI)9zcX;&1UOM0qA+>k7+fZW>GS*fs? z3G>e&35pJAAZik~<>ceJ$~wDuqaiu08zsMmoZB@izjm{McV7G`_S-;s*(U{6%t&cdvN+^4Ha@e5lrbn!hjV*HnMtNr70eu@xwk{m zv_ufsg`8LEW&p`796EkG9&U{k#z#;3^2_HipoY$H9T(mGDc9(anlb;vkb+R?1FvYE`y85+gW^*^rL7O<&WcRNG4Sq!POwJ zEEPByQ%~m@2{oFsKO)`YiNgMo^y8DRE{V2wG)c*DPsslM?@oywsRQKsW6GWDLSlu{ z$NGN5A6{CwccZPLcjDH$z+~N}@Eli(UaAx~%!uHo4!L{ECMU~dP)gGC-=O!A@aF`~ zjl~f?-)9IkpB8}Gd*^as26b1Kw$C}^^Nhm*9V4o$M(h0S+{Dbx$lB#O@%`)Vl z2qdJ^Qg*P!NPusFYHUjuugX$(Dw)isXw*v4jz-<^8%92sjBx7SZP*3fUpXbA6{k_p z22VKm(z%l9R7c#P0Pk}(5;GiPmHqxmyU5Tgqhwxn%=<6@S!iFt3{YQXlfCtTkCq~_ z-<4{E-b^J94q6dSv-_ydS1%WrO-5Fq3rBw=Lv2@}ZNMe}-H0P0HvUtcY9WX&lo(!f zGsPeaP`(n&-%hgE-av+|HJ3&>iCrDvt)0u!lxDVRefEvC6e8gAz13rb7C{7yUtpv# z*|k}Kql=58ot?R|`Tb9N>GI0NF1@)>O&PZ+?b$s^((y&;=zv)1@O3{f&0S??+WGU{ ztd_dx;${BpwrUCLoaHy`=<{M-^Q?mmdFtBQ1%G<@(3+te~9!QuP&(ANEoe$ zj{V9gy^@TIiX#iD2q#ZcN!j2hffM1RtV~M7jxC-Z3IVv|{ZE*E?=iNWh?ZOTP1Q2` zqFoPTd7js2pFN*$vAi!9ZHAMX6bn@Bgf@H}|8gS1BY<7t3DtB%{HLizKYT+)b9G6f zp6iFx>kAPjzMf6JH3WX|K&-({V+uL!I2fPxL3IL$RrBjpsK(D7JxI2?lmA zASKMt9mr+t#tT+Ug)}l2&762L$}lymsBZjabhAw*@RU@&ni4VBKaas?*f40hOEv99 zR{#B3Y5Yzmxaa^5VV(1fjIzIQv6xJ1i|$f-dU*WI&EFVw6M?H{6GE?*r3KDK@uk?elsq=f*)W<#vHk8*$-aLQOY%`+=e0O}<^jqIYEQ-*d{lAhOW(R_k_t=I#8&iRQy7;8TE8r5a;IXU(R z)ns|y^#&8Q`4D;4u7%O_T-D6+5!8{P3PH9OJPe%D%}T>?#nf)OwF!RV7>! z%I+l7!BQj&x8zsbuBS47@%+7K=oUw%tA-bd1`J@jT}pgt#~?Bs^An*@T~~NKA0YCv}9#kg^atapw@Y&6KF2%KQ2T zj6hc~7#M|4{RRs_N@vK|Sz^f| za6d?<6yjMo9Oj_^>VD1MJCwr5g{j_Ll>2V%b%}{>;9b7^UXD@uw!0Qc6UH z{R#6p$qw%FNDc<+3hA0;79?mTOa9S#X#J>ydD>pEdb0 z2HmQNG!{yo4GCG^zw|dlSd9o<+n9U_y%{CYN@QxBwRz089_Ei5j#q8?9#$T&2XF;6 z&t|U=7t}N-CEt*In2bM~X?eU!kxU>zzf0R7xg)5fBjBK``O%X5vqmPmC57)GG=FbQ z#^TE%kGm*AmcwDLh?1zu&)F6mLZM|{ZO>DYO)GK1gc>kp4j5T&9fEi{4Z0A+I??dM z+`pHV4FLuE8)ArManRc`HqHl9rfvw=%;US-!DwHRk4welyWUq-Sz11imsO0vyzZ|g z;wbnYPCEKdZh=)cG%PHk(33KkYRPe2X8C^UI)1loqo}wH%(BHyhsqO+V#keWPiZ5Z z0aJTGk;Bg)_0|8x=*_Wg0bChFps+_g!p0GR9fZTIFI?fJBH;IFAEX}@xF1+;G-ky_;I!!a>2p?#LVJ~*GQb-J$QiJ#p+%98^tHMp*=uZkiJW8^we z1%9S^_o7Rd3DBn+8#;~PmyITS(?MqO0pifPxakhNRK0x4ViqdoHymzrtPHxkql);D zeQ1x2Az7B##nvq{JD0%np_+@hX>BESWp!onzAdUym9bQMBqlEIo`$-Hc@d4IR6nlz zTyAn1N{zdPKZxMs)uIppBtQ4wSX@ka82DBYsAVC<3e)U6-(*%tX zsxQn|5j-!x7W3q^aQ0~u5{eXi>{O3B_{%S%N>E245;XhLF*0r-gMdzdzGhG+7pj$* z$ihQ9ZaBtjo@kUJ3g*Dh{((GJ$R*3IVye7FGuSAJeS|dAwz6^G^t#`t<)gj!U?*~E zYF(__$LI|6vSBocl*?!|?bWJV=Js}FC$)E)-1KmS2*+T#5lOE(&0Cn`;mi7L^)RE0ZJR-OjgORYlaspi#rIO% z8KWI!#xQ9YSUv7BUgPuhcYPn!ab27o?@GN)6@T*n8U$NHl$(=VyrdWK%4mf|cKb(G z1d_wPImUk?{x^{#rK$SNgQ2WZY`f!*a|-$2u@e&Eg{gQ+Tn4mh<E{gfy_*iz7I^2^m`s&H?cJx0K=^!g-Qx62uztqk zTHaXObj+tl@qAPyeTgK5-M-DO$zVf0CuD;*Lf7h)8(8qWq6z_=I&OPwB()z=6xRfA8)nGzGv3E zC78ZPBdsHdA=02f;l@HY`5ZOKwj`kA(_xsa)L$oZImppzE3xLLZS<}6H1oaySqekA z*Sdyhu|-y?=YfBIi&0><`kK=sv~e$WpU8D*l`_+F@6<+sbD#uSML;*uG}Q?96J*x> zI_%Zw1;L~IEqsM&mNNvxdV?6>n7z=bGWD94-lHFk%8VZiqh6A|(DnL1fBpb5f;%ta zGgmb1`z$2*B{{N8Mtm2WKbF^r^Dq`H&IhXGyPoY%O3)y~J-ziDMc4Yz`lKQwlU>m` zR8+9)TbfH&?tD1&U!xTJUEVLtHYpK6LMMY=D1s(BG8&s&NHryG#!7u?IrR{Sx90sQ zK0t~vTV3BGW+?1?efMy(m6j#0ms!nCtWB$MwqCi3B2f^C5_-ywE1UlF^R6@$>(;W{ zbjJC%wz52v-9jNMGxyfQ9*RmS>kcAKszoh353je;mTxQm_I6-onXPcBF?^Yt{osI| zc?d19z3%i=o@~}vJsb>skg=j}Ub?qFob^u@f2pgh1M*rEO61_Y^R!68WW@T#qiC^@ zLz#Sypt(|9)0!owOlD4DAo)Q}A)$tbR9 zu$fW{6qhumwT%l91M;z)t#!F4CzWF=36e%3VWSqqHsPPX?Krs!$LM_!^%u#@6;Zh0 zwkkk~n&b|Q^oU~P{-p0&qTL_=E|XVLb#Hr<6XE!r?Q_DxJh)Dd2=5oMAi0XvWVdPh zOkSE`2lnHo7Pse9R?nJZilIaLGm-E54-E?j z;1B|P&0*d0lzGidwe^b}(#5T31!O|g zeHzxtrevYvDC{Lln@mOWxIPpBJo_Q5H2!;ir-(@lHp)mGfjLY0kc>pA8u$om;@-k@ zkHhY$gIS-Gos{vwMi$+__}nY`8+M3AJ#=Vm5;`;XGn)hxtbvlGHKEzf>aUrs1#|BU?v4m0 zr?_oqp#N|r{zD1btHc;WY#~mVm70)}s#{9G2oxkRL`~1foBaZ}2Vv_*HK%4=Rpr`j zR+cTH;<;qK0tfc`*=CX7Kl~U-;rXB!z#h*Ija<;-Cly)uxpq zVgH8Qosm2Gc@2W4$a*ATDs8%13+tPYjwSo~FVurvf`l;{NY<-V^)pb2nwXqaD^)Ri zFT8>m5*F4!Fz`8uv~&CXaIx_kbS?=-6KVaF14G<0ffvLYz-|spL8w;>jQM~r-spU2 zZex?hX}9*~EiaST-9=ey?^obbE^oQ(J9O4*s?DXsMGVrgX@2)aBf$rQR|ATytR_QL z9jh=rtP~W+y|Lu(&Ds*UfBxH+7H06zmZrrwMytOv!tHTIR|4(1OA%)_S8WE`O?~y= zia_O|pzwMdIbB|NZ{p_*ChjKO#Wa8@~ zQ6#|7_$dJr-UCE?T92?C$+j;hG$2P}P-rEEZU|=JXvUk`=~PYtmFzt!DnX6yGv>57M9``67@WCR$2~Hz7PET z3|*FyArUgeeKB+AGiQ+~v z3?zDp`1D3|EyT7dEM!7J+$<{*o+BKh@H0-4bxbjiSnOA_-mjVPgQnhb6D_{&qVx;l zM2e;?j!yD$LJm7sV|bYmw&GA3aX+=L&h8)Ie{33>?0kOI9Q*W$P!tC&a>#H1!M~!3 zAoUuytWo|_tI^r&gaz@XC6`j>e)KWPYmB)!5cf>$BmoGPaxO|DoaYvFA zl1gGY^wOgeUKvsUKlJPT&-7OUD1g1g(Av6;1sTZ=G~;afq5BbI1jjEx?m&97{l_6ep)bEt2R<5l_32eR5su1)>P#dq%>+mx`4SiKG{@Y<& z7HCL~`Y>wLOTQy)Gb9yPSAQ;_zNTK2M%PiO*$-=u9nZtbAdi$*8%na;+~ZXDQ=kw% z7d|R+3%PB=7^nPFg<4t*Da4cWTCL#y3DDgfC*gH4(IFV&-O%8u z|Lh8d7{28yBnEB~3fr`I#MN#0&4f|I!x8~g=1wPYaEcGSd5RVviXv;`gxj~T-FUSX z{QCUxdlie7+Xrxs_qJVA70lQq;lIxQ;e{K7{_;^md}G%4NeHY;rp{9!xbtX1j(DQm4a2cnB2ip#@qn z*@AKN8m1qTKlIhuPpTVjSXw?}7nt~E`L&*bvW1_25{%oUQDzIIL;XWl%74X?(ZZoj zh@(Z+K`~V3O#DtQ=^1STD)oORD_TEV}XYcdKt0i%eYa&q)cOLj*6&X@yCewym zBm!Kv}nTh@@bOrYYh6rHMs~fVUOqhS@|An}5vmGq%mqGwqq@Of2=6&D5 zfP@-Su-*_728}Bc%x*OF@Mw(vcdiGFtdJ-w#!g+xUtNfVkQ)Cr5{UiaCF&WOz0eqd z0{{9&>X8kpmCHGxsi1ILtn;`B9=9(+jDh*PI?s30FmDMz2mK=`qTo7`N%no{68PYG z*N~fwnwANDMb7 zRK=4f1YXs^Sf>g_L?Htqx3n{n1BgN|?TQg{*XAW!=QX@0|K3e|>W-IxhqRvt?kA9| z2cejmoBQEhc^n_eC)qVF0>5gX`6bdp*3=Xj#*sHb@REtV0nUDu2FUX1`3%Y`=&Ubq`E*12bpU1i?{8?`qKY^Pa zX#Mk;X+co_LXa_=A zeq*KG>$_WTqZpFH);Wz{{U@gi0HY&-+0v0%0b|kHg~Oi(Irz=XBo2(nvY8vym)B68 zy?kkbe)L8S4oYa!yN=wHmdoBLwU8 zwWRC#y8qFhIN%SoEZb&00@SU-IXL(KM{U86`s#@vOyL8AX4)$Y#gGYkRTUM{=4{|w zvH`T@g|^cXa|uF>r+uC0Gxz2gYOz0MO2v~e>I>jtDYl>s|i%iT+8miLr6Z?N%B%4gN=aapc+0m#g?QkwL3y z(mY|+8zOiLl!SCYyuSx+Zm+xD0sy?p4&SE)(7KsgD?H7vmA^3mAG`T0*@47FOaQki zI*7?P%aFwaN_0DGp+2y~6H6f?2Mzz*<1J3yVem{5U?ofpndIsl)aWqtMzPZc4jAgk zWU~1AI0ak%!;m$l1%O#~2ZR9paJ_q1z zp$w#&Y{Ya&_B15PWK>!W5;itL>fCciKCgiP$NM)fJ_$UZ3+zvqx32LA!)2@7g4+T1 zePxL~0IIlP1mGfUPy{y8#E*ZS(=4hPJ%05?9y-9LafJ^&VSARKT??KzRM5d63Cy0D zc?{Z=?+Dm${x{~p@q4tQF)md6J9t#1{FfXHH~HT>eR#b{w`-3N3T_Fn!Rp=d36JU^}12H2QC=PMvNseJ z1)9gz$bQqBIc9?{;p`;*m;Vn$alYVrr7PC@@An~id`Xv*S|2t6KEABpoymj>av8uj zz+8#+*a`HRQJd#Kk=~?;Ja-~VuVKucoG!sS0x`26d2oea+}f0e&0tSYy*~f4i)?{I zeHrcNJ0X)PJ$LfcTAcDtSZ;lD>yTD&YZf*eg5v4kdmXi~rhh3lk zBD@48wg2@uOL@G%eo1yg7;-eUCy4X_09s&-gb%VwK9F+*Y?}_iw*h;0c7KI>DVR0G zuX*4elXL}tSJ2Q@>&>H#av?QPDy_mYIT=jZ=|y z$2c33!e2cA80lXQs7w5AJ9(QZX<_I#KV7@%N!pH8$Z6P3t($goI2wC!9GJehFf-$F z+!+DYWROmrh#e97F1)YSjXcSv-V~uChsw#3;2IZ#{I?dj&z)MoBX5(%e8&7^JLo^{s!HQW+pvO#Ox0(ZyD-c6zF<^x&*3u)2 zuS~8Lu(L_{P-87Ithbh|*KwS8&()hJ&FmGVdMbwa3yk#Hm}4xJuPv9MEd9iITO-l* z%}FZ2{SD6@!K;eAWqv=c>BmTKuE={}M14L97MMA_I=U!XNFsq2&s+PRy!h1<1MID*`{SrKw^I=7t98J{FUv7PSuUu38x zuzN@UJJ-L){lP`4{H0O|JbDCN_J23+o#GQ$16a}qNyQ@Z1-yADpmUl~rH@O%tguvh zeqVJpMsJ0FwPwHf5)}uL@8p*p)!Zp*5;INpj>Re0rF|Q-;vEB^@p|Nh@ZxdAUmFCF zyn>I^F2`b6CHRuv@86ppkJGE+39P5`d&XsWG^iR2FJT5WWNJP-8X9=eneDTI6jSBr zXL*vqQeA8cuua}w>;mV~@h_u(dj|(boS5B4_oy3V>gzfd9slue1ivv(7)S`ZvsPc9 zjcIamd`5iJ(^hp7YxBefKl~&?SSJ{;qPgD=pSP#ECahYcK(-$`U+<8Db;msp=2G^H z(vb#22_{fT*Q6rsT)Knj5LM`|IfYP&D&W0Mcvx5(linY|?)Yuk8~YL)2p6J;7F~WL zQnA}N_i9>x>+id*6?W44z;UB<;`Q+haArv$F&sV8=y`;ZiDM@T7HYg4EN+m3Kn%QS zBlMPBH*y^xS?pQ>_{4W0fK3eun1b<@F+_S~JCHB|3J&U@SZ=*L>Pzt?>I_361=we| zx3_5#L}8H(X%c9FU?V$Q7eHLvZKZFH{KzmSw~|_lR_iA2A9*Piu!8mCUn_+4zB0O5 z+d<6vE*fF;sS8iVAA{lCSj`|7?1{n z)kvrlUZP5sDa4L8awK*&oTJo^)fu4L0JSGwEGLJ{l_EH!BRF)53c6x4r?&^bVE@nJ zauCHq7MLjnFrE5pv9qN4!c9l+;^N{Qiafx8!?y$z+;(=`QS5C^Gx$8%XD-_QcT$hv zHT3_|{wkO>M@L6rwGCXLZux@dPQt+f6sEs{#C36C`yRr6@natTJ5B%iP8a`Q%6u`I znfx|OO#o*sDrb{_5{L@%-*<()6?!65MQ?Q@_uguyCkeLS>T+})`wD?0)} zzm|9UZkfVGDVsDlaP91!_%D2?wi^UotSh8kjW~96! zjiFd4OxaK)r`xXmK&p!&(Y&*hBtL=Ecrv9n%v}8T>@7B#WQ9m%jpun*dK~1F;N#@dqB@PjR9wKIGx-$tk=8p$CwMQ%h(Smz8+~uJ}}` z2F&Ic@?A;YKAxlcb#N9xk!Q)c;(&Szn0}_&?Cz8-+Y#0t0yhg|`5xNnBOnScHG6y_ z#1HVd0$omkk_BO45DO==6}yIoMe(G$Q{!Sg&0VKy>d~&#Yh$CjdA&?qhEJH&i(_2G zFLJ>T7AlabNQp^F$1{~3>!(y5Um$p1|3c+-8#JzxE!clT_O*Je7{uf2X3mD2te5>x z7GgjL2h|v)TU3ACapQd-I@=|5KHn!M{pM@(v@l`9c}iG@dgc?L8aF#hYn7KmC6^@M zUfrhf#SkjMn<3g5MK}01a-Lg81~0487!@onlQ~a5N|(ohU2U$Mq(lY#iTAkWa+?*Y z%n!%Grok=~_m{L0h7ccgk~)9^a>+*rqL~00c`}~i)T7fUoOt;TyZIiYHmJPp>};NS z1oC^(P7xZ+5fh+{Ehr3&`MOpfWt8_3i>rfCu)T^y` z#U?fH^ZX!1xt~#0rsny~$6>bl9^g(OVTi%<$Yt`9kzB&GtKia`%kBRfakzDLWqs+6 z@ppHm(dbg)+NCoaD?ft>sSyj*SR?@#98}#w)4qHfj}9#WGQA0;@(~dcNxyOS&i-$K z{KM*7pF%*UTKoXP|Bi@==nnwHx3Jzc-jV zbh3VTKA0{XD$8C`E~~pX`CUu_B<32QW<7i!KIKH1K%B=4`>ttj2mh!l{r1! zG<~C2dpN}9J3l&m`K`B%4`;5;7E^wB$Bib`K+~nuW`{kOEo~DDOLey4$%GjHTMVBL z7d{nr>hbV!agUnlD2HnKoj>70sIB}~4w^wjuBHb zN#C$W?DJXf2D)%uDBS=PuK@Y!CXU2)L`8UYp}~Yb-s%c#ieHxRvzer+EkePe??*Yg zHtBGs#18sDCe7r4w0a@U;{!Krw zd(W1SGJ4VeR+bhPKx$09ph`8%!NwM!m>5Je1)yr*-piSthjcp2#*-1BR9grl7dl2S zE9$T0Of#W9p^G5NM1&{{@UtI96pufW3rS-ZC`)n}S#&fBU9_D$^e!V0`!N z_BY{1a_rw*FWH;qegeyI2f>Rs#)x0o07U7^^#OmmG7MIsXzWD$De&tETMncfK{}zp zvKxh8TSxbsuoGA&y=}W#NVQ57@dT_6JL`h+^~lOFwGU=#Yg& zskeL=@}NxuTph4E6?ZHCZ-FPZWYiSYH^jy3&xd&KAFqA@==8yS-7qND2w0Lv15@+s3f&zZQV9U`~{20eo5y{-gjtS5(>sPevNs4bH)o98eUgX zE~TMdwZ?jj*8LvuMM<%7?0v?=XoNQ`ciDFr*2Y={^zEiy>ZO-CV&KX0cRYRL$a7 zEA^LO5U^N*y{>7eEfmn*M=Y$Y7Mndd@I$>q#555jbHNi0 zq8p?7VD88^SQsPSkdG33T&nX22L~fpLkLx={9f*|va+VF*+bw+kq@|}qFP7){^3LB zG`y8I!$``}Z*lkyuoGR@O z7UBMAImvk+k9w#r(ry=sbNC^-T=52l$27R2&jgEMCQMN6eU9(qeHE?}i<)`rte4y> zCOf-R2iZEg@MUH7#iWzn=dJ3t^8fxx)x?e`CLxDIdRM?+rcrn88uHP?AlyCXy0(U= zI=|k`0c*+eu}RfGF+X5j%-Qp)bMH(vc*#_;>hIFj)~TH>`RI=0>8t^f$HD2U-9zMM zagtAic0zIr+NUpxU&d+PG0L-+`|K=aZ;sJq!Z{bB|CW*G<#@0t~jcZBkZI zIa+Qd>U$%p_>D6|Hn|E|d^JG58(t z!#bMdxMH}$x?>1%ZjVGWNXB+|^DOX#W#?#KMHss&SA=uc?;nwsFvI4{;F^Hb?&JH~ zU;T2-b@01t(7u%u%CA8w7$aEJ$00tvFJ;TMjiY&~d_JoKA*3I|B zHIACyauka7d))+a2-d72grRX&{!!_?-X;Rw8*i6oq9zFvW2G-|obvC(0* ziXTyVtmDw(QQRCL{IEOcR;5TkSG0z{KW$@G{kwZmoylfs0JS41MfLbd#zNr9K2HgS zESHeqc+oE8OPeOWyIUp4rQDu;pI4L9o*K97n^Px>+N`j@K{#_!H2%@MnimEaP0BD16dW0yRrM2+Kq<0GOQGa z7LLs0#*FpHOCFdyK7$hy=wcCsd~SCKP*s#ase5Owt*p!rcbcL3EhQyeuY!I^VbUk_ z8*J4eY;Bw5i>D8-8x;(+Y(>r|N@uaS4Lw-o%iwo@6Vmwf2_TyEhB-{%BY;wK-!ia? zf471-L}G0K-CC7GrX_fdVf(xhj5_EWtAR- z`2?z_T3t#~vR_h4OV-3NQkBi|=r0oU�^V945f>MfzRtVkY2HwMj;Ef(AwIq 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 z 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 literal 0 HcmV?d00001 From 6e69cb6bdbe636dcc8b47d08c9f16a4b0383ae01 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 7 Nov 2019 14:29:00 -0800 Subject: [PATCH 032/616] Removing unnecesary encoding --- samples/15.handling-attachments/bots/attachments_bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/15.handling-attachments/bots/attachments_bot.py b/samples/15.handling-attachments/bots/attachments_bot.py index 9de9195ee..6ee848e0b 100644 --- a/samples/15.handling-attachments/bots/attachments_bot.py +++ b/samples/15.handling-attachments/bots/attachments_bot.py @@ -110,7 +110,7 @@ def _get_inline_attachment(self) -> Attachment: return Attachment( name="architecture-resize.png", - type="image/png", + content_type="image/png", content_url=f"data:image/png;base64,{base64_image}" ) @@ -138,7 +138,7 @@ async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: return Attachment( name="architecture-resize.png", - type="image/png", + content_type="image/png", content_url=attachment_uri ) From 0b17bde543eea8e72c981c6e95e47a10a53a6ff3 Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Fri, 8 Nov 2019 07:35:43 -0600 Subject: [PATCH 033/616] Added 15.handling-attachments --- samples/15.handling-attachments/README.md | 16 ++- .../bots/attachments_bot.py | 114 ++++++++++++++---- 2 files changed, 102 insertions(+), 28 deletions(-) diff --git a/samples/15.handling-attachments/README.md b/samples/15.handling-attachments/README.md index 40e84f525..678b34c11 100644 --- a/samples/15.handling-attachments/README.md +++ b/samples/15.handling-attachments/README.md @@ -1,8 +1,8 @@ -# EchoBot +# Handling Attachments -Bot Framework v4 echo bot sample. +Bot Framework v4 handling attachments 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. +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 @@ -10,7 +10,7 @@ This bot has been created using [Bot Framework](https://dev.botframework.com), i git clone https://github.com/Microsoft/botbuilder-python.git ``` - Activate your desired virtual environment -- Bring up a terminal, navigate to `botbuilder-python\samples\02.echo-bot` folder +- 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` @@ -21,10 +21,18 @@ git clone https://github.com/Microsoft/botbuilder-python.git ### 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) diff --git a/samples/15.handling-attachments/bots/attachments_bot.py b/samples/15.handling-attachments/bots/attachments_bot.py index 6ee848e0b..2f88806c0 100644 --- a/samples/15.handling-attachments/bots/attachments_bot.py +++ b/samples/15.handling-attachments/bots/attachments_bot.py @@ -1,14 +1,32 @@ # 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, \ +from botbuilder.schema import ( + ChannelAccount, + HeroCard, + CardAction, + ActivityTypes, + Attachment, + AttachmentData, + Activity, ActionTypes -import json +) + +""" +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): @@ -24,6 +42,11 @@ async def on_message_activity(self, turn_context: TurnContext): 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 " @@ -31,32 +54,50 @@ async def _send_welcome_message(self, turn_context: TurnContext): 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 + . + :param turn_context: + :return: + """ for attachment in turn_context.activity.attachments: attachment_info = await self._download_attachment_and_write(attachment) - await turn_context.send_activity( - f"Attachment {attachment_info['filename']} has been received to {attachment_info['local_path']}") + 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: - url = attachment.content_url - - local_filename = os.path.join(os.getcwd(), attachment.name) - + """ + Retrieve the attachment via the attachment's contentUrl. + :param attachment: + :return: Dict: keys "filename", "local_path" + """ try: - response = urllib.request.urlopen("http://www.python.org") + 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 = json.load(response.data) - with open(local_filename, "w") as out_file: - out_file.write(data) - - return { - "filename": attachment.name, - "local_path": local_filename - } + data = bytes(json.load(response)["data"]) else: - return None - except: - return None + 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( @@ -79,6 +120,15 @@ async def _handle_outgoing_attachment(self, turn_context: TurnContext): 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=[ @@ -104,9 +154,17 @@ async def _display_options(self, turn_context: TurnContext): 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()) + base64_image = base64.b64encode(in_file.read()).decode() return Attachment( name="architecture-resize.png", @@ -115,6 +173,11 @@ def _get_inline_attachment(self) -> Attachment: ) 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() @@ -125,7 +188,6 @@ async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: AttachmentData( name="architecture-resize.png", original_base64=image_data, - thumbnail_base64=image_data, type="image/png" ) ) @@ -134,7 +196,7 @@ async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: attachment_uri = \ base_uri \ + ("" if base_uri.endswith("/") else "/") \ - + f"v3/attachments/${urllib.parse.urlencode(response.id)}/views/original" + + f"v3/attachments/{response.id}/views/original" return Attachment( name="architecture-resize.png", @@ -143,8 +205,12 @@ async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: ) 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="Resources\architecture-resize.png", + 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" ) From 6c28f1becb4dda10c1adb36f04cc425a8f9dca8c Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Fri, 8 Nov 2019 14:09:12 -0600 Subject: [PATCH 034/616] Added 17.multilingual-bot --- samples/17.multilingual-bot/README.md | 58 +++++ samples/17.multilingual-bot/app.py | 101 ++++++++ samples/17.multilingual-bot/bots/__init__.py | 6 + .../bots/multilingual_bot.py | 106 ++++++++ .../cards/welcomeCard.json | 46 ++++ samples/17.multilingual-bot/config.py | 17 ++ .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ samples/17.multilingual-bot/requirements.txt | 3 + .../translation/__init__.py | 7 + .../translation/microsoft_translator.py | 39 +++ .../translation/translation_middleware.py | 78 ++++++ .../translation/translation_settings.py | 12 + 12 files changed, 715 insertions(+) create mode 100644 samples/17.multilingual-bot/README.md create mode 100644 samples/17.multilingual-bot/app.py create mode 100644 samples/17.multilingual-bot/bots/__init__.py create mode 100644 samples/17.multilingual-bot/bots/multilingual_bot.py create mode 100644 samples/17.multilingual-bot/cards/welcomeCard.json create mode 100644 samples/17.multilingual-bot/config.py create mode 100644 samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/17.multilingual-bot/requirements.txt create mode 100644 samples/17.multilingual-bot/translation/__init__.py create mode 100644 samples/17.multilingual-bot/translation/microsoft_translator.py create mode 100644 samples/17.multilingual-bot/translation/translation_middleware.py create mode 100644 samples/17.multilingual-bot/translation/translation_settings.py diff --git a/samples/17.multilingual-bot/README.md b/samples/17.multilingual-bot/README.md new file mode 100644 index 000000000..41666b6f3 --- /dev/null +++ b/samples/17.multilingual-bot/README.md @@ -0,0 +1,58 @@ +# Multilingual Bot + +Bot Framework v4 multilingual bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to translate incoming and outgoing text using a custom middleware and the [Microsoft Translator Text API](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/). + +## Concepts introduced in this sample + +Translation Middleware: We create a translation middleware that can translate text from bot to user and from user to bot, allowing the creation of multi-lingual bots. + +The middleware is driven by user state. This means that users can specify their language preference, and the middleware automatically will intercept messages back and forth and present them to the user in their preferred language. + +Users can change their language preference anytime, and since this gets written to the user state, the middleware will read this state and instantly modify its behavior to honor the newly selected preferred language. + +The [Microsoft Translator Text API](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/), Microsoft Translator Text API is a cloud-based machine translation service. With this API you can translate text in near real-time from any app or service through a simple REST API call. +The API uses the most modern neural machine translation technology, as well as offering statistical machine translation technology. + +## 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\17.multilingual-bot` folder +- In the terminal, type `pip install -r requirements.txt` + +- To consume the Microsoft Translator Text API, first obtain a key following the instructions in the [Microsoft Translator Text API documentation](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-text-how-to-signup). Paste the key in the `SUBSCRIPTION_KEY` and `SUBSCRIPTION_REGION` settings in the `config.py` file. + +- 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 + + +### Creating a custom middleware + +Translation Middleware: We create a translation middleware than can translate text from bot to user and from user to bot, allowing the creation of multilingual bots. +Users can specify their language preference, which is stored in the user state. The translation middleware translates to and from the user's preferred language. + +### Microsoft Translator Text API + +The [Microsoft Translator Text API](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/), Microsoft Translator Text API is a cloud-based machine translation service. With this API you can translate text in near real-time from any app or service through a simple REST API call. +The API uses the most modern neural machine translation technology, as well as offering statistical machine translation technology. + +# 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) +- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?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) +- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/17.multilingual-bot/app.py b/samples/17.multilingual-bot/app.py new file mode 100644 index 000000000..c968cd633 --- /dev/null +++ b/samples/17.multilingual-bot/app.py @@ -0,0 +1,101 @@ +# 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 ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MemoryStorage, + TurnContext, + UserState, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import MultiLingualBot + +# Create the loop and Flask app +from translation import TranslationMiddleware, MicrosoftTranslator + +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(self, 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 = MethodType(on_error, ADAPTER) + +# Create MemoryStorage and state +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) + +# Create translation middleware and add to adapter +TRANSLATOR = MicrosoftTranslator(app.config["SUBSCRIPTION_KEY"], app.config["SUBSCRIPTION_REGION"]) +TRANSLATION_MIDDLEWARE = TranslationMiddleware(TRANSLATOR, USER_STATE) +ADAPTER.use(TRANSLATION_MIDDLEWARE) + +# Create Bot +BOT = MultiLingualBot(USER_STATE) + + +# 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 diff --git a/samples/17.multilingual-bot/bots/__init__.py b/samples/17.multilingual-bot/bots/__init__.py new file mode 100644 index 000000000..377f4a8ec --- /dev/null +++ b/samples/17.multilingual-bot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .multilingual_bot import MultiLingualBot + +__all__ = ["MultiLingualBot"] diff --git a/samples/17.multilingual-bot/bots/multilingual_bot.py b/samples/17.multilingual-bot/bots/multilingual_bot.py new file mode 100644 index 000000000..4ca973db4 --- /dev/null +++ b/samples/17.multilingual-bot/bots/multilingual_bot.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os + +from botbuilder.core import ActivityHandler, TurnContext, UserState, CardFactory, MessageFactory +from botbuilder.schema import ChannelAccount, Attachment, SuggestedActions, CardAction, ActionTypes + +from translation.translation_settings import TranslationSettings + +""" +This bot demonstrates how to use Microsoft Translator. +More information can be found at: +https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-info-overview" +""" + + +class MultiLingualBot(ActivityHandler): + def __init__(self, user_state: UserState): + if user_state is None: + raise TypeError( + "[MultiLingualBot]: Missing parameter. user_state is required but None was given" + ) + + self.user_state = user_state + + self.language_preference_accessor = self.user_state.create_property("LanguagePreference") + + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + # Greet anyone that was not the target (recipient) of this message. + # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + MessageFactory.attachment(self._create_adaptive_card_attachment()) + ) + await turn_context.send_activity( + "This bot will introduce you to translation middleware. Say \'hi\' to get started." + ) + + async def on_message_activity(self, turn_context: TurnContext): + if self._is_language_change_requested(turn_context.activity.text): + # If the user requested a language change through the suggested actions with values "es" or "en", + # simply change the user's language preference in the user state. + # The translation middleware will catch this setting and translate both ways to the user's + # selected language. + # If Spanish was selected by the user, the reply below will actually be shown in Spanish to the user. + current_language = turn_context.activity.text.lower() + if current_language == TranslationSettings.english_english.value \ + or current_language == TranslationSettings.spanish_english.value: + lang = TranslationSettings.english_english.value + else: + lang = TranslationSettings.english_spanish.value + + await self.language_preference_accessor.set(turn_context, lang) + + await turn_context.send_activity(f"Your current language code is: {lang}") + + # Save the user profile updates into the user state. + await self.user_state.save_changes(turn_context) + else: + # Show the user the possible options for language. If the user chooses a different language + # than the default, then the translation middleware will pick it up from the user state and + # translate messages both ways, i.e. user to bot and bot to user. + reply = MessageFactory.text("Choose your language:") + reply.suggested_actions = SuggestedActions( + actions=[ + CardAction( + title="Español", + type=ActionTypes.post_back, + value=TranslationSettings.english_spanish.value + ), + CardAction( + title="English", + type=ActionTypes.post_back, + value=TranslationSettings.english_english.value + ) + ] + ) + + await turn_context.send_activity(reply) + + def _create_adaptive_card_attachment(self) -> Attachment: + """ + Load attachment from file. + :return: + """ + card_path = os.path.join(os.getcwd(), "cards/welcomeCard.json") + with open(card_path, "rt") as in_file: + card_data = json.load(in_file) + + return CardFactory.adaptive_card(card_data) + + def _is_language_change_requested(self, utterance: str) -> bool: + if not utterance: + return False + + utterance = utterance.lower() + return utterance == TranslationSettings.english_spanish.value \ + or utterance == TranslationSettings.english_english.value \ + or utterance == TranslationSettings.spanish_spanish.value \ + or utterance == TranslationSettings.spanish_english.value + diff --git a/samples/17.multilingual-bot/cards/welcomeCard.json b/samples/17.multilingual-bot/cards/welcomeCard.json new file mode 100644 index 000000000..100aa5287 --- /dev/null +++ b/samples/17.multilingual-bot/cards/welcomeCard.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Image", + "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", + "size": "stretch" + }, + { + "type": "TextBlock", + "spacing": "medium", + "size": "default", + "weight": "bolder", + "text": "Welcome to Bot Framework!", + "wrap": true, + "maxLines": 0 + }, + { + "type": "TextBlock", + "size": "default", + "isSubtle": "true", + "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", + "wrap": true, + "maxLines": 0 + } + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Get an overview", + "url": "https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" + }, + { + "type": "Action.OpenUrl", + "title": "Ask a question", + "url": "https://stackoverflow.com/questions/tagged/botframework" + }, + { + "type": "Action.OpenUrl", + "title": "Learn how to deploy", + "url": "https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" + } + ] +} \ No newline at end of file diff --git a/samples/17.multilingual-bot/config.py b/samples/17.multilingual-bot/config.py new file mode 100644 index 000000000..7d323dda5 --- /dev/null +++ b/samples/17.multilingual-bot/config.py @@ -0,0 +1,17 @@ +#!/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", "") + SUBSCRIPTION_KEY = os.environ.get("SubscriptionKey", "") + SUBSCRIPTION_REGION = os.environ.get("SubscriptionRegion", "") diff --git a/samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/17.multilingual-bot/requirements.txt b/samples/17.multilingual-bot/requirements.txt new file mode 100644 index 000000000..32e489163 --- /dev/null +++ b/samples/17.multilingual-bot/requirements.txt @@ -0,0 +1,3 @@ +requests +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/samples/17.multilingual-bot/translation/__init__.py b/samples/17.multilingual-bot/translation/__init__.py new file mode 100644 index 000000000..7112f41c0 --- /dev/null +++ b/samples/17.multilingual-bot/translation/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .microsoft_translator import MicrosoftTranslator +from .translation_middleware import TranslationMiddleware + +__all__ = ["MicrosoftTranslator", "TranslationMiddleware"] diff --git a/samples/17.multilingual-bot/translation/microsoft_translator.py b/samples/17.multilingual-bot/translation/microsoft_translator.py new file mode 100644 index 000000000..e6a0ef16f --- /dev/null +++ b/samples/17.multilingual-bot/translation/microsoft_translator.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import requests +import uuid + + +class MicrosoftTranslator: + def __init__(self, subscription_key: str, subscription_region: str): + self.subscription_key = subscription_key + self.subscription_region = subscription_region + + # Don't forget to replace with your Cog Services location! + # Our Flask route will supply two arguments: text_input and language_output. + # When the translate text button is pressed in our Flask app, the Ajax request + # will grab these values from our web app, and use them in the request. + # See main.js for Ajax calls. + async def translate(self, text_input, language_output): + base_url = 'https://api.cognitive.microsofttranslator.com' + path = '/translate?api-version=3.0' + params = '&to=' + language_output + constructed_url = base_url + path + params + + headers = { + 'Ocp-Apim-Subscription-Key': self.subscription_key, + 'Ocp-Apim-Subscription-Region': self.subscription_region, + 'Content-type': 'application/json', + 'X-ClientTraceId': str(uuid.uuid4()) + } + + # You can pass more than one object in body. + body = [{ + 'text': text_input + }] + response = requests.post(constructed_url, headers=headers, json=body) + j = response.json() + + # for this sample, return the first translation + return j[0]["translations"][0]["text"] diff --git a/samples/17.multilingual-bot/translation/translation_middleware.py b/samples/17.multilingual-bot/translation/translation_middleware.py new file mode 100644 index 000000000..3b2ee0930 --- /dev/null +++ b/samples/17.multilingual-bot/translation/translation_middleware.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Callable, Awaitable, List + +from botbuilder.core import Middleware, UserState, TurnContext +from botbuilder.schema import Activity, ActivityTypes + +from translation import MicrosoftTranslator +from translation.translation_settings import TranslationSettings + +""" +Middleware for translating text between the user and bot. +Uses the Microsoft Translator Text API. +""" + + +class TranslationMiddleware(Middleware): + def __init__(self, translator: MicrosoftTranslator, user_state: UserState): + self.translator = translator + self.language_preference_accessor = user_state.create_property("LanguagePreference") + + async def on_turn( + self, turn_context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + """ + Processes an incoming activity. + :param turn_context: + :param logic: + :return: + """ + translate = await self._should_translate(turn_context) + if translate and turn_context.activity.type == ActivityTypes.message: + turn_context.activity.text = await self.translator.translate( + turn_context.activity.text, TranslationSettings.default_language.value + ) + + async def aux_on_send( + context: TurnContext, activities: List[Activity], next_send: Callable + ): + user_language = await self.language_preference_accessor.get( + context, TranslationSettings.default_language.value + ) + should_translate = user_language != TranslationSettings.default_language.value + + # Translate messages sent to the user to user language + if should_translate: + for activity in activities: + await self._translate_message_activity(activity, user_language) + + return await next_send() + + async def aux_on_update( + context: TurnContext, activity: Activity, next_update: Callable + ): + user_language = await self.language_preference_accessor.get( + context, TranslationSettings.default_language.value + ) + should_translate = user_language != TranslationSettings.default_language.value + + # Translate messages sent to the user to user language + if should_translate and activity.type == ActivityTypes.message: + await self._translate_message_activity(activity, user_language) + + return await next_update() + + turn_context.on_send_activities(aux_on_send) + turn_context.on_update_activity(aux_on_update) + + await logic() + + async def _should_translate(self, turn_context: TurnContext) -> bool: + user_language = await self.language_preference_accessor.get(turn_context, TranslationSettings.default_language.value) + return user_language != TranslationSettings.default_language.value + + async def _translate_message_activity(self, activity: Activity, target_locale: str): + if activity.type == ActivityTypes.message: + activity.text = await self.translator.translate(activity.text, target_locale) diff --git a/samples/17.multilingual-bot/translation/translation_settings.py b/samples/17.multilingual-bot/translation/translation_settings.py new file mode 100644 index 000000000..aee41542d --- /dev/null +++ b/samples/17.multilingual-bot/translation/translation_settings.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class TranslationSettings(str, Enum): + default_language = "en" + english_english = "en" + english_spanish = "es" + spanish_english = "in" + spanish_spanish = "it" From d5515ba8f6164be66642aa2fbbdd8adae7a2983f Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Mon, 11 Nov 2019 12:17:58 -0600 Subject: [PATCH 035/616] Added 23.facebook-events sample --- samples/23.facebook-events/README.md | 36 +++ samples/23.facebook-events/app.py | 83 ++++++ samples/23.facebook-events/bots/__init__.py | 6 + .../23.facebook-events/bots/facebook_bot.py | 123 +++++++++ samples/23.facebook-events/config.py | 15 ++ .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ samples/23.facebook-events/requirements.txt | 3 + 7 files changed, 508 insertions(+) create mode 100644 samples/23.facebook-events/README.md create mode 100644 samples/23.facebook-events/app.py create mode 100644 samples/23.facebook-events/bots/__init__.py create mode 100644 samples/23.facebook-events/bots/facebook_bot.py create mode 100644 samples/23.facebook-events/config.py create mode 100644 samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/23.facebook-events/requirements.txt diff --git a/samples/23.facebook-events/README.md b/samples/23.facebook-events/README.md new file mode 100644 index 000000000..01a0f2619 --- /dev/null +++ b/samples/23.facebook-events/README.md @@ -0,0 +1,36 @@ +# Facebook events + +Bot Framework v4 facebook events bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), is shows how to integrate and consume Facebook specific payloads, such as postbacks, quick replies and optin events. Since Bot Framework supports multiple Facebook pages for a single bot, we also show how to know the page to which the message was sent, so developers can have custom behavior per page. + +More information about configuring a bot for Facebook Messenger can be found here: [Connect a bot to Facebook](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-channel-connect-facebook) + +## 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\23.facebook-evbents` 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 + +## 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) +- [Facebook Quick Replies](https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies/0) +- [Facebook PostBack](https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messaging_postbacks/) +- [Facebook Opt-in](https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messaging_optins/) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/23.facebook-events/app.py b/samples/23.facebook-events/app.py new file mode 100644 index 000000000..babdb5fb9 --- /dev/null +++ b/samples/23.facebook-events/app.py @@ -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 FacebookBot + +# 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(self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = FacebookBot() + +# 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 diff --git a/samples/23.facebook-events/bots/__init__.py b/samples/23.facebook-events/bots/__init__.py new file mode 100644 index 000000000..7db4bb27c --- /dev/null +++ b/samples/23.facebook-events/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .facebook_bot import FacebookBot + +__all__ = ["FacebookBot"] diff --git a/samples/23.facebook-events/bots/facebook_bot.py b/samples/23.facebook-events/bots/facebook_bot.py new file mode 100644 index 000000000..d7d29a76e --- /dev/null +++ b/samples/23.facebook-events/bots/facebook_bot.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.choices import Choice, ChoiceFactory +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext, CardFactory +from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, HeroCard + +FacebookPageIdOption = "Facebook Id" +QuickRepliesOption = "Quick Replies" +PostBackOption = "PostBack" + + +class FacebookBot(ActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + if not await self._process_facebook_payload(turn_context, turn_context.activity.channel_data): + await self._show_choices(turn_context) + + async def on_event_activity(self, turn_context: TurnContext): + await self._process_facebook_payload(turn_context, turn_context.activity.value) + + async def _show_choices(self, turn_context: TurnContext): + choices = [ + Choice( + value=QuickRepliesOption, + action=CardAction( + title=QuickRepliesOption, + type=ActionTypes.post_back, + value=QuickRepliesOption + ) + ), + Choice( + value=FacebookPageIdOption, + action=CardAction( + title=FacebookPageIdOption, + type=ActionTypes.post_back, + value=FacebookPageIdOption + ) + ), + Choice( + value=PostBackOption, + action=CardAction( + title=PostBackOption, + type=ActionTypes.post_back, + value=PostBackOption + ) + ) + ] + + message = ChoiceFactory.for_channel( + turn_context.activity.channel_id, + choices, + "What Facebook feature would you like to try? Here are some quick replies to choose from!" + ) + await turn_context.send_activity(message) + + async def _process_facebook_payload(self, turn_context: TurnContext, data) -> bool: + if "postback" in data: + await self._on_facebook_postback(turn_context, data["postback"]) + return True + elif "optin" in data: + await self._on_facebook_optin(turn_context, data["optin"]) + return True + elif "message" in data and "quick_reply" in data["message"]: + await self._on_facebook_quick_reply(turn_context, data["message"]["quick_reply"]) + return True + elif "message" in data and data["message"]["is_echo"]: + await self._on_facebook_echo(turn_context, data["message"]) + return True + + async def _on_facebook_postback(self, turn_context: TurnContext, facebook_postback: dict): + # TODO: Your PostBack handling logic here... + + reply = MessageFactory.text("Postback") + await turn_context.send_activity(reply) + await self._show_choices(turn_context) + + async def _on_facebook_quick_reply(self, turn_context: TurnContext, facebook_quick_reply: dict): + # TODO: Your quick reply event handling logic here... + + if turn_context.activity.text == FacebookPageIdOption: + reply = MessageFactory.text( + f"This message comes from the following Facebook Page: {turn_context.activity.recipient.id}" + ) + await turn_context.send_activity(reply) + await self._show_choices(turn_context) + elif turn_context.activity.text == PostBackOption: + card = HeroCard( + text="Is 42 the answer to the ultimate question of Life, the Universe, and Everything?", + buttons=[ + CardAction( + title="Yes", + type=ActionTypes.post_back, + value="Yes" + ), + CardAction( + title="No", + type=ActionTypes.post_back, + value="No" + ) + ] + ) + reply = MessageFactory.attachment(CardFactory.hero_card(card)) + await turn_context.send_activity(reply) + else: + await turn_context.send_activity("Quick Reply") + await self._show_choices(turn_context) + + async def _on_facebook_optin(self, turn_context: TurnContext, facebook_optin: dict): + # TODO: Your optin event handling logic here... + await turn_context.send_activity("Opt In") + pass + + async def _on_facebook_echo(self, turn_context: TurnContext, facebook_message: dict): + # TODO: Your echo event handling logic here... + await turn_context.send_activity("Echo") + pass diff --git a/samples/23.facebook-events/config.py b/samples/23.facebook-events/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/23.facebook-events/config.py @@ -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", "") diff --git a/samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json b/samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/23.facebook-events/requirements.txt b/samples/23.facebook-events/requirements.txt new file mode 100644 index 000000000..a69322ec3 --- /dev/null +++ b/samples/23.facebook-events/requirements.txt @@ -0,0 +1,3 @@ +jsonpickle==1.2 +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From 52a1d84ab38c2072943f9df55db2c89d6b219fcc Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Mon, 11 Nov 2019 12:22:52 -0600 Subject: [PATCH 036/616] 17.multilingual-bot suggested corrections --- samples/17.multilingual-bot/app.py | 4 ++-- samples/17.multilingual-bot/bots/multilingual_bot.py | 11 +++++------ .../translation/microsoft_translator.py | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/samples/17.multilingual-bot/app.py b/samples/17.multilingual-bot/app.py index c968cd633..20b1490b4 100644 --- a/samples/17.multilingual-bot/app.py +++ b/samples/17.multilingual-bot/app.py @@ -32,7 +32,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -55,7 +55,7 @@ async def on_error(self, context: TurnContext, error: Exception): # Send a trace activity, which will be displayed in Bot Framework Emulator await context.send_activity(trace_activity) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +ADAPTER.on_turn_error = on_error # Create MemoryStorage and state MEMORY = MemoryStorage() diff --git a/samples/17.multilingual-bot/bots/multilingual_bot.py b/samples/17.multilingual-bot/bots/multilingual_bot.py index 4ca973db4..8ff3de599 100644 --- a/samples/17.multilingual-bot/bots/multilingual_bot.py +++ b/samples/17.multilingual-bot/bots/multilingual_bot.py @@ -28,7 +28,7 @@ def __init__(self, user_state: UserState): self.language_preference_accessor = self.user_state.create_property("LanguagePreference") async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext + self, members_added: [ChannelAccount], turn_context: TurnContext ): # Greet anyone that was not the target (recipient) of this message. # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. @@ -99,8 +99,7 @@ def _is_language_change_requested(self, utterance: str) -> bool: return False utterance = utterance.lower() - return utterance == TranslationSettings.english_spanish.value \ - or utterance == TranslationSettings.english_english.value \ - or utterance == TranslationSettings.spanish_spanish.value \ - or utterance == TranslationSettings.spanish_english.value - + return (utterance == TranslationSettings.english_spanish.value + or utterance == TranslationSettings.english_english.value + or utterance == TranslationSettings.spanish_spanish.value + or utterance == TranslationSettings.spanish_english.value) diff --git a/samples/17.multilingual-bot/translation/microsoft_translator.py b/samples/17.multilingual-bot/translation/microsoft_translator.py index e6a0ef16f..4b9a796c8 100644 --- a/samples/17.multilingual-bot/translation/microsoft_translator.py +++ b/samples/17.multilingual-bot/translation/microsoft_translator.py @@ -33,7 +33,7 @@ async def translate(self, text_input, language_output): 'text': text_input }] response = requests.post(constructed_url, headers=headers, json=body) - j = response.json() + json_response = response.json() # for this sample, return the first translation - return j[0]["translations"][0]["text"] + return json_response[0]["translations"][0]["text"] From 3c0ad15a029b1f7b48ea9bf5530ae610a277f0c0 Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Mon, 11 Nov 2019 12:25:59 -0600 Subject: [PATCH 037/616] 15.handling-attachments suggested corrections --- samples/15.handling-attachments/app.py | 4 ++-- samples/15.handling-attachments/bots/attachments_bot.py | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/samples/15.handling-attachments/app.py b/samples/15.handling-attachments/app.py index e91d29d84..7a67a1231 100644 --- a/samples/15.handling-attachments/app.py +++ b/samples/15.handling-attachments/app.py @@ -24,7 +24,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -47,7 +47,7 @@ async def on_error(self, context: TurnContext, error: Exception): # Send a trace activity, which will be displayed in Bot Framework Emulator await context.send_activity(trace_activity) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +ADAPTER.on_turn_error = on_error # Create the Bot BOT = AttachmentsBot() diff --git a/samples/15.handling-attachments/bots/attachments_bot.py b/samples/15.handling-attachments/bots/attachments_bot.py index 2f88806c0..194b83564 100644 --- a/samples/15.handling-attachments/bots/attachments_bot.py +++ b/samples/15.handling-attachments/bots/attachments_bot.py @@ -193,10 +193,9 @@ async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: ) base_uri: str = connector.config.base_url - attachment_uri = \ - base_uri \ - + ("" if base_uri.endswith("/") else "/") \ - + f"v3/attachments/{response.id}/views/original" + attachment_uri = (base_uri + + ("" if base_uri.endswith("/") else "/") + + f"v3/attachments/{response.id}/views/original") return Attachment( name="architecture-resize.png", From 911e1cf1d1a4978fd5d9659285d7f9c176ea1b9a Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Mon, 11 Nov 2019 12:28:28 -0600 Subject: [PATCH 038/616] 23.facebook-events: on_error is now an unbound function --- samples/23.facebook-events/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/23.facebook-events/app.py b/samples/23.facebook-events/app.py index babdb5fb9..7ded65bc2 100644 --- a/samples/23.facebook-events/app.py +++ b/samples/23.facebook-events/app.py @@ -24,7 +24,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -47,7 +47,7 @@ async def on_error(self, context: TurnContext, error: Exception): # Send a trace activity, which will be displayed in Bot Framework Emulator await context.send_activity(trace_activity) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +ADAPTER.on_turn_error = on_error # Create the Bot BOT = FacebookBot() From 5e275f0987a4115cd57ef95175ed57a1f791d2dd Mon Sep 17 00:00:00 2001 From: Tracy Boehrer Date: Tue, 12 Nov 2019 09:00:36 -0600 Subject: [PATCH 039/616] pylint and black, suggested corrections. --- samples/23.facebook-events/app.py | 16 ++-- .../23.facebook-events/bots/facebook_bot.py | 88 ++++++++++--------- 2 files changed, 58 insertions(+), 46 deletions(-) diff --git a/samples/23.facebook-events/app.py b/samples/23.facebook-events/app.py index 7ded65bc2..efd359d67 100644 --- a/samples/23.facebook-events/app.py +++ b/samples/23.facebook-events/app.py @@ -4,10 +4,13 @@ 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.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) from botbuilder.schema import Activity, ActivityTypes from bots import FacebookBot @@ -32,9 +35,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -42,11 +47,12 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 diff --git a/samples/23.facebook-events/bots/facebook_bot.py b/samples/23.facebook-events/bots/facebook_bot.py index d7d29a76e..7ee4ee609 100644 --- a/samples/23.facebook-events/bots/facebook_bot.py +++ b/samples/23.facebook-events/bots/facebook_bot.py @@ -5,9 +5,9 @@ from botbuilder.core import ActivityHandler, MessageFactory, TurnContext, CardFactory from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, HeroCard -FacebookPageIdOption = "Facebook Id" -QuickRepliesOption = "Quick Replies" -PostBackOption = "PostBack" +FACEBOOK_PAGEID_OPTION = "Facebook Id" +QUICK_REPLIES_OPTION = "Quick Replies" +POSTBACK_OPTION = "PostBack" class FacebookBot(ActivityHandler): @@ -19,7 +19,9 @@ async def on_members_added_activity( await turn_context.send_activity("Hello and welcome!") async def on_message_activity(self, turn_context: TurnContext): - if not await self._process_facebook_payload(turn_context, turn_context.activity.channel_data): + if not await self._process_facebook_payload( + turn_context, turn_context.activity.channel_data + ): await self._show_choices(turn_context) async def on_event_activity(self, turn_context: TurnContext): @@ -28,35 +30,35 @@ async def on_event_activity(self, turn_context: TurnContext): async def _show_choices(self, turn_context: TurnContext): choices = [ Choice( - value=QuickRepliesOption, + value=QUICK_REPLIES_OPTION, action=CardAction( - title=QuickRepliesOption, + title=QUICK_REPLIES_OPTION, type=ActionTypes.post_back, - value=QuickRepliesOption - ) + value=QUICK_REPLIES_OPTION, + ), ), Choice( - value=FacebookPageIdOption, + value=FACEBOOK_PAGEID_OPTION, action=CardAction( - title=FacebookPageIdOption, + title=FACEBOOK_PAGEID_OPTION, type=ActionTypes.post_back, - value=FacebookPageIdOption - ) + value=FACEBOOK_PAGEID_OPTION, + ), ), Choice( - value=PostBackOption, + value=POSTBACK_OPTION, action=CardAction( - title=PostBackOption, + title=POSTBACK_OPTION, type=ActionTypes.post_back, - value=PostBackOption - ) - ) + value=POSTBACK_OPTION, + ), + ), ] message = ChoiceFactory.for_channel( turn_context.activity.channel_id, choices, - "What Facebook feature would you like to try? Here are some quick replies to choose from!" + "What Facebook feature would you like to try? Here are some quick replies to choose from!", ) await turn_context.send_activity(message) @@ -64,60 +66,64 @@ async def _process_facebook_payload(self, turn_context: TurnContext, data) -> bo if "postback" in data: await self._on_facebook_postback(turn_context, data["postback"]) return True - elif "optin" in data: + + if "optin" in data: await self._on_facebook_optin(turn_context, data["optin"]) return True - elif "message" in data and "quick_reply" in data["message"]: - await self._on_facebook_quick_reply(turn_context, data["message"]["quick_reply"]) + + if "message" in data and "quick_reply" in data["message"]: + await self._on_facebook_quick_reply( + turn_context, data["message"]["quick_reply"] + ) return True - elif "message" in data and data["message"]["is_echo"]: + + if "message" in data and data["message"]["is_echo"]: await self._on_facebook_echo(turn_context, data["message"]) return True - async def _on_facebook_postback(self, turn_context: TurnContext, facebook_postback: dict): + async def _on_facebook_postback( + self, turn_context: TurnContext, facebook_postback: dict + ): # TODO: Your PostBack handling logic here... - reply = MessageFactory.text("Postback") + reply = MessageFactory.text(f"Postback: {facebook_postback}") await turn_context.send_activity(reply) await self._show_choices(turn_context) - async def _on_facebook_quick_reply(self, turn_context: TurnContext, facebook_quick_reply: dict): + async def _on_facebook_quick_reply( + self, turn_context: TurnContext, facebook_quick_reply: dict + ): # TODO: Your quick reply event handling logic here... - if turn_context.activity.text == FacebookPageIdOption: + if turn_context.activity.text == FACEBOOK_PAGEID_OPTION: reply = MessageFactory.text( f"This message comes from the following Facebook Page: {turn_context.activity.recipient.id}" ) await turn_context.send_activity(reply) await self._show_choices(turn_context) - elif turn_context.activity.text == PostBackOption: + elif turn_context.activity.text == POSTBACK_OPTION: card = HeroCard( text="Is 42 the answer to the ultimate question of Life, the Universe, and Everything?", buttons=[ - CardAction( - title="Yes", - type=ActionTypes.post_back, - value="Yes" - ), - CardAction( - title="No", - type=ActionTypes.post_back, - value="No" - ) - ] + CardAction(title="Yes", type=ActionTypes.post_back, value="Yes"), + CardAction(title="No", type=ActionTypes.post_back, value="No"), + ], ) reply = MessageFactory.attachment(CardFactory.hero_card(card)) await turn_context.send_activity(reply) else: + print(facebook_quick_reply) await turn_context.send_activity("Quick Reply") await self._show_choices(turn_context) async def _on_facebook_optin(self, turn_context: TurnContext, facebook_optin: dict): # TODO: Your optin event handling logic here... + print(facebook_optin) await turn_context.send_activity("Opt In") - pass - async def _on_facebook_echo(self, turn_context: TurnContext, facebook_message: dict): + async def _on_facebook_echo( + self, turn_context: TurnContext, facebook_message: dict + ): # TODO: Your echo event handling logic here... + print(facebook_message) await turn_context.send_activity("Echo") - pass From 9ed74df0ed112b33dd3845383ee59130ff15d9d6 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 12 Nov 2019 11:51:07 -0600 Subject: [PATCH 040/616] pylint and black changes. No logic changes. (#427) --- .../adapter/console_adapter.py | 25 +++-- samples/01.console-echo/main.py | 14 +-- samples/02.echo-bot/app.py | 5 +- samples/03.welcome-user/app.py | 14 ++- .../03.welcome-user/bots/welcome_user_bot.py | 104 ++++++++++-------- samples/05.multi-turn-prompt/app.py | 9 +- .../05.multi-turn-prompt/bots/dialog_bot.py | 22 ++-- .../dialogs/user_profile_dialog.py | 99 +++++++++-------- samples/06.using-cards/app.py | 9 +- samples/06.using-cards/bots/dialog_bot.py | 2 - samples/06.using-cards/bots/rich_cards_bot.py | 10 +- samples/06.using-cards/dialogs/main_dialog.py | 31 +++--- .../06.using-cards/helpers/activity_helper.py | 7 +- samples/08.suggested-actions/app.py | 20 ++-- .../bots/suggested_actions_bot.py | 45 ++++---- .../13.core-bot/adapter_with_error_handler.py | 10 +- samples/13.core-bot/booking_details.py | 6 +- .../bots/dialog_and_welcome_bot.py | 8 +- samples/13.core-bot/bots/dialog_bot.py | 2 - samples/13.core-bot/dialogs/booking_dialog.py | 62 +++++------ .../dialogs/cancel_and_help_dialog.py | 4 +- .../dialogs/date_resolver_dialog.py | 7 +- samples/13.core-bot/dialogs/main_dialog.py | 4 +- samples/13.core-bot/helpers/luis_helper.py | 9 +- samples/15.handling-attachments/app.py | 16 ++- .../bots/attachments_bot.py | 93 ++++++++-------- samples/16.proactive-messages/app.py | 26 +++-- .../bots/proactive_bot.py | 20 ++-- samples/17.multilingual-bot/app.py | 14 ++- .../bots/multilingual_bot.py | 57 ++++++---- .../translation/microsoft_translator.py | 20 ++-- .../translation/translation_middleware.py | 54 +++++---- samples/18.bot-authentication/app.py | 14 ++- .../18.bot-authentication/bots/auth_bot.py | 14 +-- .../18.bot-authentication/bots/dialog_bot.py | 2 - .../dialogs/logout_dialog.py | 4 +- .../dialogs/main_dialog.py | 45 +++++--- samples/19.custom-dialogs/app.py | 14 ++- samples/19.custom-dialogs/bots/dialog_bot.py | 7 +- .../19.custom-dialogs/dialogs/root_dialog.py | 88 +++++++-------- .../19.custom-dialogs/dialogs/slot_details.py | 27 +++-- .../dialogs/slot_filling_dialog.py | 45 +++++--- samples/21.corebot-app-insights/app.py | 9 +- .../bots/dialog_bot.py | 4 - samples/21.corebot-app-insights/config.py | 4 +- .../dialogs/booking_dialog.py | 19 ++-- .../dialogs/cancel_and_help_dialog.py | 4 +- .../dialogs/date_resolver_dialog.py | 22 ++-- .../dialogs/main_dialog.py | 14 +-- .../helpers/luis_helper.py | 4 +- samples/43.complex-dialog/app.py | 11 +- .../bots/dialog_and_welcome_bot.py | 8 +- .../data_models/user_profile.py | 4 +- .../43.complex-dialog/dialogs/main_dialog.py | 22 ++-- .../dialogs/review_selection_dialog.py | 54 +++++---- .../dialogs/top_level_dialog.py | 37 +++---- samples/44.prompt-users-for-input/app.py | 11 +- .../bots/custom_prompt_bot.py | 101 ++++++++++++----- .../data_models/conversation_flow.py | 3 +- samples/47.inspection/app.py | 24 ++-- samples/47.inspection/bots/echo_bot.py | 32 ++++-- 61 files changed, 829 insertions(+), 646 deletions(-) diff --git a/samples/01.console-echo/adapter/console_adapter.py b/samples/01.console-echo/adapter/console_adapter.py index 9ee38f065..28f4b4f8e 100644 --- a/samples/01.console-echo/adapter/console_adapter.py +++ b/samples/01.console-echo/adapter/console_adapter.py @@ -116,7 +116,7 @@ async def send_activities(self, context: TurnContext, activities: List[Activity] raise TypeError( "ConsoleAdapter.send_activities(): `context` argument cannot be None." ) - if type(activities) != list: + if not isinstance(activities, list): raise TypeError( "ConsoleAdapter.send_activities(): `activities` argument must be a list." ) @@ -130,24 +130,27 @@ async def next_activity(i: int): if i < len(activities): responses.append(ResourceResponse()) - a = activities[i] + activity = activities[i] - if a.type == "delay": - await asyncio.sleep(a.delay) + if activity.type == "delay": + await asyncio.sleep(activity.delay) await next_activity(i + 1) - elif a.type == ActivityTypes.message: - if a.attachments is not None and len(a.attachments) > 0: + elif activity.type == ActivityTypes.message: + if ( + activity.attachments is not None + and len(activity.attachments) > 0 + ): append = ( "(1 attachment)" - if len(a.attachments) == 1 - else f"({len(a.attachments)} attachments)" + if len(activity.attachments) == 1 + else f"({len(activity.attachments)} attachments)" ) - print(f"{a.text} {append}") + print(f"{activity.text} {append}") else: - print(a.text) + print(activity.text) await next_activity(i + 1) else: - print(f"[{a.type}]") + print(f"[{activity.type}]") await next_activity(i + 1) else: return responses diff --git a/samples/01.console-echo/main.py b/samples/01.console-echo/main.py index 351ff1879..73801d1b8 100644 --- a/samples/01.console-echo/main.py +++ b/samples/01.console-echo/main.py @@ -2,26 +2,24 @@ # Licensed under the MIT License. import asyncio -from botbuilder.core import TurnContext, ConversationState, UserState, MemoryStorage -from botbuilder.schema import ActivityTypes from adapter import ConsoleAdapter from bot import EchoBot # Create adapter -adapter = ConsoleAdapter() -bot = EchoBot() +ADAPTER = ConsoleAdapter() +BOT = EchoBot() -loop = asyncio.get_event_loop() +LOOP = asyncio.get_event_loop() if __name__ == "__main__": try: # Greet user print("Hi... I'm an echobot. Whatever you say I'll echo back.") - loop.run_until_complete(adapter.process_activity(bot.on_turn)) + LOOP.run_until_complete(ADAPTER.process_activity(BOT.on_turn)) except KeyboardInterrupt: pass finally: - loop.stop() - loop.close() + LOOP.stop() + LOOP.close() diff --git a/samples/02.echo-bot/app.py b/samples/02.echo-bot/app.py index e1de9d56a..5cc960eb8 100644 --- a/samples/02.echo-bot/app.py +++ b/samples/02.echo-bot/app.py @@ -4,7 +4,6 @@ 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 @@ -24,7 +23,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -47,7 +46,7 @@ async def on_error(self, context: TurnContext, error: Exception): # Send a trace activity, which will be displayed in Bot Framework Emulator await context.send_activity(trace_activity) -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) +ADAPTER.on_turn_error = on_error # Create the Bot BOT = EchoBot() diff --git a/samples/03.welcome-user/app.py b/samples/03.welcome-user/app.py index 7a771763d..7941afeb1 100644 --- a/samples/03.welcome-user/app.py +++ b/samples/03.welcome-user/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -30,7 +29,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -38,9 +37,11 @@ async def on_error(self, context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -48,12 +49,13 @@ async def on_error(self, context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 = MethodType(on_error, ADAPTER) + +ADAPTER.on_turn_error = on_error # Create MemoryStorage, UserState MEMORY = MemoryStorage() diff --git a/samples/03.welcome-user/bots/welcome_user_bot.py b/samples/03.welcome-user/bots/welcome_user_bot.py index 8fca0919f..9aa584732 100644 --- a/samples/03.welcome-user/bots/welcome_user_bot.py +++ b/samples/03.welcome-user/bots/welcome_user_bot.py @@ -1,8 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.core import ActivityHandler, TurnContext, UserState, CardFactory, MessageFactory -from botbuilder.schema import ChannelAccount, HeroCard, CardImage, CardAction, ActionTypes +from botbuilder.core import ( + ActivityHandler, + TurnContext, + UserState, + CardFactory, + MessageFactory, +) +from botbuilder.schema import ( + ChannelAccount, + HeroCard, + CardImage, + CardAction, + ActionTypes, +) from data_models import WelcomeUserState @@ -18,76 +30,76 @@ def __init__(self, user_state: UserState): self.user_state_accessor = self.user_state.create_property("WelcomeUserState") - self.WELCOME_MESSAGE = """This is a simple Welcome Bot sample. This bot will introduce you - to welcoming and greeting users. You can say 'intro' to see the - introduction card. If you are running this bot in the Bot Framework - Emulator, press the 'Restart Conversation' button to simulate user joining + self.WELCOME_MESSAGE = """This is a simple Welcome Bot sample. This bot will introduce you + to welcoming and greeting users. You can say 'intro' to see the + introduction card. If you are running this bot in the Bot Framework + Emulator, press the 'Restart Conversation' button to simulate user joining a bot or a channel""" - + async def on_turn(self, turn_context: TurnContext): await super().on_turn(turn_context) # save changes to WelcomeUserState after each turn await self.user_state.save_changes(turn_context) - """ - Greet when users are added to the conversation. - Note that all channels do not send the conversation update activity. - If you find that this bot works in the emulator, but does not in - another channel the reason is most likely that the channel does not - send this activity. - """ - async def on_members_added_activity( self, members_added: [ChannelAccount], turn_context: TurnContext ): + """ + Greet when users are added to the conversation. + Note that all channels do not send the conversation update activity. + If you find that this bot works in the emulator, but does not in + another channel the reason is most likely that the channel does not + send this activity. + """ for member in members_added: if member.id != turn_context.activity.recipient.id: await turn_context.send_activity( f"Hi there { member.name }. " + self.WELCOME_MESSAGE ) - await turn_context.send_activity("""You are seeing this message because the bot received at least one - 'ConversationUpdate' event, indicating you (and possibly others) - joined the conversation. If you are using the emulator, pressing - the 'Start Over' button to trigger this event again. The specifics - of the 'ConversationUpdate' event depends on the channel. You can + await turn_context.send_activity( + """You are seeing this message because the bot received at least one + 'ConversationUpdate' event, indicating you (and possibly others) + joined the conversation. If you are using the emulator, pressing + the 'Start Over' button to trigger this event again. The specifics + of the 'ConversationUpdate' event depends on the channel. You can read more information at: https://aka.ms/about-botframework-welcome-user""" ) - await turn_context.send_activity("""It is a good pattern to use this event to send general greeting - to user, explaining what your bot can do. In this example, the bot + await turn_context.send_activity( + """It is a good pattern to use this event to send general greeting + to user, explaining what your bot can do. In this example, the bot handles 'hello', 'hi', 'help' and 'intro'. Try it now, type 'hi'""" ) - """ - Respond to messages sent from the user. - """ - async def on_message_activity(self, turn_context: TurnContext): + """ + Respond to messages sent from the user. + """ # Get the state properties from the turn context. - welcome_user_state = await self.user_state_accessor.get(turn_context, WelcomeUserState) + welcome_user_state = await self.user_state_accessor.get( + turn_context, WelcomeUserState + ) if not welcome_user_state.did_welcome_user: welcome_user_state.did_welcome_user = True await turn_context.send_activity( "You are seeing this message because this was your first message ever to this bot." - ) + ) name = turn_context.activity.from_property.name await turn_context.send_activity( - f"It is a good practice to welcome the user and provide personal greeting. For example: Welcome { name }" + f"It is a good practice to welcome the user and provide personal greeting. For example: Welcome {name}" ) - + else: # This example hardcodes specific utterances. You should use LUIS or QnA for more advance language # understanding. text = turn_context.activity.text.lower() if text in ("hello", "hi"): - await turn_context.send_activity( - f"You said { text }" - ) + await turn_context.send_activity(f"You said { text }") elif text in ("intro", "help"): await self.__send_intro_card(turn_context) else: @@ -97,37 +109,35 @@ async def __send_intro_card(self, turn_context: TurnContext): card = HeroCard( title="Welcome to Bot Framework!", text="Welcome to Welcome Users bot sample! This Introduction card " - "is a great way to introduce your Bot to the user and suggest " - "some things to get them started. We use this opportunity to " - "recommend a few next steps for learning more creating and deploying bots.", - images=[ - CardImage( - url="https://aka.ms/bf-welcome-card-image" - ) - ], + "is a great way to introduce your Bot to the user and suggest " + "some things to get them started. We use this opportunity to " + "recommend a few next steps for learning more creating and deploying bots.", + images=[CardImage(url="https://aka.ms/bf-welcome-card-image")], buttons=[ CardAction( type=ActionTypes.open_url, title="Get an overview", text="Get an overview", display_text="Get an overview", - value="https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" + value="https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0", ), CardAction( type=ActionTypes.open_url, title="Ask a question", text="Ask a question", display_text="Ask a question", - value="https://stackoverflow.com/questions/tagged/botframework" + value="https://stackoverflow.com/questions/tagged/botframework", ), CardAction( type=ActionTypes.open_url, title="Learn how to deploy", text="Learn how to deploy", display_text="Learn how to deploy", - value="https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" - ) - ] + value="https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0", + ), + ], ) - return await turn_context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + return await turn_context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(card)) + ) diff --git a/samples/05.multi-turn-prompt/app.py b/samples/05.multi-turn-prompt/app.py index 790c2019c..fd68f6667 100644 --- a/samples/05.multi-turn-prompt/app.py +++ b/samples/05.multi-turn-prompt/app.py @@ -39,9 +39,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -49,7 +51,7 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/error" + value_type="https://www.botframework.com/schemas/error", ) # Send a trace activity, which will be displayed in Bot Framework Emulator @@ -58,6 +60,7 @@ async def on_error(context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) + # Set the error handler on the Adapter. # In this case, we want an unbound method, so MethodType is not needed. ADAPTER.on_turn_error = on_error diff --git a/samples/05.multi-turn-prompt/bots/dialog_bot.py b/samples/05.multi-turn-prompt/bots/dialog_bot.py index 37a140966..c66d73755 100644 --- a/samples/05.multi-turn-prompt/bots/dialog_bot.py +++ b/samples/05.multi-turn-prompt/bots/dialog_bot.py @@ -5,21 +5,21 @@ from botbuilder.dialogs import Dialog from helpers.dialog_helper import DialogHelper -""" -This Bot implementation can run any type of Dialog. The use of type parameterization is to allows multiple -different bots to be run at different endpoints within the same project. This can be achieved by defining distinct -Controller types each with dependency on distinct Bot types. The ConversationState is used by the Dialog system. The -UserState isn't, however, it might have been used in a Dialog implementation, and the requirement is that all -BotState objects are saved at the end of a turn. -""" - class DialogBot(ActivityHandler): + """ + This Bot implementation can run any type of Dialog. The use of type parameterization is to allows multiple + different bots to be run at different endpoints within the same project. This can be achieved by defining distinct + Controller types each with dependency on distinct Bot types. The ConversationState is used by the Dialog system. The + UserState isn't, however, it might have been used in a Dialog implementation, and the requirement is that all + BotState objects are saved at the end of a turn. + """ + def __init__( - self, - conversation_state: ConversationState, + self, + conversation_state: ConversationState, user_state: UserState, - dialog: Dialog + dialog: Dialog, ): if conversation_state is None: raise TypeError( diff --git a/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py index 86eea641b..dad1f6d18 100644 --- a/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py +++ b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py @@ -5,15 +5,15 @@ ComponentDialog, WaterfallDialog, WaterfallStepContext, - DialogTurnResult + DialogTurnResult, ) from botbuilder.dialogs.prompts import ( - TextPrompt, - NumberPrompt, - ChoicePrompt, - ConfirmPrompt, + TextPrompt, + NumberPrompt, + ChoicePrompt, + ConfirmPrompt, PromptOptions, - PromptValidatorContext + PromptValidatorContext, ) from botbuilder.dialogs.choices import Choice from botbuilder.core import MessageFactory, UserState @@ -22,47 +22,45 @@ class UserProfileDialog(ComponentDialog): - def __init__( - self, user_state: UserState - ): + def __init__(self, user_state: UserState): super(UserProfileDialog, self).__init__(UserProfileDialog.__name__) self.user_profile_accessor = user_state.create_property("UserProfile") self.add_dialog( WaterfallDialog( - WaterfallDialog.__name__, [ - self.transport_step, + WaterfallDialog.__name__, + [ + self.transport_step, self.name_step, self.name_confirm_step, self.age_step, self.confirm_step, - self.summary_step - ] + self.summary_step, + ], ) ) self.add_dialog(TextPrompt(TextPrompt.__name__)) self.add_dialog( - NumberPrompt( - NumberPrompt.__name__, - UserProfileDialog.age_prompt_validator - ) + NumberPrompt(NumberPrompt.__name__, UserProfileDialog.age_prompt_validator) ) self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) self.initial_dialog_id = WaterfallDialog.__name__ - async def transport_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # WaterfallStep always finishes with the end of the Waterfall or with another dialog; - # here it is a Prompt Dialog. Running a prompt here means the next WaterfallStep will + async def transport_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + # WaterfallStep always finishes with the end of the Waterfall or with another dialog; + # here it is a Prompt Dialog. Running a prompt here means the next WaterfallStep will # be run when the users response is received. return await step_context.prompt( - ChoicePrompt.__name__, + ChoicePrompt.__name__, PromptOptions( prompt=MessageFactory.text("Please enter your mode of transport."), - choices=[Choice("Car"), Choice("Bus"), Choice("Bicycle")] - ) + choices=[Choice("Car"), Choice("Bus"), Choice("Bicycle")], + ), ) async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: @@ -70,12 +68,12 @@ async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResul return await step_context.prompt( TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Please enter your name.") - ) + PromptOptions(prompt=MessageFactory.text("Please enter your name.")), ) - async def name_confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def name_confirm_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: step_context.values["name"] = step_context.result # We can send messages to the user at any point in the WaterfallStep. @@ -83,19 +81,19 @@ async def name_confirm_step(self, step_context: WaterfallStepContext) -> DialogT MessageFactory.text(f"Thanks {step_context.result}") ) - # WaterfallStep always finishes with the end of the Waterfall or + # WaterfallStep always finishes with the end of the Waterfall or # with another dialog; here it is a Prompt Dialog. return await step_context.prompt( ConfirmPrompt.__name__, PromptOptions( prompt=MessageFactory.text("Would you like to give your age?") - ) + ), ) async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: if step_context.result: # User said "yes" so we will be prompting for the age. - # WaterfallStep always finishes with the end of the Waterfall or with another dialog, + # WaterfallStep always finishes with the end of the Waterfall or with another dialog, # here it is a Prompt Dialog. return await step_context.prompt( NumberPrompt.__name__, @@ -103,36 +101,44 @@ async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult prompt=MessageFactory.text("Please enter your age."), retry_prompt=MessageFactory.text( "The value entered must be greater than 0 and less than 150." - ) - ) + ), + ), ) - else: - # User said "no" so we will skip the next step. Give -1 as the age. - return await step_context.next(-1) - async def confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # User said "no" so we will skip the next step. Give -1 as the age. + return await step_context.next(-1) + + async def confirm_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: age = step_context.result step_context.values["age"] = step_context.result - msg = "No age given." if step_context.result == -1 else f"I have your age as {age}." + msg = ( + "No age given." + if step_context.result == -1 + else f"I have your age as {age}." + ) # We can send messages to the user at any point in the WaterfallStep. await step_context.context.send_activity(MessageFactory.text(msg)) - # WaterfallStep always finishes with the end of the Waterfall or + # WaterfallStep always finishes with the end of the Waterfall or # with another dialog; here it is a Prompt Dialog. return await step_context.prompt( ConfirmPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Is this ok?") - ) + PromptOptions(prompt=MessageFactory.text("Is this ok?")), ) - async def summary_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def summary_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: if step_context.result: # Get the current profile object from user state. Changes to it # will saved during Bot.on_turn. - user_profile = await self.user_profile_accessor.get(step_context.context, UserProfile) + user_profile = await self.user_profile_accessor.get( + step_context.context, UserProfile + ) user_profile.transport = step_context.values["transport"] user_profile.name = step_context.values["name"] @@ -148,11 +154,14 @@ async def summary_step(self, step_context: WaterfallStepContext) -> DialogTurnRe MessageFactory.text("Thanks. Your profile will not be kept.") ) - # WaterfallStep always finishes with the end of the Waterfall or with another + # WaterfallStep always finishes with the end of the Waterfall or with another # dialog, here it is the end. return await step_context.end_dialog() @staticmethod async def age_prompt_validator(prompt_context: PromptValidatorContext) -> bool: # This condition is our validation rule. You can also change the value at this point. - return prompt_context.recognized.succeeded and 0 < prompt_context.recognized.value < 150 + return ( + prompt_context.recognized.succeeded + and 0 < prompt_context.recognized.value < 150 + ) diff --git a/samples/06.using-cards/app.py b/samples/06.using-cards/app.py index fe0c69b56..257474898 100644 --- a/samples/06.using-cards/app.py +++ b/samples/06.using-cards/app.py @@ -42,9 +42,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -52,7 +54,7 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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) @@ -60,6 +62,7 @@ async def on_error(context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) + # Set the error handler on the Adapter. # In this case, we want an unbound method, so MethodType is not needed. ADAPTER.on_turn_error = on_error diff --git a/samples/06.using-cards/bots/dialog_bot.py b/samples/06.using-cards/bots/dialog_bot.py index 2702db884..ff4473e85 100644 --- a/samples/06.using-cards/bots/dialog_bot.py +++ b/samples/06.using-cards/bots/dialog_bot.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import asyncio - from helpers.dialog_helper import DialogHelper from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext from botbuilder.dialogs import Dialog diff --git a/samples/06.using-cards/bots/rich_cards_bot.py b/samples/06.using-cards/bots/rich_cards_bot.py index d307465a0..54da137db 100644 --- a/samples/06.using-cards/bots/rich_cards_bot.py +++ b/samples/06.using-cards/bots/rich_cards_bot.py @@ -5,13 +5,13 @@ from botbuilder.schema import ChannelAccount from .dialog_bot import DialogBot -""" - RichCardsBot prompts a user to select a Rich Card and then returns the card - that matches the user's selection. -""" - class RichCardsBot(DialogBot): + """ + RichCardsBot prompts a user to select a Rich Card and then returns the card + that matches the user's selection. + """ + def __init__(self, conversation_state, user_state, dialog): super().__init__(conversation_state, user_state, dialog) diff --git a/samples/06.using-cards/dialogs/main_dialog.py b/samples/06.using-cards/dialogs/main_dialog.py index 1a574e44c..9490933e7 100644 --- a/samples/06.using-cards/dialogs/main_dialog.py +++ b/samples/06.using-cards/dialogs/main_dialog.py @@ -4,8 +4,6 @@ from botbuilder.core import CardFactory, MessageFactory from botbuilder.dialogs import ( ComponentDialog, - DialogSet, - DialogTurnStatus, WaterfallDialog, WaterfallStepContext, ) @@ -28,8 +26,8 @@ ReceiptItem, ) -from .resources.adaptive_card_example import ADAPTIVE_CARD_CONTENT from helpers.activity_helper import create_activity_reply +from .resources.adaptive_card_example import ADAPTIVE_CARD_CONTENT MAIN_WATERFALL_DIALOG = "mainWaterfallDialog" @@ -49,12 +47,11 @@ def __init__(self): # The initial child Dialog to run. self.initial_dialog_id = MAIN_WATERFALL_DIALOG - """ - 1. Prompts the user if the user is not in the middle of a dialog. - 2. Re-prompts the user when an invalid input is received. - """ - async def choice_card_step(self, step_context: WaterfallStepContext): + """ + 1. Prompts the user if the user is not in the middle of a dialog. + 2. Re-prompts the user when an invalid input is received. + """ menu_text = ( "Which card would you like to see?\n" "(1) Adaptive Card\n" @@ -73,12 +70,12 @@ async def choice_card_step(self, step_context: WaterfallStepContext): "TextPrompt", PromptOptions(prompt=MessageFactory.text(menu_text)) ) - """ - Send a Rich Card response to the user based on their choice. - self method is only called when a valid prompt response is parsed from the user's response to the ChoicePrompt. - """ - async def show_card_step(self, step_context: WaterfallStepContext): + """ + Send a Rich Card response to the user based on their choice. + self method is only called when a valid prompt response is parsed from the user's + response to the ChoicePrompt. + """ response = step_context.result.lower().strip() choice_dict = { "1": [self.create_adaptive_card], @@ -141,11 +138,9 @@ async def show_card_step(self, step_context: WaterfallStepContext): return await step_context.end_dialog() - """ - ====================================== - Helper functions used to create cards. - ====================================== - """ + # ====================================== + # Helper functions used to create cards. + # ====================================== # Methods to generate cards def create_adaptive_card(self) -> Attachment: diff --git a/samples/06.using-cards/helpers/activity_helper.py b/samples/06.using-cards/helpers/activity_helper.py index 16188a3ad..354317c3e 100644 --- a/samples/06.using-cards/helpers/activity_helper.py +++ b/samples/06.using-cards/helpers/activity_helper.py @@ -5,7 +5,6 @@ from botbuilder.schema import ( Activity, ActivityTypes, - Attachment, ChannelAccount, ConversationAccount, ) @@ -15,9 +14,11 @@ def create_activity_reply( activity: Activity, text: str = None, locale: str = None, - attachments: [Attachment] = [], + attachments=None, ): - attachments_aux = [attachment for attachment in attachments] + if attachments is None: + attachments = [] + attachments_aux = attachments.copy() return Activity( type=ActivityTypes.message, diff --git a/samples/08.suggested-actions/app.py b/samples/08.suggested-actions/app.py index 4e9403486..1504563d3 100644 --- a/samples/08.suggested-actions/app.py +++ b/samples/08.suggested-actions/app.py @@ -4,10 +4,13 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response -from botbuilder.core import BotFrameworkAdapterSettings, BotFrameworkAdapter, TurnContext +from botbuilder.core import ( + BotFrameworkAdapterSettings, + BotFrameworkAdapter, + TurnContext, +) from botbuilder.schema import Activity, ActivityTypes from bots import SuggestActionsBot @@ -24,7 +27,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -32,9 +35,11 @@ async def on_error(self, context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -42,12 +47,13 @@ async def on_error(self, context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 = MethodType(on_error, ADAPTER) + +ADAPTER.on_turn_error = on_error # Create Bot BOT = SuggestActionsBot() diff --git a/samples/08.suggested-actions/bots/suggested_actions_bot.py b/samples/08.suggested-actions/bots/suggested_actions_bot.py index 5bee547be..3daa70d5e 100644 --- a/samples/08.suggested-actions/bots/suggested_actions_bot.py +++ b/samples/08.suggested-actions/bots/suggested_actions_bot.py @@ -4,15 +4,17 @@ from botbuilder.core import ActivityHandler, MessageFactory, TurnContext from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, SuggestedActions -""" -This bot will respond to the user's input with suggested actions. -Suggested actions enable your bot to present buttons that the user -can tap to provide input. -""" - class SuggestActionsBot(ActivityHandler): - async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext): + """ + This bot will respond to the user's input with suggested actions. + Suggested actions enable your bot to present buttons that the user + can tap to provide input. + """ + + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): """ Send a welcome message to the user and tell them what actions they may perform to use this bot """ @@ -34,10 +36,13 @@ async def on_message_activity(self, turn_context: TurnContext): async def _send_welcome_message(self, turn_context: TurnContext): for member in turn_context.activity.members_added: if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity(MessageFactory.text( - f"Welcome to SuggestedActionsBot {member.name}. This bot will introduce you to suggestedActions. " - f"Please answer the question: " - )) + await turn_context.send_activity( + MessageFactory.text( + f"Welcome to SuggestedActionsBot {member.name}." + f" This bot will introduce you to suggestedActions." + f" Please answer the question: " + ) + ) await self._send_suggested_actions(turn_context) @@ -67,21 +72,9 @@ async def _send_suggested_actions(self, turn_context: TurnContext): reply.suggested_actions = SuggestedActions( actions=[ - CardAction( - title="Red", - type=ActionTypes.im_back, - value="Read" - ), - CardAction( - title="Yellow", - type=ActionTypes.im_back, - value="Yellow" - ), - CardAction( - title="Blue", - type=ActionTypes.im_back, - value="Blue" - ) + CardAction(title="Red", type=ActionTypes.im_back, value="Red"), + CardAction(title="Yellow", type=ActionTypes.im_back, value="Yellow"), + CardAction(title="Blue", type=ActionTypes.im_back, value="Blue"), ] ) diff --git a/samples/13.core-bot/adapter_with_error_handler.py b/samples/13.core-bot/adapter_with_error_handler.py index 8a4bcaf54..1826e1e47 100644 --- a/samples/13.core-bot/adapter_with_error_handler.py +++ b/samples/13.core-bot/adapter_with_error_handler.py @@ -9,7 +9,7 @@ ConversationState, TurnContext, ) -from botbuilder.schema import InputHints, ActivityTypes, Activity +from botbuilder.schema import ActivityTypes, Activity class AdapterWithErrorHandler(BotFrameworkAdapter): @@ -30,9 +30,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -40,7 +42,7 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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) diff --git a/samples/13.core-bot/booking_details.py b/samples/13.core-bot/booking_details.py index 24c7a1df8..9c2d2a1bc 100644 --- a/samples/13.core-bot/booking_details.py +++ b/samples/13.core-bot/booking_details.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - class BookingDetails: def __init__( @@ -10,8 +8,10 @@ def __init__( destination: str = None, origin: str = None, travel_date: str = None, - unsupported_airports: List[str] = [], + unsupported_airports=None, ): + if unsupported_airports is None: + unsupported_airports = [] self.destination = destination self.origin = origin self.travel_date = travel_date diff --git a/samples/13.core-bot/bots/dialog_and_welcome_bot.py b/samples/13.core-bot/bots/dialog_and_welcome_bot.py index b392e2e1f..bfe8957af 100644 --- a/samples/13.core-bot/bots/dialog_and_welcome_bot.py +++ b/samples/13.core-bot/bots/dialog_and_welcome_bot.py @@ -5,16 +5,14 @@ import os.path from typing import List -from botbuilder.core import CardFactory from botbuilder.core import ( - ActivityHandler, ConversationState, MessageFactory, UserState, TurnContext, ) from botbuilder.dialogs import Dialog -from botbuilder.schema import Activity, Attachment, ChannelAccount +from botbuilder.schema import Attachment, ChannelAccount from helpers.dialog_helper import DialogHelper from .dialog_bot import DialogBot @@ -51,8 +49,8 @@ async def on_members_added_activity( def create_adaptive_card_attachment(self): relative_path = os.path.abspath(os.path.dirname(__file__)) path = os.path.join(relative_path, "../cards/welcomeCard.json") - with open(path) as f: - card = json.load(f) + with open(path) as in_file: + card = json.load(in_file) return Attachment( content_type="application/vnd.microsoft.card.adaptive", content=card diff --git a/samples/13.core-bot/bots/dialog_bot.py b/samples/13.core-bot/bots/dialog_bot.py index fc563d2ec..eb560a1be 100644 --- a/samples/13.core-bot/bots/dialog_bot.py +++ b/samples/13.core-bot/bots/dialog_bot.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import asyncio - from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext from botbuilder.dialogs import Dialog from helpers.dialog_helper import DialogHelper diff --git a/samples/13.core-bot/dialogs/booking_dialog.py b/samples/13.core-bot/dialogs/booking_dialog.py index 297dff07c..5b4381919 100644 --- a/samples/13.core-bot/dialogs/booking_dialog.py +++ b/samples/13.core-bot/dialogs/booking_dialog.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from datatypes_date_time.timex import Timex + from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions from botbuilder.core import MessageFactory @@ -8,8 +10,6 @@ from .cancel_and_help_dialog import CancelAndHelpDialog from .date_resolver_dialog import DateResolverDialog -from datatypes_date_time.timex import Timex - class BookingDialog(CancelAndHelpDialog): def __init__(self, dialog_id: str = None): @@ -33,15 +33,14 @@ def __init__(self, dialog_id: str = None): self.initial_dialog_id = WaterfallDialog.__name__ - """ - If a destination city has not been provided, prompt for one. - :param step_context: - :return DialogTurnResult: - """ - async def destination_step( self, step_context: WaterfallStepContext ) -> DialogTurnResult: + """ + If a destination city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ booking_details = step_context.options if booking_details.destination is None: @@ -54,13 +53,12 @@ async def destination_step( ) return await step_context.next(booking_details.destination) - """ - If an origin city has not been provided, prompt for one. - :param step_context: - :return DialogTurnResult: - """ - async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + If an origin city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ booking_details = step_context.options # Capture the response to the previous step's prompt @@ -75,16 +73,15 @@ async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnRes ) return await step_context.next(booking_details.origin) - """ - If a travel date has not been provided, prompt for one. - This will use the DATE_RESOLVER_DIALOG. - :param step_context: - :return DialogTurnResult: - """ - async def travel_date_step( self, step_context: WaterfallStepContext ) -> DialogTurnResult: + """ + If a travel date has not been provided, prompt for one. + This will use the DATE_RESOLVER_DIALOG. + :param step_context: + :return DialogTurnResult: + """ booking_details = step_context.options # Capture the results of the previous step @@ -97,15 +94,14 @@ async def travel_date_step( ) return await step_context.next(booking_details.travel_date) - """ - Confirm the information the user has provided. - :param step_context: - :return DialogTurnResult: - """ - async def confirm_step( self, step_context: WaterfallStepContext ) -> DialogTurnResult: + """ + Confirm the information the user has provided. + :param step_context: + :return DialogTurnResult: + """ booking_details = step_context.options # Capture the results of the previous step @@ -123,14 +119,12 @@ async def confirm_step( ConfirmPrompt.__name__, PromptOptions(prompt=prompt_message) ) - """ - Complete the interaction and end the dialog. - :param step_context: - :return DialogTurnResult: - """ - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - + """ + Complete the interaction and end the dialog. + :param step_context: + :return DialogTurnResult: + """ if step_context.result: booking_details = step_context.options diff --git a/samples/13.core-bot/dialogs/cancel_and_help_dialog.py b/samples/13.core-bot/dialogs/cancel_and_help_dialog.py index 93c71d7df..f8bcc77d0 100644 --- a/samples/13.core-bot/dialogs/cancel_and_help_dialog.py +++ b/samples/13.core-bot/dialogs/cancel_and_help_dialog.py @@ -31,7 +31,7 @@ async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: help_message_text, help_message_text, InputHints.expecting_input ) - if text == "help" or text == "?": + if text in ("help", "?"): await inner_dc.context.send_activity(help_message) return DialogTurnResult(DialogTurnStatus.Waiting) @@ -40,7 +40,7 @@ async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: cancel_message_text, cancel_message_text, InputHints.ignoring_input ) - if text == "cancel" or text == "quit": + if text in ("cancel", "quit"): await inner_dc.context.send_activity(cancel_message) return await inner_dc.cancel_all_dialogs() diff --git a/samples/13.core-bot/dialogs/date_resolver_dialog.py b/samples/13.core-bot/dialogs/date_resolver_dialog.py index a375b6fa4..a34f47a7a 100644 --- a/samples/13.core-bot/dialogs/date_resolver_dialog.py +++ b/samples/13.core-bot/dialogs/date_resolver_dialog.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from datatypes_date_time.timex import Timex + from botbuilder.core import MessageFactory from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext from botbuilder.dialogs.prompts import ( @@ -12,8 +14,6 @@ from botbuilder.schema import InputHints from .cancel_and_help_dialog import CancelAndHelpDialog -from datatypes_date_time.timex import Timex - class DateResolverDialog(CancelAndHelpDialog): def __init__(self, dialog_id: str = None): @@ -44,7 +44,8 @@ async def initial_step( prompt_msg_text, prompt_msg_text, InputHints.expecting_input ) - reprompt_msg_text = "I'm sorry, for best results, please enter your travel date including the month, day and year." + reprompt_msg_text = "I'm sorry, for best results, please enter your travel date including the month, " \ + "day and year. " reprompt_msg = MessageFactory.text( reprompt_msg_text, reprompt_msg_text, InputHints.expecting_input ) diff --git a/samples/13.core-bot/dialogs/main_dialog.py b/samples/13.core-bot/dialogs/main_dialog.py index d85242375..82dfaa00b 100644 --- a/samples/13.core-bot/dialogs/main_dialog.py +++ b/samples/13.core-bot/dialogs/main_dialog.py @@ -11,10 +11,10 @@ from botbuilder.core import MessageFactory, TurnContext from botbuilder.schema import InputHints -from .booking_dialog import BookingDialog from booking_details import BookingDetails from flight_booking_recognizer import FlightBookingRecognizer from helpers.luis_helper import LuisHelper, Intent +from .booking_dialog import BookingDialog class MainDialog(ComponentDialog): @@ -81,7 +81,7 @@ async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult # Run the BookingDialog giving it whatever details we have from the LUIS call. return await step_context.begin_dialog(self._booking_dialog_id, luis_result) - elif intent == Intent.GET_WEATHER.value: + if intent == Intent.GET_WEATHER.value: get_weather_text = "TODO: get weather flow here" get_weather_message = MessageFactory.text( get_weather_text, get_weather_text, InputHints.ignoring_input diff --git a/samples/13.core-bot/helpers/luis_helper.py b/samples/13.core-bot/helpers/luis_helper.py index fc59d8969..3e28bc47e 100644 --- a/samples/13.core-bot/helpers/luis_helper.py +++ b/samples/13.core-bot/helpers/luis_helper.py @@ -81,8 +81,9 @@ async def execute_luis_query( from_entities[0]["text"].capitalize() ) - # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. - # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. + # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop + # the Time part. TIMEX is a format that represents DateTime expressions that include some ambiguity. + # e.g. missing a Year. date_entities = recognizer_result.entities.get("datetime", []) if date_entities: timex = date_entities[0]["timex"] @@ -95,7 +96,7 @@ async def execute_luis_query( else: result.travel_date = None - except Exception as e: - print(e) + except Exception as exception: + print(exception) return intent, result diff --git a/samples/15.handling-attachments/app.py b/samples/15.handling-attachments/app.py index 7a67a1231..47758a1e3 100644 --- a/samples/15.handling-attachments/app.py +++ b/samples/15.handling-attachments/app.py @@ -4,10 +4,13 @@ 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.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) from botbuilder.schema import Activity, ActivityTypes from bots import AttachmentsBot @@ -32,9 +35,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -42,11 +47,12 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 diff --git a/samples/15.handling-attachments/bots/attachments_bot.py b/samples/15.handling-attachments/bots/attachments_bot.py index 194b83564..51fd8bb50 100644 --- a/samples/15.handling-attachments/bots/attachments_bot.py +++ b/samples/15.handling-attachments/bots/attachments_bot.py @@ -16,25 +16,30 @@ Attachment, AttachmentData, Activity, - ActionTypes + 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): + """ + 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. + """ + + 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: + 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) @@ -49,8 +54,10 @@ async def _send_welcome_message(self, turn_context: TurnContext): """ 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 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): @@ -68,7 +75,8 @@ async def _handle_incoming_attachment(self, turn_context: TurnContext): 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']}") + f"Attachment {attachment_info['filename']} has been received to {attachment_info['local_path']}" + ) async def _download_attachment_and_write(self, attachment: Attachment) -> dict: """ @@ -91,18 +99,13 @@ async def _download_attachment_and_write(self, attachment: Attachment) -> dict: 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 {"filename": attachment.name, "local_path": local_filename} + except Exception as exception: + print(exception) return {} async def _handle_outgoing_attachment(self, turn_context: TurnContext): - reply = Activity( - type=ActivityTypes.message - ) + reply = Activity(type=ActivityTypes.message) first_char = turn_context.activity.text[0] if first_char == "1": @@ -133,21 +136,15 @@ async def _display_options(self, turn_context: TurnContext): 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" + type=ActionTypes.im_back, title="1. Inline Attachment", value="1" ), CardAction( - type=ActionTypes.im_back, - title="2. Internet Attachment", - value="2" + type=ActionTypes.im_back, title="2. Internet Attachment", value="2" ), CardAction( - type=ActionTypes.im_back, - title="3. Uploaded Attachment", - value="3" - ) - ] + type=ActionTypes.im_back, title="3. Uploaded Attachment", value="3" + ), + ], ) reply = MessageFactory.attachment(CardFactory.hero_card(card)) @@ -169,7 +166,7 @@ def _get_inline_attachment(self) -> Attachment: return Attachment( name="architecture-resize.png", content_type="image/png", - content_url=f"data:image/png;base64,{base64_image}" + content_url=f"data:image/png;base64,{base64_image}", ) async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: @@ -178,29 +175,35 @@ async def _get_upload_attachment(self, turn_context: TurnContext) -> Attachment: :param turn_context: :return: Attachment """ - with open(os.path.join(os.getcwd(), "resources/architecture-resize.png"), "rb") as in_file: + 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) + 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" - ) + 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") + 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 + content_url=attachment_uri, ) def _get_internet_attachment(self) -> Attachment: @@ -211,5 +214,5 @@ def _get_internet_attachment(self) -> 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" + content_url="https://docs.microsoft.com/en-us/bot-framework/media/how-it-works/architecture-resize.png", ) diff --git a/samples/16.proactive-messages/app.py b/samples/16.proactive-messages/app.py index 8abfdaeda..b00709eff 100644 --- a/samples/16.proactive-messages/app.py +++ b/samples/16.proactive-messages/app.py @@ -5,11 +5,14 @@ import sys import uuid from datetime import datetime -from types import MethodType from typing import Dict from flask import Flask, request, Response -from botbuilder.core import BotFrameworkAdapterSettings, TurnContext, BotFrameworkAdapter +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) from botbuilder.schema import Activity, ActivityTypes, ConversationReference from bots import ProactiveBot @@ -26,7 +29,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -34,9 +37,11 @@ async def on_error(self, context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -44,12 +49,13 @@ async def on_error(self, context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 = MethodType(on_error, ADAPTER) + +ADAPTER.on_turn_error = on_error # Create a shared dictionary. The Bot will add conversation references when users # join the conversation and send messages. @@ -91,9 +97,7 @@ def messages(): @APP.route("/api/notify") def notify(): try: - task = LOOP.create_task( - _send_proactive_message() - ) + task = LOOP.create_task(_send_proactive_message()) LOOP.run_until_complete(task) return Response(status=201, response="Proactive messages have been sent") @@ -108,7 +112,7 @@ async def _send_proactive_message(): return await ADAPTER.continue_conversation( APP_ID, conversation_reference, - lambda turn_context: turn_context.send_activity("proactive hello") + lambda turn_context: turn_context.send_activity("proactive hello"), ) diff --git a/samples/16.proactive-messages/bots/proactive_bot.py b/samples/16.proactive-messages/bots/proactive_bot.py index 79cc2df71..c65626899 100644 --- a/samples/16.proactive-messages/bots/proactive_bot.py +++ b/samples/16.proactive-messages/bots/proactive_bot.py @@ -8,9 +8,7 @@ class ProactiveBot(ActivityHandler): - def __init__( - self, conversation_references: Dict[str, ConversationReference] - ): + def __init__(self, conversation_references: Dict[str, ConversationReference]): self.conversation_references = conversation_references async def on_conversation_update_activity(self, turn_context: TurnContext): @@ -22,13 +20,17 @@ async def on_members_added_activity( ): for member in members_added: if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity("Welcome to the Proactive Bot sample. Navigate to " - "http://localhost:3978/api/notify to proactively message everyone " - "who has previously messaged this bot.") + await turn_context.send_activity( + "Welcome to the Proactive Bot sample. Navigate to " + "http://localhost:3978/api/notify to proactively message everyone " + "who has previously messaged this bot." + ) async def on_message_activity(self, turn_context: TurnContext): self._add_conversation_reference(turn_context.activity) - return await turn_context.send_activity(f"You sent: {turn_context.activity.text}") + return await turn_context.send_activity( + f"You sent: {turn_context.activity.text}" + ) def _add_conversation_reference(self, activity: Activity): """ @@ -38,4 +40,6 @@ def _add_conversation_reference(self, activity: Activity): :return: """ conversation_reference = TurnContext.get_conversation_reference(activity) - self.conversation_references[conversation_reference.user.id] = conversation_reference + self.conversation_references[ + conversation_reference.user.id + ] = conversation_reference diff --git a/samples/17.multilingual-bot/app.py b/samples/17.multilingual-bot/app.py index 20b1490b4..bdba1af1a 100644 --- a/samples/17.multilingual-bot/app.py +++ b/samples/17.multilingual-bot/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -40,9 +39,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -50,11 +51,12 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 MemoryStorage and state @@ -62,7 +64,9 @@ async def on_error(context: TurnContext, error: Exception): USER_STATE = UserState(MEMORY) # Create translation middleware and add to adapter -TRANSLATOR = MicrosoftTranslator(app.config["SUBSCRIPTION_KEY"], app.config["SUBSCRIPTION_REGION"]) +TRANSLATOR = MicrosoftTranslator( + app.config["SUBSCRIPTION_KEY"], app.config["SUBSCRIPTION_REGION"] +) TRANSLATION_MIDDLEWARE = TranslationMiddleware(TRANSLATOR, USER_STATE) ADAPTER.use(TRANSLATION_MIDDLEWARE) diff --git a/samples/17.multilingual-bot/bots/multilingual_bot.py b/samples/17.multilingual-bot/bots/multilingual_bot.py index 8ff3de599..b2bcf24fa 100644 --- a/samples/17.multilingual-bot/bots/multilingual_bot.py +++ b/samples/17.multilingual-bot/bots/multilingual_bot.py @@ -4,19 +4,31 @@ import json import os -from botbuilder.core import ActivityHandler, TurnContext, UserState, CardFactory, MessageFactory -from botbuilder.schema import ChannelAccount, Attachment, SuggestedActions, CardAction, ActionTypes +from botbuilder.core import ( + ActivityHandler, + TurnContext, + UserState, + CardFactory, + MessageFactory, +) +from botbuilder.schema import ( + ChannelAccount, + Attachment, + SuggestedActions, + CardAction, + ActionTypes, +) from translation.translation_settings import TranslationSettings -""" -This bot demonstrates how to use Microsoft Translator. -More information can be found at: -https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-info-overview" -""" - class MultiLingualBot(ActivityHandler): + """ + This bot demonstrates how to use Microsoft Translator. + More information can be found at: + https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-info-overview" + """ + def __init__(self, user_state: UserState): if user_state is None: raise TypeError( @@ -25,10 +37,12 @@ def __init__(self, user_state: UserState): self.user_state = user_state - self.language_preference_accessor = self.user_state.create_property("LanguagePreference") + self.language_preference_accessor = self.user_state.create_property( + "LanguagePreference" + ) async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext + self, members_added: [ChannelAccount], turn_context: TurnContext ): # Greet anyone that was not the target (recipient) of this message. # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. @@ -38,7 +52,7 @@ async def on_members_added_activity( MessageFactory.attachment(self._create_adaptive_card_attachment()) ) await turn_context.send_activity( - "This bot will introduce you to translation middleware. Say \'hi\' to get started." + "This bot will introduce you to translation middleware. Say 'hi' to get started." ) async def on_message_activity(self, turn_context: TurnContext): @@ -49,8 +63,9 @@ async def on_message_activity(self, turn_context: TurnContext): # selected language. # If Spanish was selected by the user, the reply below will actually be shown in Spanish to the user. current_language = turn_context.activity.text.lower() - if current_language == TranslationSettings.english_english.value \ - or current_language == TranslationSettings.spanish_english.value: + if current_language in ( + TranslationSettings.english_english.value, TranslationSettings.spanish_english.value + ): lang = TranslationSettings.english_english.value else: lang = TranslationSettings.english_spanish.value @@ -71,13 +86,13 @@ async def on_message_activity(self, turn_context: TurnContext): CardAction( title="Español", type=ActionTypes.post_back, - value=TranslationSettings.english_spanish.value + value=TranslationSettings.english_spanish.value, ), CardAction( title="English", type=ActionTypes.post_back, - value=TranslationSettings.english_english.value - ) + value=TranslationSettings.english_english.value, + ), ] ) @@ -99,7 +114,9 @@ def _is_language_change_requested(self, utterance: str) -> bool: return False utterance = utterance.lower() - return (utterance == TranslationSettings.english_spanish.value - or utterance == TranslationSettings.english_english.value - or utterance == TranslationSettings.spanish_spanish.value - or utterance == TranslationSettings.spanish_english.value) + return utterance in ( + TranslationSettings.english_spanish.value, + TranslationSettings.english_english.value, + TranslationSettings.spanish_spanish.value, + TranslationSettings.spanish_english.value + ) diff --git a/samples/17.multilingual-bot/translation/microsoft_translator.py b/samples/17.multilingual-bot/translation/microsoft_translator.py index 4b9a796c8..9af148fc6 100644 --- a/samples/17.multilingual-bot/translation/microsoft_translator.py +++ b/samples/17.multilingual-bot/translation/microsoft_translator.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import requests import uuid +import requests class MicrosoftTranslator: @@ -16,22 +16,20 @@ def __init__(self, subscription_key: str, subscription_region: str): # will grab these values from our web app, and use them in the request. # See main.js for Ajax calls. async def translate(self, text_input, language_output): - base_url = 'https://api.cognitive.microsofttranslator.com' - path = '/translate?api-version=3.0' - params = '&to=' + language_output + base_url = "https://api.cognitive.microsofttranslator.com" + path = "/translate?api-version=3.0" + params = "&to=" + language_output constructed_url = base_url + path + params headers = { - 'Ocp-Apim-Subscription-Key': self.subscription_key, - 'Ocp-Apim-Subscription-Region': self.subscription_region, - 'Content-type': 'application/json', - 'X-ClientTraceId': str(uuid.uuid4()) + "Ocp-Apim-Subscription-Key": self.subscription_key, + "Ocp-Apim-Subscription-Region": self.subscription_region, + "Content-type": "application/json", + "X-ClientTraceId": str(uuid.uuid4()), } # You can pass more than one object in body. - body = [{ - 'text': text_input - }] + body = [{"text": text_input}] response = requests.post(constructed_url, headers=headers, json=body) json_response = response.json() diff --git a/samples/17.multilingual-bot/translation/translation_middleware.py b/samples/17.multilingual-bot/translation/translation_middleware.py index 3b2ee0930..b983b2acb 100644 --- a/samples/17.multilingual-bot/translation/translation_middleware.py +++ b/samples/17.multilingual-bot/translation/translation_middleware.py @@ -9,39 +9,43 @@ from translation import MicrosoftTranslator from translation.translation_settings import TranslationSettings -""" -Middleware for translating text between the user and bot. -Uses the Microsoft Translator Text API. -""" - class TranslationMiddleware(Middleware): + """ + Middleware for translating text between the user and bot. + Uses the Microsoft Translator Text API. + """ + def __init__(self, translator: MicrosoftTranslator, user_state: UserState): self.translator = translator - self.language_preference_accessor = user_state.create_property("LanguagePreference") + self.language_preference_accessor = user_state.create_property( + "LanguagePreference" + ) async def on_turn( - self, turn_context: TurnContext, logic: Callable[[TurnContext], Awaitable] + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] ): """ Processes an incoming activity. - :param turn_context: + :param context: :param logic: :return: """ - translate = await self._should_translate(turn_context) - if translate and turn_context.activity.type == ActivityTypes.message: - turn_context.activity.text = await self.translator.translate( - turn_context.activity.text, TranslationSettings.default_language.value + translate = await self._should_translate(context) + if translate and context.activity.type == ActivityTypes.message: + context.activity.text = await self.translator.translate( + context.activity.text, TranslationSettings.default_language.value ) async def aux_on_send( - context: TurnContext, activities: List[Activity], next_send: Callable + ctx: TurnContext, activities: List[Activity], next_send: Callable ): user_language = await self.language_preference_accessor.get( - context, TranslationSettings.default_language.value + ctx, TranslationSettings.default_language.value + ) + should_translate = ( + user_language != TranslationSettings.default_language.value ) - should_translate = user_language != TranslationSettings.default_language.value # Translate messages sent to the user to user language if should_translate: @@ -51,12 +55,14 @@ async def aux_on_send( return await next_send() async def aux_on_update( - context: TurnContext, activity: Activity, next_update: Callable + ctx: TurnContext, activity: Activity, next_update: Callable ): user_language = await self.language_preference_accessor.get( - context, TranslationSettings.default_language.value + ctx, TranslationSettings.default_language.value + ) + should_translate = ( + user_language != TranslationSettings.default_language.value ) - should_translate = user_language != TranslationSettings.default_language.value # Translate messages sent to the user to user language if should_translate and activity.type == ActivityTypes.message: @@ -64,15 +70,19 @@ async def aux_on_update( return await next_update() - turn_context.on_send_activities(aux_on_send) - turn_context.on_update_activity(aux_on_update) + context.on_send_activities(aux_on_send) + context.on_update_activity(aux_on_update) await logic() async def _should_translate(self, turn_context: TurnContext) -> bool: - user_language = await self.language_preference_accessor.get(turn_context, TranslationSettings.default_language.value) + user_language = await self.language_preference_accessor.get( + turn_context, TranslationSettings.default_language.value + ) return user_language != TranslationSettings.default_language.value async def _translate_message_activity(self, activity: Activity, target_locale: str): if activity.type == ActivityTypes.message: - activity.text = await self.translator.translate(activity.text, target_locale) + activity.text = await self.translator.translate( + activity.text, target_locale + ) diff --git a/samples/18.bot-authentication/app.py b/samples/18.bot-authentication/app.py index 70d9e8334..c8910b155 100644 --- a/samples/18.bot-authentication/app.py +++ b/samples/18.bot-authentication/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -33,7 +32,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -41,10 +40,12 @@ async def on_error(self, context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -52,12 +53,13 @@ async def on_error(self, context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 = MethodType(on_error, ADAPTER) + +ADAPTER.on_turn_error = on_error # Create MemoryStorage and state MEMORY = MemoryStorage() diff --git a/samples/18.bot-authentication/bots/auth_bot.py b/samples/18.bot-authentication/bots/auth_bot.py index c1d1d936f..93166f655 100644 --- a/samples/18.bot-authentication/bots/auth_bot.py +++ b/samples/18.bot-authentication/bots/auth_bot.py @@ -21,9 +21,7 @@ def __init__( user_state: UserState, dialog: Dialog, ): - super(AuthBot, self).__init__( - conversation_state, user_state, dialog - ) + super(AuthBot, self).__init__(conversation_state, user_state, dialog) async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext @@ -32,12 +30,12 @@ async def on_members_added_activity( # Greet anyone that was not the target (recipient) of this message. # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity("Welcome to AuthenticationBot. Type anything to get logged in. Type " - "'logout' to sign-out.") + await turn_context.send_activity( + "Welcome to AuthenticationBot. Type anything to get logged in. Type " + "'logout' to sign-out." + ) - async def on_token_response_event( - self, turn_context: TurnContext - ): + async def on_token_response_event(self, turn_context: TurnContext): # Run the Dialog with the new Token Response Event Activity. await DialogHelper.run_dialog( self.dialog, diff --git a/samples/18.bot-authentication/bots/dialog_bot.py b/samples/18.bot-authentication/bots/dialog_bot.py index fc563d2ec..eb560a1be 100644 --- a/samples/18.bot-authentication/bots/dialog_bot.py +++ b/samples/18.bot-authentication/bots/dialog_bot.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import asyncio - from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext from botbuilder.dialogs import Dialog from helpers.dialog_helper import DialogHelper diff --git a/samples/18.bot-authentication/dialogs/logout_dialog.py b/samples/18.bot-authentication/dialogs/logout_dialog.py index b8d420a40..de77e5c04 100644 --- a/samples/18.bot-authentication/dialogs/logout_dialog.py +++ b/samples/18.bot-authentication/dialogs/logout_dialog.py @@ -12,7 +12,9 @@ def __init__(self, dialog_id: str, connection_name: str): self.connection_name = connection_name - async def on_begin_dialog(self, inner_dc: DialogContext, options: object) -> DialogTurnResult: + async def on_begin_dialog( + self, inner_dc: DialogContext, options: object + ) -> DialogTurnResult: return await inner_dc.begin_dialog(self.initial_dialog_id, options) async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: diff --git a/samples/18.bot-authentication/dialogs/main_dialog.py b/samples/18.bot-authentication/dialogs/main_dialog.py index 3e7a80287..964a3aff2 100644 --- a/samples/18.bot-authentication/dialogs/main_dialog.py +++ b/samples/18.bot-authentication/dialogs/main_dialog.py @@ -6,16 +6,15 @@ WaterfallDialog, WaterfallStepContext, DialogTurnResult, - PromptOptions) + PromptOptions, +) from botbuilder.dialogs.prompts import OAuthPrompt, OAuthPromptSettings, ConfirmPrompt from dialogs import LogoutDialog class MainDialog(LogoutDialog): - def __init__( - self, connection_name: str - ): + def __init__(self, connection_name: str): super(MainDialog, self).__init__(MainDialog.__name__, connection_name) self.add_dialog( @@ -25,8 +24,8 @@ def __init__( connection_name=connection_name, text="Please Sign In", title="Sign In", - timeout=300000 - ) + timeout=300000, + ), ) ) @@ -34,12 +33,13 @@ def __init__( self.add_dialog( WaterfallDialog( - "WFDialog", [ + "WFDialog", + [ self.prompt_step, self.login_step, self.display_token_phase1, - self.display_token_phase2 - ] + self.display_token_phase2, + ], ) ) @@ -53,14 +53,21 @@ async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResu # token directly from the prompt itself. There is an example of this in the next method. if step_context.result: await step_context.context.send_activity("You are now logged in.") - return await step_context.prompt(ConfirmPrompt.__name__, PromptOptions( - prompt=MessageFactory.text("Would you like to view your token?") - )) + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("Would you like to view your token?") + ), + ) - await step_context.context.send_activity("Login was not successful please try again.") + await step_context.context.send_activity( + "Login was not successful please try again." + ) return await step_context.end_dialog() - async def display_token_phase1(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def display_token_phase1( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: await step_context.context.send_activity("Thank you.") if step_context.result: @@ -76,8 +83,12 @@ async def display_token_phase1(self, step_context: WaterfallStepContext) -> Dial return await step_context.end_dialog() - async def display_token_phase2(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def display_token_phase2( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: if step_context.result: - await step_context.context.send_activity(f"Here is your token {step_context.result['token']}") + await step_context.context.send_activity( + f"Here is your token {step_context.result['token']}" + ) - return await step_context.end_dialog() \ No newline at end of file + return await step_context.end_dialog() diff --git a/samples/19.custom-dialogs/app.py b/samples/19.custom-dialogs/app.py index 880dd8a85..1c3579210 100644 --- a/samples/19.custom-dialogs/app.py +++ b/samples/19.custom-dialogs/app.py @@ -4,7 +4,6 @@ import asyncio import sys from datetime import datetime -from types import MethodType from flask import Flask, request, Response from botbuilder.core import ( @@ -33,7 +32,7 @@ # Catch-all for errors. -async def on_error(self, context: TurnContext, error: Exception): +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. @@ -41,9 +40,11 @@ async def on_error(self, context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -51,12 +52,13 @@ async def on_error(self, context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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 = MethodType(on_error, ADAPTER) + +ADAPTER.on_turn_error = on_error # Create MemoryStorage and state MEMORY = MemoryStorage() diff --git a/samples/19.custom-dialogs/bots/dialog_bot.py b/samples/19.custom-dialogs/bots/dialog_bot.py index b9648661c..2edc0dbe4 100644 --- a/samples/19.custom-dialogs/bots/dialog_bot.py +++ b/samples/19.custom-dialogs/bots/dialog_bot.py @@ -8,7 +8,12 @@ class DialogBot(ActivityHandler): - def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + def __init__( + self, + conversation_state: ConversationState, + user_state: UserState, + dialog: Dialog, + ): self.conversation_state = conversation_state self.user_state = user_state self.dialog = dialog diff --git a/samples/19.custom-dialogs/dialogs/root_dialog.py b/samples/19.custom-dialogs/dialogs/root_dialog.py index e7ab55ec8..5d371ce6a 100644 --- a/samples/19.custom-dialogs/dialogs/root_dialog.py +++ b/samples/19.custom-dialogs/dialogs/root_dialog.py @@ -2,25 +2,25 @@ # Licensed under the MIT License. from typing import Dict +from recognizers_text import Culture from botbuilder.dialogs import ( ComponentDialog, WaterfallDialog, WaterfallStepContext, DialogTurnResult, - NumberPrompt, PromptValidatorContext) + NumberPrompt, + PromptValidatorContext, +) from botbuilder.dialogs.prompts import TextPrompt from botbuilder.core import MessageFactory, UserState -from recognizers_text import Culture from dialogs import SlotFillingDialog from dialogs.slot_details import SlotDetails class RootDialog(ComponentDialog): - def __init__( - self, user_state: UserState - ): + def __init__(self, user_state: UserState): super(RootDialog, self).__init__(RootDialog.__name__) self.user_state_accessor = user_state.create_property("result") @@ -29,15 +29,11 @@ def __init__( # In this example we will want two text prompts to run, one for the first name and one for the last fullname_slots = [ SlotDetails( - name="first", - dialog_id="text", - prompt="Please enter your first name." + name="first", dialog_id="text", prompt="Please enter your first name." ), SlotDetails( - name="last", - dialog_id="text", - prompt="Please enter your last name." - ) + name="last", dialog_id="text", prompt="Please enter your last name." + ), ] # This defines an address dialog that collects street, city and zip properties. @@ -45,42 +41,26 @@ def __init__( SlotDetails( name="street", dialog_id="text", - prompt="Please enter the street address." + prompt="Please enter the street address.", ), - SlotDetails( - name="city", - dialog_id="text", - prompt="Please enter the city." - ), - SlotDetails( - name="zip", - dialog_id="text", - prompt="Please enter the zip." - ) + SlotDetails(name="city", dialog_id="text", prompt="Please enter the city."), + SlotDetails(name="zip", dialog_id="text", prompt="Please enter the zip."), ] # Dialogs can be nested and the slot filling dialog makes use of that. In this example some of the child # dialogs are slot filling dialogs themselves. slots = [ + SlotDetails(name="fullname", dialog_id="fullname",), SlotDetails( - name="fullname", - dialog_id="fullname", - ), - SlotDetails( - name="age", - dialog_id="number", - prompt="Please enter your age." + name="age", dialog_id="number", prompt="Please enter your age." ), SlotDetails( name="shoesize", dialog_id="shoesize", prompt="Please enter your shoe size.", - retry_prompt="You must enter a size between 0 and 16. Half sizes are acceptable." + retry_prompt="You must enter a size between 0 and 16. Half sizes are acceptable.", ), - SlotDetails( - name="address", - dialog_id="address" - ) + SlotDetails(name="address", dialog_id="address"), ] # Add the various dialogs that will be used to the DialogSet. @@ -88,27 +68,35 @@ def __init__( self.add_dialog(SlotFillingDialog("fullname", fullname_slots)) self.add_dialog(TextPrompt("text")) self.add_dialog(NumberPrompt("number", default_locale=Culture.English)) - self.add_dialog(NumberPrompt("shoesize", RootDialog.shoe_size_validator, default_locale=Culture.English)) + self.add_dialog( + NumberPrompt( + "shoesize", + RootDialog.shoe_size_validator, + default_locale=Culture.English, + ) + ) self.add_dialog(SlotFillingDialog("slot-dialog", slots)) # Defines a simple two step Waterfall to test the slot dialog. self.add_dialog( - WaterfallDialog( - "waterfall", [self.start_dialog, self.process_result] - ) + WaterfallDialog("waterfall", [self.start_dialog, self.process_result]) ) # The initial child Dialog to run. self.initial_dialog_id = "waterfall" - async def start_dialog(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def start_dialog( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: # Start the child dialog. This will run the top slot dialog than will complete when all the properties are # gathered. return await step_context.begin_dialog("slot-dialog") - async def process_result(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def process_result( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: # To demonstrate that the slot dialog collected all the properties we will echo them back to the user. - if type(step_context.result) is dict and len(step_context.result) > 0: + if isinstance(step_context.result, dict) and len(step_context.result) > 0: fullname: Dict[str, object] = step_context.result["fullname"] shoe_size: float = step_context.result["shoesize"] address: dict = step_context.result["address"] @@ -118,12 +106,20 @@ async def process_result(self, step_context: WaterfallStepContext) -> DialogTurn obj["data"] = {} obj["data"]["fullname"] = f"{fullname.get('first')} {fullname.get('last')}" obj["data"]["shoesize"] = f"{shoe_size}" - obj["data"]["address"] = f"{address['street']}, {address['city']}, {address['zip']}" + obj["data"][ + "address" + ] = f"{address['street']}, {address['city']}, {address['zip']}" # show user the values - await step_context.context.send_activity(MessageFactory.text(obj["data"]["fullname"])) - await step_context.context.send_activity(MessageFactory.text(obj["data"]["shoesize"])) - await step_context.context.send_activity(MessageFactory.text(obj["data"]["address"])) + await step_context.context.send_activity( + MessageFactory.text(obj["data"]["fullname"]) + ) + await step_context.context.send_activity( + MessageFactory.text(obj["data"]["shoesize"]) + ) + await step_context.context.send_activity( + MessageFactory.text(obj["data"]["address"]) + ) return await step_context.end_dialog() diff --git a/samples/19.custom-dialogs/dialogs/slot_details.py b/samples/19.custom-dialogs/dialogs/slot_details.py index 3478f8b55..172d81c67 100644 --- a/samples/19.custom-dialogs/dialogs/slot_details.py +++ b/samples/19.custom-dialogs/dialogs/slot_details.py @@ -6,16 +6,23 @@ class SlotDetails: - def __init__(self, - name: str, - dialog_id: str, - options: PromptOptions = None, - prompt: str = None, - retry_prompt: str = None - ): + def __init__( + self, + name: str, + dialog_id: str, + options: PromptOptions = None, + prompt: str = None, + retry_prompt: str = None, + ): self.name = name self.dialog_id = dialog_id - self.options = options if options else PromptOptions( - prompt=MessageFactory.text(prompt), - retry_prompt=None if retry_prompt is None else MessageFactory.text(retry_prompt) + self.options = ( + options + if options + else PromptOptions( + prompt=MessageFactory.text(prompt), + retry_prompt=None + if retry_prompt is None + else MessageFactory.text(retry_prompt), + ) ) diff --git a/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py index 7f7043055..6e354431a 100644 --- a/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py +++ b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py @@ -6,21 +6,24 @@ from botbuilder.dialogs import ( DialogContext, DialogTurnResult, - Dialog, DialogInstance, DialogReason) + Dialog, + DialogInstance, + DialogReason, +) from botbuilder.schema import ActivityTypes from dialogs.slot_details import SlotDetails -""" -This is an example of implementing a custom Dialog class. This is similar to the Waterfall dialog in the -framework; however, it is based on a Dictionary rather than a sequential set of functions. The dialog is defined by a -list of 'slots', each slot represents a property we want to gather and the dialog we will be using to collect it. -Often the property is simply an atomic piece of data such as a number or a date. But sometimes the property is itself -a complex object, in which case we can use the slot dialog to collect that compound property. -""" - class SlotFillingDialog(Dialog): + """ + This is an example of implementing a custom Dialog class. This is similar to the Waterfall dialog in the + framework; however, it is based on a Dictionary rather than a sequential set of functions. The dialog is defined + by a list of 'slots', each slot represents a property we want to gather and the dialog we will be using to + collect it. Often the property is simply an atomic piece of data such as a number or a date. But sometimes the + property is itself a complex object, in which case we can use the slot dialog to collect that compound property. + """ + def __init__(self, dialog_id: str, slots: List[SlotDetails]): super(SlotFillingDialog, self).__init__(dialog_id) @@ -34,17 +37,21 @@ def __init__(self, dialog_id: str, slots: List[SlotDetails]): # The list of slots defines the properties to collect and the dialogs to use to collect them. self.slots = slots - async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + async def begin_dialog( + self, dialog_context: "DialogContext", options: object = None + ): if dialog_context.context.activity.type != ActivityTypes.message: return await dialog_context.end_dialog({}) return await self._run_prompt(dialog_context) - async def continue_dialog(self, dialog_context: DialogContext, options: object = None): + async def continue_dialog(self, dialog_context: "DialogContext"): if dialog_context.context.activity.type != ActivityTypes.message: return Dialog.end_of_turn return await self._run_prompt(dialog_context) - async def resume_dialog(self, dialog_context: DialogContext, reason: DialogReason, result: object): + async def resume_dialog( + self, dialog_context: DialogContext, reason: DialogReason, result: object + ): slot_name = dialog_context.active_dialog.state[self.SLOT_NAME] values = self._get_persisted_values(dialog_context.active_dialog) values[slot_name] = result @@ -74,12 +81,16 @@ async def _run_prompt(self, dialog_context: DialogContext) -> DialogTurnResult: dialog_context.active_dialog.state[self.SLOT_NAME] = unfilled_slot.name # Run the child dialog - return await dialog_context.begin_dialog(unfilled_slot.dialog_id, unfilled_slot.options) - else: - # No more slots to fill so end the dialog. - return await dialog_context.end_dialog(state) + return await dialog_context.begin_dialog( + unfilled_slot.dialog_id, unfilled_slot.options + ) + + # No more slots to fill so end the dialog. + return await dialog_context.end_dialog(state) - def _get_persisted_values(self, dialog_instance: DialogInstance) -> Dict[str, object]: + def _get_persisted_values( + self, dialog_instance: DialogInstance + ) -> Dict[str, object]: obj = dialog_instance.state.get(self.PERSISTED_VALUES) if not obj: diff --git a/samples/21.corebot-app-insights/app.py b/samples/21.corebot-app-insights/app.py index 5011c21f9..91d2f29af 100644 --- a/samples/21.corebot-app-insights/app.py +++ b/samples/21.corebot-app-insights/app.py @@ -52,9 +52,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -62,7 +64,7 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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) @@ -70,6 +72,7 @@ async def on_error(context: TurnContext, error: Exception): # Clear out state await CONVERSATION_STATE.delete(context) + # Set the error handler on the Adapter. # In this case, we want an unbound method, so MethodType is not needed. ADAPTER.on_turn_error = on_error diff --git a/samples/21.corebot-app-insights/bots/dialog_bot.py b/samples/21.corebot-app-insights/bots/dialog_bot.py index 3b55ba7a7..8c9322bc9 100644 --- a/samples/21.corebot-app-insights/bots/dialog_bot.py +++ b/samples/21.corebot-app-insights/bots/dialog_bot.py @@ -36,9 +36,6 @@ def __init__( self.conversation_state = conversation_state self.user_state = user_state self.dialog = dialog - self.dialogState = self.conversation_state.create_property( - "DialogState" - ) # pylint:disable=invalid-name self.telemetry_client = telemetry_client async def on_turn(self, turn_context: TurnContext): @@ -49,7 +46,6 @@ async def on_turn(self, turn_context: TurnContext): await self.user_state.save_changes(turn_context, False) async def on_message_activity(self, turn_context: TurnContext): - # pylint:disable=invalid-name await DialogHelper.run_dialog( self.dialog, turn_context, diff --git a/samples/21.corebot-app-insights/config.py b/samples/21.corebot-app-insights/config.py index 339154fc0..b3c87e304 100644 --- a/samples/21.corebot-app-insights/config.py +++ b/samples/21.corebot-app-insights/config.py @@ -16,4 +16,6 @@ class DefaultConfig: LUIS_API_KEY = os.environ.get("LuisAPIKey", "") # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") - APPINSIGHTS_INSTRUMENTATION_KEY = os.environ.get("AppInsightsInstrumentationKey", "") + APPINSIGHTS_INSTRUMENTATION_KEY = os.environ.get( + "AppInsightsInstrumentationKey", "" + ) diff --git a/samples/21.corebot-app-insights/dialogs/booking_dialog.py b/samples/21.corebot-app-insights/dialogs/booking_dialog.py index 139d146fc..ab9b341b8 100644 --- a/samples/21.corebot-app-insights/dialogs/booking_dialog.py +++ b/samples/21.corebot-app-insights/dialogs/booking_dialog.py @@ -2,10 +2,11 @@ # Licensed under the MIT License. """Flight booking dialog.""" +from datatypes_date_time.timex import Timex + from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions from botbuilder.core import MessageFactory, BotTelemetryClient, NullTelemetryClient -from datatypes_date_time.timex import Timex from .cancel_and_help_dialog import CancelAndHelpDialog from .date_resolver_dialog import DateResolverDialog @@ -59,8 +60,8 @@ async def destination_step( prompt=MessageFactory.text("To what city would you like to travel?") ), ) # pylint: disable=line-too-long,bad-continuation - else: - return await step_context.next(booking_details.destination) + + return await step_context.next(booking_details.destination) async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: """Prompt for origin city.""" @@ -75,8 +76,8 @@ async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnRes prompt=MessageFactory.text("From what city will you be travelling?") ), ) # pylint: disable=line-too-long,bad-continuation - else: - return await step_context.next(booking_details.origin) + + return await step_context.next(booking_details.origin) async def travel_date_step( self, step_context: WaterfallStepContext @@ -94,8 +95,8 @@ async def travel_date_step( return await step_context.begin_dialog( DateResolverDialog.__name__, booking_details.travel_date ) # pylint: disable=line-too-long - else: - return await step_context.next(booking_details.travel_date) + + return await step_context.next(booking_details.travel_date) async def confirm_step( self, step_context: WaterfallStepContext @@ -122,8 +123,8 @@ async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResu booking_details.travel_date = step_context.result return await step_context.end_dialog(booking_details) - else: - return await step_context.end_dialog() + + return await step_context.end_dialog() def is_ambiguous(self, timex: str) -> bool: """Ensure time is correct.""" diff --git a/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py b/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py index 2a73c669a..4dab4dbe4 100644 --- a/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py +++ b/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py @@ -44,11 +44,11 @@ async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: if inner_dc.context.activity.type == ActivityTypes.message: text = inner_dc.context.activity.text.lower() - if text == "help" or text == "?": + if text in ("help", "?"): await inner_dc.context.send_activity("Show Help...") return DialogTurnResult(DialogTurnStatus.Waiting) - if text == "cancel" or text == "quit": + if text in ("cancel", "quit"): await inner_dc.context.send_activity("Cancelling") return await inner_dc.cancel_all_dialogs() diff --git a/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py b/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py index f64a27955..baa5224ac 100644 --- a/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py +++ b/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. """Handle date/time resolution for booking dialog.""" + +from datatypes_date_time.timex import Timex + from botbuilder.core import MessageFactory, BotTelemetryClient, NullTelemetryClient from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext from botbuilder.dialogs.prompts import ( @@ -9,7 +12,6 @@ PromptOptions, DateTimeResolution, ) -from datatypes_date_time.timex import Timex from .cancel_and_help_dialog import CancelAndHelpDialog @@ -62,15 +64,15 @@ async def initial_step( retry_prompt=MessageFactory.text(reprompt_msg), ), ) - else: - # We have a Date we just need to check it is unambiguous. - if "definite" in Timex(timex).types: - # This is essentially a "reprompt" of the data we were given up front. - return await step_context.prompt( - DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) - ) - else: - return await step_context.next(DateTimeResolution(timex=timex)) + + # We have a Date we just need to check it is unambiguous. + if "definite" in Timex(timex).types: + # This is essentially a "reprompt" of the data we were given up front. + return await step_context.prompt( + DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) + ) + + return await step_context.next(DateTimeResolution(timex=timex)) async def final_step(self, step_context: WaterfallStepContext): """Cleanup - set final return value and end dialog.""" diff --git a/samples/21.corebot-app-insights/dialogs/main_dialog.py b/samples/21.corebot-app-insights/dialogs/main_dialog.py index e4807ce8a..6e70deadd 100644 --- a/samples/21.corebot-app-insights/dialogs/main_dialog.py +++ b/samples/21.corebot-app-insights/dialogs/main_dialog.py @@ -63,13 +63,13 @@ async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResu ) return await step_context.next(None) - else: - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("What can I help you with today?") - ), - ) # pylint: disable=bad-continuation + + return await step_context.prompt( + TextPrompt.__name__, + PromptOptions( + prompt=MessageFactory.text("What can I help you with today?") + ), + ) async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: """Use language understanding to gather details about booking.""" diff --git a/samples/21.corebot-app-insights/helpers/luis_helper.py b/samples/21.corebot-app-insights/helpers/luis_helper.py index e244940c4..81e28a032 100644 --- a/samples/21.corebot-app-insights/helpers/luis_helper.py +++ b/samples/21.corebot-app-insights/helpers/luis_helper.py @@ -63,8 +63,8 @@ async def execute_luis_query( ) if date_entities: booking_details.travel_date = ( - None - ) # Set when we get a timex format + None # Set when we get a timex format + ) except Exception as exception: print(exception) diff --git a/samples/43.complex-dialog/app.py b/samples/43.complex-dialog/app.py index 9610c3e3c..f18a309d1 100644 --- a/samples/43.complex-dialog/app.py +++ b/samples/43.complex-dialog/app.py @@ -40,9 +40,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -50,14 +52,15 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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) - + # Clear out state await CONVERSATION_STATE.delete(context) + # Set the error handler on the Adapter. # In this case, we want an unbound function, so MethodType is not needed. ADAPTER.on_turn_error = on_error diff --git a/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py b/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py index 2c2d91d92..68c3c9a30 100644 --- a/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py +++ b/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py @@ -31,7 +31,9 @@ async def on_members_added_activity( for member in members_added: # Greet anyone that was not the target (recipient) of this message. if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity(MessageFactory.text( - f"Welcome to Complex Dialog Bot {member.name}. This bot provides a complex conversation, with " - f"multiple dialogs. Type anything to get started. ") + await turn_context.send_activity( + MessageFactory.text( + f"Welcome to Complex Dialog Bot {member.name}. This bot provides a complex conversation, with " + f"multiple dialogs. Type anything to get started. " + ) ) diff --git a/samples/43.complex-dialog/data_models/user_profile.py b/samples/43.complex-dialog/data_models/user_profile.py index 4ceb9a639..0267721d4 100644 --- a/samples/43.complex-dialog/data_models/user_profile.py +++ b/samples/43.complex-dialog/data_models/user_profile.py @@ -5,7 +5,9 @@ class UserProfile: - def __init__(self, name: str = None, age: int = 0, companies_to_review: List[str] = None): + def __init__( + self, name: str = None, age: int = 0, companies_to_review: List[str] = None + ): self.name: str = name self.age: int = age self.companies_to_review: List[str] = companies_to_review diff --git a/samples/43.complex-dialog/dialogs/main_dialog.py b/samples/43.complex-dialog/dialogs/main_dialog.py index 9fd04ce05..8b3fcd82d 100644 --- a/samples/43.complex-dialog/dialogs/main_dialog.py +++ b/samples/43.complex-dialog/dialogs/main_dialog.py @@ -14,32 +14,31 @@ class MainDialog(ComponentDialog): - def __init__( - self, user_state: UserState - ): + def __init__(self, user_state: UserState): super(MainDialog, self).__init__(MainDialog.__name__) self.user_state = user_state self.add_dialog(TopLevelDialog(TopLevelDialog.__name__)) self.add_dialog( - WaterfallDialog( - "WFDialog", [ - self.initial_step, - self.final_step - ] - ) + WaterfallDialog("WFDialog", [self.initial_step, self.final_step]) ) self.initial_dialog_id = "WFDialog" - async def initial_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def initial_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: return await step_context.begin_dialog(TopLevelDialog.__name__) async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: user_info: UserProfile = step_context.result - companies = "no companies" if len(user_info.companies_to_review) == 0 else " and ".join(user_info.companies_to_review) + companies = ( + "no companies" + if len(user_info.companies_to_review) == 0 + else " and ".join(user_info.companies_to_review) + ) status = f"You are signed up to review {companies}." await step_context.context.send_activity(MessageFactory.text(status)) @@ -49,4 +48,3 @@ async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResu await accessor.set(step_context.context, user_info) return await step_context.end_dialog() - diff --git a/samples/43.complex-dialog/dialogs/review_selection_dialog.py b/samples/43.complex-dialog/dialogs/review_selection_dialog.py index 1e6f0c747..2119068bb 100644 --- a/samples/43.complex-dialog/dialogs/review_selection_dialog.py +++ b/samples/43.complex-dialog/dialogs/review_selection_dialog.py @@ -3,7 +3,12 @@ from typing import List -from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult, ComponentDialog +from botbuilder.dialogs import ( + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + ComponentDialog, +) from botbuilder.dialogs.prompts import ChoicePrompt, PromptOptions from botbuilder.dialogs.choices import Choice, FoundChoice from botbuilder.core import MessageFactory @@ -11,40 +16,49 @@ class ReviewSelectionDialog(ComponentDialog): def __init__(self, dialog_id: str = None): - super(ReviewSelectionDialog, self).__init__(dialog_id or ReviewSelectionDialog.__name__) + super(ReviewSelectionDialog, self).__init__( + dialog_id or ReviewSelectionDialog.__name__ + ) self.COMPANIES_SELECTED = "value-companiesSelected" self.DONE_OPTION = "done" - self.company_options = ["Adatum Corporation", - "Contoso Suites", - "Graphic Design Institute", - "Wide World Importers"] + self.company_options = [ + "Adatum Corporation", + "Contoso Suites", + "Graphic Design Institute", + "Wide World Importers", + ] self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) self.add_dialog( WaterfallDialog( - WaterfallDialog.__name__, [ - self.selection_step, - self.loop_step - ] + WaterfallDialog.__name__, [self.selection_step, self.loop_step] ) ) self.initial_dialog_id = WaterfallDialog.__name__ - async def selection_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def selection_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: # step_context.options will contains the value passed in begin_dialog or replace_dialog. # if this value wasn't provided then start with an emtpy selection list. This list will # eventually be returned to the parent via end_dialog. - selected: [str] = step_context.options if step_context.options is not None else [] + selected: [ + str + ] = step_context.options if step_context.options is not None else [] step_context.values[self.COMPANIES_SELECTED] = selected if len(selected) == 0: - message = f"Please choose a company to review, or `{self.DONE_OPTION}` to finish." + message = ( + f"Please choose a company to review, or `{self.DONE_OPTION}` to finish." + ) else: - message = f"You have selected **{selected[0]}**. You can review an additional company, "\ - f"or choose `{self.DONE_OPTION}` to finish. " + message = ( + f"You have selected **{selected[0]}**. You can review an additional company, " + f"or choose `{self.DONE_OPTION}` to finish. " + ) # create a list of options to choose, with already selected items removed. options = self.company_options.copy() @@ -56,14 +70,14 @@ async def selection_step(self, step_context: WaterfallStepContext) -> DialogTurn prompt_options = PromptOptions( prompt=MessageFactory.text(message), retry_prompt=MessageFactory.text("Please choose an option from the list."), - choices=self._to_choices(options) + choices=self._to_choices(options), ) return await step_context.prompt(ChoicePrompt.__name__, prompt_options) def _to_choices(self, choices: [str]) -> List[Choice]: choice_list: List[Choice] = [] - for c in choices: - choice_list.append(Choice(value=c)) + for choice in choices: + choice_list.append(Choice(value=choice)) return choice_list async def loop_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: @@ -80,4 +94,6 @@ async def loop_step(self, step_context: WaterfallStepContext) -> DialogTurnResul return await step_context.end_dialog(selected) # Otherwise, repeat this dialog, passing in the selections from this iteration. - return await step_context.replace_dialog(ReviewSelectionDialog.__name__, selected) + return await step_context.replace_dialog( + ReviewSelectionDialog.__name__, selected + ) diff --git a/samples/43.complex-dialog/dialogs/top_level_dialog.py b/samples/43.complex-dialog/dialogs/top_level_dialog.py index 96992e080..4342e668f 100644 --- a/samples/43.complex-dialog/dialogs/top_level_dialog.py +++ b/samples/43.complex-dialog/dialogs/top_level_dialog.py @@ -6,13 +6,9 @@ WaterfallDialog, DialogTurnResult, WaterfallStepContext, - ComponentDialog -) -from botbuilder.dialogs.prompts import ( - PromptOptions, - TextPrompt, - NumberPrompt + ComponentDialog, ) +from botbuilder.dialogs.prompts import PromptOptions, TextPrompt, NumberPrompt from data_models import UserProfile from dialogs.review_selection_dialog import ReviewSelectionDialog @@ -20,9 +16,7 @@ class TopLevelDialog(ComponentDialog): def __init__(self, dialog_id: str = None): - super(TopLevelDialog, self).__init__( - dialog_id or TopLevelDialog.__name__ - ) + super(TopLevelDialog, self).__init__(dialog_id or TopLevelDialog.__name__) # Key name to store this dialogs state info in the StepContext self.USER_INFO = "value-userInfo" @@ -34,12 +28,13 @@ def __init__(self, dialog_id: str = None): self.add_dialog( WaterfallDialog( - "WFDialog", [ + "WFDialog", + [ self.name_step, self.age_step, self.start_selection_step, - self.acknowledgement_step - ] + self.acknowledgement_step, + ], ) ) @@ -66,23 +61,27 @@ async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult ) return await step_context.prompt(NumberPrompt.__name__, prompt_options) - async def start_selection_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + async def start_selection_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: # Set the user's age to what they entered in response to the age prompt. user_profile: UserProfile = step_context.values[self.USER_INFO] user_profile.age = step_context.result if user_profile.age < 25: # If they are too young, skip the review selection dialog, and pass an empty list to the next step. - await step_context.context.send_activity(MessageFactory.text( - "You must be 25 or older to participate.") + await step_context.context.send_activity( + MessageFactory.text("You must be 25 or older to participate.") ) return await step_context.next([]) - else: - # Otherwise, start the review selection dialog. - return await step_context.begin_dialog(ReviewSelectionDialog.__name__) - async def acknowledgement_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Otherwise, start the review selection dialog. + return await step_context.begin_dialog(ReviewSelectionDialog.__name__) + + async def acknowledgement_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: # Set the user's company selection to what they entered in the review-selection dialog. user_profile: UserProfile = step_context.values[self.USER_INFO] user_profile.companies_to_review = step_context.result diff --git a/samples/44.prompt-users-for-input/app.py b/samples/44.prompt-users-for-input/app.py index 79b861b59..34633b1fe 100644 --- a/samples/44.prompt-users-for-input/app.py +++ b/samples/44.prompt-users-for-input/app.py @@ -38,9 +38,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -48,14 +50,15 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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) - + # Clear out state await CONVERSATION_STATE.delete(context) + # Set the error handler on the Adapter. # In this case, we want an unbound method, so MethodType is not needed. ADAPTER.on_turn_error = on_error diff --git a/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py b/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py index 67cecfc54..693eee92a 100644 --- a/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py +++ b/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py @@ -3,15 +3,24 @@ from datetime import datetime -from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState, MessageFactory from recognizers_number import recognize_number, Culture from recognizers_date_time import recognize_datetime +from botbuilder.core import ( + ActivityHandler, + ConversationState, + TurnContext, + UserState, + MessageFactory, +) + from data_models import ConversationFlow, Question, UserProfile class ValidationResult: - def __init__(self, is_valid: bool = False, value: object = None, message: str = None): + def __init__( + self, is_valid: bool = False, value: object = None, message: str = None + ): self.is_valid = is_valid self.value = value self.message = message @@ -45,57 +54,84 @@ async def on_message_activity(self, turn_context: TurnContext): await self.conversation_state.save_changes(turn_context) await self.user_state.save_changes(turn_context) - async def _fill_out_user_profile(self, flow: ConversationFlow, profile: UserProfile, turn_context: TurnContext): + async def _fill_out_user_profile( + self, flow: ConversationFlow, profile: UserProfile, turn_context: TurnContext + ): user_input = turn_context.activity.text.strip() # ask for name if flow.last_question_asked == Question.NONE: - await turn_context.send_activity(MessageFactory.text("Let's get started. What is your name?")) + await turn_context.send_activity( + MessageFactory.text("Let's get started. What is your name?") + ) flow.last_question_asked = Question.NAME # validate name then ask for age elif flow.last_question_asked == Question.NAME: validate_result = self._validate_name(user_input) if not validate_result.is_valid: - await turn_context.send_activity(MessageFactory.text(validate_result.message)) + await turn_context.send_activity( + MessageFactory.text(validate_result.message) + ) else: profile.name = validate_result.value - await turn_context.send_activity(MessageFactory.text(f"Hi {profile.name}")) - await turn_context.send_activity(MessageFactory.text("How old are you?")) + await turn_context.send_activity( + MessageFactory.text(f"Hi {profile.name}") + ) + await turn_context.send_activity( + MessageFactory.text("How old are you?") + ) flow.last_question_asked = Question.AGE # validate age then ask for date elif flow.last_question_asked == Question.AGE: validate_result = self._validate_age(user_input) if not validate_result.is_valid: - await turn_context.send_activity(MessageFactory.text(validate_result.message)) + await turn_context.send_activity( + MessageFactory.text(validate_result.message) + ) else: profile.age = validate_result.value - await turn_context.send_activity(MessageFactory.text(f"I have your age as {profile.age}.")) - await turn_context.send_activity(MessageFactory.text("When is your flight?")) + await turn_context.send_activity( + MessageFactory.text(f"I have your age as {profile.age}.") + ) + await turn_context.send_activity( + MessageFactory.text("When is your flight?") + ) flow.last_question_asked = Question.DATE # validate date and wrap it up elif flow.last_question_asked == Question.DATE: validate_result = self._validate_date(user_input) if not validate_result.is_valid: - await turn_context.send_activity(MessageFactory.text(validate_result.message)) + await turn_context.send_activity( + MessageFactory.text(validate_result.message) + ) else: profile.date = validate_result.value - await turn_context.send_activity(MessageFactory.text( - f"Your cab ride to the airport is scheduled for {profile.date}.") + await turn_context.send_activity( + MessageFactory.text( + f"Your cab ride to the airport is scheduled for {profile.date}." + ) + ) + await turn_context.send_activity( + MessageFactory.text( + f"Thanks for completing the booking {profile.name}." + ) ) - await turn_context.send_activity(MessageFactory.text( - f"Thanks for completing the booking {profile.name}.") + await turn_context.send_activity( + MessageFactory.text("Type anything to run the bot again.") ) - await turn_context.send_activity(MessageFactory.text("Type anything to run the bot again.")) flow.last_question_asked = Question.NONE def _validate_name(self, user_input: str) -> ValidationResult: if not user_input: - return ValidationResult(is_valid=False, message="Please enter a name that contains at least one character.") - else: - return ValidationResult(is_valid=True, value=user_input) + return ValidationResult( + is_valid=False, + message="Please enter a name that contains at least one character.", + ) + + return ValidationResult(is_valid=True, value=user_input) def _validate_age(self, user_input: str) -> ValidationResult: # Attempt to convert the Recognizer result to an integer. This works for "a dozen", "twelve", "12", and so on. @@ -107,7 +143,9 @@ def _validate_age(self, user_input: str) -> ValidationResult: if 18 <= age <= 120: return ValidationResult(is_valid=True, value=age) - return ValidationResult(is_valid=False, message="Please enter an age between 18 and 120.") + return ValidationResult( + is_valid=False, message="Please enter an age between 18 and 120." + ) def _validate_date(self, user_input: str) -> ValidationResult: try: @@ -125,16 +163,27 @@ def _validate_date(self, user_input: str) -> ValidationResult: candidate = datetime.strptime(value, "%Y-%m-%d") elif resolution["type"] == "time": candidate = datetime.strptime(value, "%H:%M:%S") - candidate = candidate.replace(year=now.year, month=now.month, day=now.day) + candidate = candidate.replace( + year=now.year, month=now.month, day=now.day + ) else: candidate = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") # user response must be more than an hour out diff = candidate - now if diff.total_seconds() >= 3600: - return ValidationResult(is_valid=True, value=candidate.strftime("%m/%d/%y @ %H:%M")) - - return ValidationResult(is_valid=False, message="I'm sorry, please enter a date at least an hour out.") + return ValidationResult( + is_valid=True, + value=candidate.strftime("%m/%d/%y @ %H:%M"), + ) + + return ValidationResult( + is_valid=False, + message="I'm sorry, please enter a date at least an hour out.", + ) except ValueError: - return ValidationResult(is_valid=False, message="I'm sorry, I could not interpret that as an appropriate " - "date. Please enter a date at least an hour out.") + return ValidationResult( + is_valid=False, + message="I'm sorry, I could not interpret that as an appropriate " + "date. Please enter a date at least an hour out.", + ) diff --git a/samples/44.prompt-users-for-input/data_models/conversation_flow.py b/samples/44.prompt-users-for-input/data_models/conversation_flow.py index f40732419..f848db64f 100644 --- a/samples/44.prompt-users-for-input/data_models/conversation_flow.py +++ b/samples/44.prompt-users-for-input/data_models/conversation_flow.py @@ -13,7 +13,6 @@ class Question(Enum): class ConversationFlow: def __init__( - self, - last_question_asked: Question = Question.NONE, + self, last_question_asked: Question = Question.NONE, ): self.last_question_asked = last_question_asked diff --git a/samples/47.inspection/app.py b/samples/47.inspection/app.py index 4e4bef778..c699450c5 100644 --- a/samples/47.inspection/app.py +++ b/samples/47.inspection/app.py @@ -4,20 +4,20 @@ import asyncio import sys from datetime import datetime -from types import MethodType -from botbuilder.core.inspection import InspectionMiddleware, InspectionState -from botframework.connector.auth import MicrosoftAppCredentials from flask import Flask, request, Response + from botbuilder.core import ( BotFrameworkAdapter, BotFrameworkAdapterSettings, ConversationState, MemoryStorage, TurnContext, - UserState, + UserState ) +from botbuilder.core.inspection import InspectionMiddleware, InspectionState from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import MicrosoftAppCredentials from bots import EchoBot @@ -41,9 +41,11 @@ async def on_error(context: TurnContext, error: Exception): # 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.") + 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': + if context.activity.channel_id == "emulator": # Create a trace activity that contains the error object trace_activity = Activity( label="TurnError", @@ -51,14 +53,15 @@ async def on_error(context: TurnContext, error: Exception): timestamp=datetime.utcnow(), type=ActivityTypes.trace, value=f"{error}", - value_type="https://www.botframework.com/schemas/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) - + # Clear out state await CONVERSATION_STATE.delete(context) + # Set the error handler on the Adapter. # In this case, we want an unbound method, so MethodType is not needed. ADAPTER.on_turn_error = on_error @@ -74,9 +77,8 @@ async def on_error(context: TurnContext, error: Exception): user_state=USER_STATE, conversation_state=CONVERSATION_STATE, credentials=MicrosoftAppCredentials( - app_id=APP.config["APP_ID"], - password=APP.config["APP_PASSWORD"] - ) + app_id=APP.config["APP_ID"], password=APP.config["APP_PASSWORD"] + ), ) ADAPTER.use(INSPECTION_MIDDLEWARE) diff --git a/samples/47.inspection/bots/echo_bot.py b/samples/47.inspection/bots/echo_bot.py index fe4d8d099..21a99aa9d 100644 --- a/samples/47.inspection/bots/echo_bot.py +++ b/samples/47.inspection/bots/echo_bot.py @@ -1,7 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState, MessageFactory +from botbuilder.core import ( + ActivityHandler, + ConversationState, + TurnContext, + UserState, + MessageFactory, +) from botbuilder.schema import ChannelAccount from data_models import CustomState @@ -21,7 +27,9 @@ def __init__(self, conversation_state: ConversationState, user_state: UserState) self.conversation_state = conversation_state self.user_state = user_state - self.conversation_state_accessor = self.conversation_state.create_property("CustomState") + self.conversation_state_accessor = self.conversation_state.create_property( + "CustomState" + ) self.user_state_accessor = self.user_state.create_property("CustomState") async def on_turn(self, turn_context: TurnContext): @@ -30,7 +38,9 @@ async def on_turn(self, turn_context: TurnContext): await self.conversation_state.save_changes(turn_context) await self.user_state.save_changes(turn_context) - async def on_members_added_activity(self, members_added: [ChannelAccount], turn_context: TurnContext): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): for member in members_added: if member.id != turn_context.activity.recipient.id: await turn_context.send_activity("Hello and welcome!") @@ -38,13 +48,17 @@ async def on_members_added_activity(self, members_added: [ChannelAccount], turn_ async def on_message_activity(self, turn_context: TurnContext): # Get the state properties from the turn context. user_data = await self.user_state_accessor.get(turn_context, CustomState) - conversation_data = await self.conversation_state_accessor.get(turn_context, CustomState) + conversation_data = await self.conversation_state_accessor.get( + turn_context, CustomState + ) - await turn_context.send_activity(MessageFactory.text( - f"Echo: {turn_context.activity.text}, " - f"conversation state: {conversation_data.value}, " - f"user state: {user_data.value}")) + await turn_context.send_activity( + MessageFactory.text( + f"Echo: {turn_context.activity.text}, " + f"conversation state: {conversation_data.value}, " + f"user state: {user_data.value}" + ) + ) user_data.value = user_data.value + 1 conversation_data.value = conversation_data.value + 1 - From f7ae13315406f1580b942d462c3d789518ef8646 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 12 Nov 2019 13:11:20 -0600 Subject: [PATCH 041/616] Fixes #425: Using incorrect BotState (#426) --- samples/45.state-management/bots/state_management_bot.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/samples/45.state-management/bots/state_management_bot.py b/samples/45.state-management/bots/state_management_bot.py index 40e2640ee..47b8b21f8 100644 --- a/samples/45.state-management/bots/state_management_bot.py +++ b/samples/45.state-management/bots/state_management_bot.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. import time -import pytz from datetime import datetime from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState @@ -25,10 +24,10 @@ def __init__(self, conversation_state: ConversationState, user_state: UserState) self.conversation_state = conversation_state self.user_state = user_state - self.conversation_data = self.conversation_state.create_property( + self.conversation_data_accessor = self.conversation_state.create_property( "ConversationData" ) - self.user_profile = self.conversation_state.create_property("UserProfile") + self.user_profile_accessor = self.user_state.create_property("UserProfile") async def on_turn(self, turn_context: TurnContext): await super().on_turn(turn_context) @@ -47,8 +46,8 @@ async def on_members_added_activity( async def on_message_activity(self, turn_context: TurnContext): # Get the state properties from the turn context. - user_profile = await self.user_profile.get(turn_context, UserProfile) - conversation_data = await self.conversation_data.get( + user_profile = await self.user_profile_accessor.get(turn_context, UserProfile) + conversation_data = await self.conversation_data_accessor.get( turn_context, ConversationData ) From 0986575b60a41f952d87ae4c082ca2d1b55d149f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 13 Nov 2019 18:13:18 -0800 Subject: [PATCH 042/616] Added send_activities and updated the logic --- .../botbuilder-ai/tests/luis/null_adapter.py | 4 +- .../botbuilder/core/adapters/test_adapter.py | 4 +- .../botbuilder/core/bot_adapter.py | 6 +- .../botbuilder/core/bot_framework_adapter.py | 46 +++++++++++---- .../botbuilder/core/transcript_logger.py | 25 +++++++- .../botbuilder/core/turn_context.py | 57 +++++++++++++------ .../botbuilder-core/tests/simple_adapter.py | 4 +- .../tests/test_activity_handler.py | 5 +- .../botbuilder-core/tests/test_bot_adapter.py | 2 +- .../tests/test_turn_context.py | 4 +- .../adapter/console_adapter.py | 2 +- 11 files changed, 120 insertions(+), 39 deletions(-) diff --git a/libraries/botbuilder-ai/tests/luis/null_adapter.py b/libraries/botbuilder-ai/tests/luis/null_adapter.py index 8c8835c14..7e1ed3051 100644 --- a/libraries/botbuilder-ai/tests/luis/null_adapter.py +++ b/libraries/botbuilder-ai/tests/luis/null_adapter.py @@ -12,7 +12,9 @@ class NullAdapter(BotAdapter): This is a BotAdapter that does nothing on the Send operation, equivalent to piping to /dev/null. """ - async def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: return [ResourceResponse()] async def update_activity(self, context: TurnContext, activity: Activity): diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 41bcb3e50..54095b1eb 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -114,7 +114,9 @@ async def process_activity( activity.timestamp = activity.timestamp or datetime.utcnow() await self.run_pipeline(TurnContext(self, activity), logic) - async def send_activities(self, context, activities: List[Activity]): + async def send_activities( + self, context, activities: List[Activity] + ) -> List[ResourceResponse]: """ INTERNAL: called by the logic under test to send a set of activities. These will be buffered to the current `TestFlow` instance for comparison against the expected results. diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 7452625c4..af893d3ed 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import List, Callable, Awaitable -from botbuilder.schema import Activity, ConversationReference +from botbuilder.schema import Activity, ConversationReference, ResourceResponse from . import conversation_reference_extension from .bot_assert import BotAssert @@ -19,7 +19,9 @@ def __init__( self.on_turn_error = on_turn_error @abstractmethod - async def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: """ Sends a set of activities to the user. An array of responses from the server will be returned. :param context: diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index aaac1119b..5a38be990 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -12,6 +12,7 @@ ConversationAccount, ConversationParameters, ConversationReference, + ResourceResponse, TokenResponse, ) from botframework.connector import Channels, EmulatorApiClient @@ -330,9 +331,13 @@ async def delete_activity( except Exception as error: raise error - async def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: try: + responses: List[ResourceResponse] = [] for activity in activities: + response: ResourceResponse = None if activity.type == "delay": try: delay_in_ms = float(activity.value) / 1000 @@ -345,17 +350,38 @@ async def send_activities(self, context: TurnContext, activities: List[Activity] else: await asyncio.sleep(delay_in_ms) elif activity.type == "invokeResponse": - context.turn_state.add(self._INVOKE_RESPONSE_KEY) - elif activity.reply_to_id: - client = self.create_connector_client(activity.service_url) - await client.conversations.reply_to_activity( - activity.conversation.id, activity.reply_to_id, activity - ) + context.turn_state[self._INVOKE_RESPONSE_KEY] = activity else: + if not getattr(activity, "service_url", None): + raise TypeError( + "BotFrameworkAdapter.send_activity(): service_url can not be None." + ) + if ( + not hasattr(activity, "conversation") + or not activity.conversation + or not getattr(activity.conversation, "id", None) + ): + raise TypeError( + "BotFrameworkAdapter.send_activity(): conversation.id can not be None." + ) + client = self.create_connector_client(activity.service_url) - await client.conversations.send_to_conversation( - activity.conversation.id, activity - ) + if activity.type == "trace" and activity.channel_id != "emulator": + pass + elif activity.reply_to_id: + response = await client.conversations.reply_to_activity( + activity.conversation.id, activity.reply_to_id, activity + ) + else: + response = await client.conversations.send_to_conversation( + activity.conversation.id, activity + ) + + if not response: + response = ResourceResponse(activity.id or "") + + responses.append(response) + return responses except Exception as error: raise error diff --git a/libraries/botbuilder-core/botbuilder/core/transcript_logger.py b/libraries/botbuilder-core/botbuilder/core/transcript_logger.py index ef3918145..a35d18d75 100644 --- a/libraries/botbuilder-core/botbuilder/core/transcript_logger.py +++ b/libraries/botbuilder-core/botbuilder/core/transcript_logger.py @@ -4,6 +4,8 @@ import datetime import copy +import random +import string from queue import Queue from abc import ABC, abstractmethod from typing import Awaitable, Callable, List @@ -57,8 +59,27 @@ async def send_activities_handler( ): # Run full pipeline responses = await next_send() - for activity in activities: - self.log_activity(transcript, copy.copy(activity)) + for index, activity in enumerate(activities): + cloned_activity = copy.copy(activity) + if index < len(responses): + cloned_activity.id = responses[index].id + + # For certain channels, a ResourceResponse with an id is not always sent to the bot. + # This fix uses the timestamp on the activity to populate its id for logging the transcript + # If there is no outgoing timestamp, the current time for the bot is used for the activity.id + if not cloned_activity.id: + alphanumeric = string.ascii_lowercase + string.digits + prefix = "g_" + "".join( + random.choice(alphanumeric) for i in range(5) + ) + epoch = datetime.datetime.utcfromtimestamp(0) + if cloned_activity.timestamp: + reference = cloned_activity.timestamp + else: + reference = datetime.datetime.today() + delta = (reference - epoch).total_seconds() * 1000 + cloned_activity.id = f"{prefix}{delta}" + self.log_activity(transcript, cloned_activity) return responses context.on_send_activities(send_activities_handler) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 75bb278d3..8e26aa16c 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -2,13 +2,14 @@ # Licensed under the MIT License. import re -from copy import copy +from copy import copy, deepcopy from datetime import datetime from typing import List, Callable, Union, Dict from botbuilder.schema import ( Activity, ActivityTypes, ConversationReference, + InputHints, Mention, ResourceResponse, ) @@ -144,35 +145,57 @@ def set(self, key: str, value: object) -> None: self._services[key] = value async def send_activity( - self, *activity_or_text: Union[Activity, str] + self, + activity_or_text: Union[Activity, str], + speak: str = None, + input_hint: str = None, ) -> ResourceResponse: """ Sends a single activity or message to the user. :param activity_or_text: :return: """ - reference = TurnContext.get_conversation_reference(self.activity) + if isinstance(activity_or_text, str): + activity_or_text = Activity( + text=activity_or_text, + input_hint=input_hint or InputHints.accepting_input, + speak=speak, + ) + + result = await self.send_activities([activity_or_text]) + return result[0] if result else None + + async def send_activities( + self, activities: List[Activity] + ) -> List[ResourceResponse]: + sent_non_trace_activity = False + ref = TurnContext.get_conversation_reference(self.activity) + + def activity_validator(activity: Activity) -> Activity: + if not getattr(activity, "type", None): + activity.type = ActivityTypes.message + if activity.type != ActivityTypes.trace: + nonlocal sent_non_trace_activity + sent_non_trace_activity = True + if not activity.input_hint: + activity.input_hint = "acceptingInput" + activity.id = None + return activity output = [ - TurnContext.apply_conversation_reference( - Activity(text=a, type="message") if isinstance(a, str) else a, reference + activity_validator( + TurnContext.apply_conversation_reference(deepcopy(act), ref) ) - for a in activity_or_text + for act in activities ] - for activity in output: - if not activity.input_hint: - activity.input_hint = "acceptingInput" - async def callback(context: "TurnContext", output): - responses = await context.adapter.send_activities(context, output) - context._responded = True # pylint: disable=protected-access + async def logic(): + responses = await self.adapter.send_activities(self, output) + if sent_non_trace_activity: + self.responded = True return responses - result = await self._emit( - self._on_send_activities, output, callback(self, output) - ) - - return result[0] if result else ResourceResponse() + return await self._emit(self._on_send_activities, output, logic()) async def update_activity(self, activity: Activity): """ diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index 63f575a82..08dfc0c67 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -24,7 +24,9 @@ async def delete_activity( if self._call_on_delete is not None: self._call_on_delete(reference) - async def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: self.test_aux.assertIsNotNone( activities, "SimpleAdapter.delete_activity: missing reference" ) diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 1e068e295..90a49019b 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -8,6 +8,7 @@ ChannelAccount, ConversationReference, MessageReaction, + ResourceResponse, ) @@ -66,7 +67,9 @@ async def delete_activity( ): raise NotImplementedError() - async def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: raise NotImplementedError() async def update_activity(self, context: TurnContext, activity: Activity): diff --git a/libraries/botbuilder-core/tests/test_bot_adapter.py b/libraries/botbuilder-core/tests/test_bot_adapter.py index dafbe29d8..30d865956 100644 --- a/libraries/botbuilder-core/tests/test_bot_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_adapter.py @@ -42,7 +42,7 @@ def validate_responses( # pylint: disable=unused-argument resource_response = await context.send_activity(activity) self.assertTrue( - resource_response.id == activity_id, "Incorrect response Id returned" + resource_response.id != activity_id, "Incorrect response Id returned" ) async def test_continue_conversation_direct_msg(self): diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index fee872462..017b5383e 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -27,7 +27,7 @@ class SimpleAdapter(BotAdapter): - async def send_activities(self, context, activities): + async def send_activities(self, context, activities) -> List[ResourceResponse]: responses = [] assert context is not None assert activities is not None @@ -205,7 +205,7 @@ async def send_handler(context, activities, next_handler_coroutine): called = True assert activities is not None assert context is not None - assert activities[0].id == "1234" + assert not activities[0].id await next_handler_coroutine() context.on_send_activities(send_handler) diff --git a/samples/01.console-echo/adapter/console_adapter.py b/samples/01.console-echo/adapter/console_adapter.py index 28f4b4f8e..16824436f 100644 --- a/samples/01.console-echo/adapter/console_adapter.py +++ b/samples/01.console-echo/adapter/console_adapter.py @@ -105,7 +105,7 @@ async def process_activity(self, logic: Callable): context = TurnContext(self, activity) await self.run_pipeline(context, logic) - async def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities(self, context: TurnContext, activities: List[Activity]) -> List[ResourceResponse]: """ Logs a series of activities to the console. :param context: From ab79e3c601cee4d2485df6584df8a1e7e6445cde Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 13 Nov 2019 18:16:18 -0800 Subject: [PATCH 043/616] pylint: Added send_activities and updated the logic --- libraries/botbuilder-ai/tests/luis/null_adapter.py | 1 + libraries/botbuilder-core/tests/simple_adapter.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/libraries/botbuilder-ai/tests/luis/null_adapter.py b/libraries/botbuilder-ai/tests/luis/null_adapter.py index 7e1ed3051..086215d17 100644 --- a/libraries/botbuilder-ai/tests/luis/null_adapter.py +++ b/libraries/botbuilder-ai/tests/luis/null_adapter.py @@ -11,6 +11,7 @@ class NullAdapter(BotAdapter): """ This is a BotAdapter that does nothing on the Send operation, equivalent to piping to /dev/null. """ + # pylint: disable=unused-argument async def send_activities( self, context: TurnContext, activities: List[Activity] diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index 08dfc0c67..a80fa29b3 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -8,6 +8,8 @@ class SimpleAdapter(BotAdapter): + # pylint: disable=unused-argument + def __init__(self, call_on_send=None, call_on_update=None, call_on_delete=None): super(SimpleAdapter, self).__init__() self.test_aux = unittest.TestCase("__init__") From 555cc7c71a685b5b45194b2dc3f301214e61f092 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 13 Nov 2019 18:18:35 -0800 Subject: [PATCH 044/616] pylint: Added send_activities and updated the logic --- .pylintrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 40a38eff1..4f7803931 100644 --- a/.pylintrc +++ b/.pylintrc @@ -157,7 +157,8 @@ disable=print-statement, too-many-function-args, too-many-return-statements, import-error, - no-name-in-module + no-name-in-module, + too-many-branches # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option From 7fc4cb79305f0a2a100f2763d41c845377e6a3a9 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 13 Nov 2019 18:26:26 -0800 Subject: [PATCH 045/616] black formatter: Added send_activities and updated the logic --- libraries/botbuilder-ai/tests/luis/null_adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-ai/tests/luis/null_adapter.py b/libraries/botbuilder-ai/tests/luis/null_adapter.py index 086215d17..61c1c8931 100644 --- a/libraries/botbuilder-ai/tests/luis/null_adapter.py +++ b/libraries/botbuilder-ai/tests/luis/null_adapter.py @@ -11,6 +11,7 @@ class NullAdapter(BotAdapter): """ This is a BotAdapter that does nothing on the Send operation, equivalent to piping to /dev/null. """ + # pylint: disable=unused-argument async def send_activities( From 435e77b00b283f3fd2e65305086d401bf81a0654 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 14 Nov 2019 11:46:19 -0600 Subject: [PATCH 046/616] Added 11.qnamaker (#429) --- samples/11.qnamaker/README.md | 56 ++++ samples/11.qnamaker/app.py | 82 ++++++ samples/11.qnamaker/bots/__init__.py | 6 + samples/11.qnamaker/bots/qna_bot.py | 37 +++ .../cognitiveModels/smartLightFAQ.tsv | 15 ++ samples/11.qnamaker/config.py | 18 ++ .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ samples/11.qnamaker/requirements.txt | 3 + 8 files changed, 459 insertions(+) create mode 100644 samples/11.qnamaker/README.md create mode 100644 samples/11.qnamaker/app.py create mode 100644 samples/11.qnamaker/bots/__init__.py create mode 100644 samples/11.qnamaker/bots/qna_bot.py create mode 100644 samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv create mode 100644 samples/11.qnamaker/config.py create mode 100644 samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/11.qnamaker/requirements.txt diff --git a/samples/11.qnamaker/README.md b/samples/11.qnamaker/README.md new file mode 100644 index 000000000..27edff425 --- /dev/null +++ b/samples/11.qnamaker/README.md @@ -0,0 +1,56 @@ +# QnA Maker + +Bot Framework v4 QnA Maker bot sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a bot that uses the [QnA Maker Cognitive AI](https://www.qnamaker.ai) service. + +The [QnA Maker Service](https://www.qnamaker.ai) enables you to build, train and publish a simple question and answer bot based on FAQ URLs, structured documents or editorial content in minutes. In this sample, we demonstrate how to use the QnA Maker service to answer questions based on a FAQ text file used as input. + +## Prerequisites + +This samples **requires** prerequisites in order to run. + +### Overview + +This bot uses [QnA Maker Service](https://www.qnamaker.ai), an AI based cognitive service, to implement simple Question and Answer conversational patterns. + +### Create a QnAMaker Application to enable QnA Knowledge Bases + +QnA knowledge base setup and application configuration steps can be found [here](https://aka.ms/qna-instructions). + +## 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\11.qnamaker` folder +- In the terminal, type `pip install -r requirements.txt` +- Update `QNA_KNOWLEDGEBASE_ID`, `QNA_ENDPOINT_KEY`, and `QNA_ENDPOINT_HOST` in `config.py` +- 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 + +## QnA Maker service + +QnA Maker enables you to power a question and answer service from your semi-structured content. + +One of the basic requirements in writing your own bot is to seed it with questions and answers. In many cases, the questions and answers already exist in content like FAQ URLs/documents, product manuals, etc. With QnA Maker, users can query your application in a natural, conversational manner. QnA Maker uses machine learning to extract relevant question-answer pairs from your content. It also uses powerful matching and ranking algorithms to provide the best possible match between the user query and the questions. + +## 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) +- [QnA Maker Documentation](https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/overview/overview) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) +- [QnA Maker CLI](https://github.com/Microsoft/botbuilder-tools/tree/master/packages/QnAMaker) +- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) +- [Azure Portal](https://portal.azure.com) diff --git a/samples/11.qnamaker/app.py b/samples/11.qnamaker/app.py new file mode 100644 index 000000000..1f8c6f97f --- /dev/null +++ b/samples/11.qnamaker/app.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +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 QnABot + +# 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 = QnABot(app.config) + +# 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 diff --git a/samples/11.qnamaker/bots/__init__.py b/samples/11.qnamaker/bots/__init__.py new file mode 100644 index 000000000..457940100 --- /dev/null +++ b/samples/11.qnamaker/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .qna_bot import QnABot + +__all__ = ["QnABot"] diff --git a/samples/11.qnamaker/bots/qna_bot.py b/samples/11.qnamaker/bots/qna_bot.py new file mode 100644 index 000000000..8ff589e07 --- /dev/null +++ b/samples/11.qnamaker/bots/qna_bot.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from flask import Config + +from botbuilder.ai.qna import QnAMaker, QnAMakerEndpoint +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount + + +class QnABot(ActivityHandler): + def __init__(self, config: Config): + self.qna_maker = QnAMaker( + QnAMakerEndpoint( + knowledge_base_id=config["QNA_KNOWLEDGEBASE_ID"], + endpoint_key=config["QNA_ENDPOINT_KEY"], + host=config["QNA_ENDPOINT_HOST"], + ) + ) + + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + "Welcome to the QnA Maker sample! Ask me a question and I will try " + "to answer it." + ) + + async def on_message_activity(self, turn_context: TurnContext): + # The actual call to the QnA Maker service. + response = await self.qna_maker.get_answers(turn_context) + if response and len(response) > 0: + await turn_context.send_activity(MessageFactory.text(response[0].answer)) + else: + await turn_context.send_activity("No QnA Maker answers were found.") diff --git a/samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv b/samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv new file mode 100644 index 000000000..754118909 --- /dev/null +++ b/samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv @@ -0,0 +1,15 @@ +Question Answer Source Keywords +Question Answer 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Source +My Contoso smart light won't turn on. Check the connection to the wall outlet to make sure it's plugged in properly. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +Light won't turn on. Check the connection to the wall outlet to make sure it's plugged in properly. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +My smart light app stopped responding. Restart the app. If the problem persists, contact support. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +How do I contact support? Email us at service@contoso.com 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +I need help. Email us at service@contoso.com 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +I upgraded the app and it doesn't work anymore. When you upgrade, you need to disable Bluetooth, then re-enable it. After re-enable, re-pair your light with the app. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +Light doesn't work after upgrade. When you upgrade, you need to disable Bluetooth, then re-enable it. After re-enable, re-pair your light with the app. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial +Question Answer 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Source +Who should I contact for customer service? Please direct all customer service questions to (202) 555-0164 \n 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial +Why does the light not work? The simplest way to troubleshoot your smart light is to turn it off and on. \n 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial +How long does the light's battery last for? The battery will last approximately 10 - 12 weeks with regular use. \n 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial +What type of light bulb do I need? A 26-Watt compact fluorescent light bulb that features both energy savings and long-life performance. 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial +Hi Hello Editorial \ No newline at end of file diff --git a/samples/11.qnamaker/config.py b/samples/11.qnamaker/config.py new file mode 100644 index 000000000..068a30d35 --- /dev/null +++ b/samples/11.qnamaker/config.py @@ -0,0 +1,18 @@ +#!/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", "") + QNA_KNOWLEDGEBASE_ID = os.environ.get("QnAKnowledgebaseId", "") + QNA_ENDPOINT_KEY = os.environ.get("QnAEndpointKey", "") + QNA_ENDPOINT_HOST = os.environ.get("QnAEndpointHostName", "") diff --git a/samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json b/samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/11.qnamaker/requirements.txt b/samples/11.qnamaker/requirements.txt new file mode 100644 index 000000000..cf76fec34 --- /dev/null +++ b/samples/11.qnamaker/requirements.txt @@ -0,0 +1,3 @@ +botbuilder-core>=4.4.0b1 +botbuilder-ai>=4.4.0b1 +flask>=1.0.3 From c8044ab3c4c96bc7bf4ae9aa0672627f87a82c20 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 14 Nov 2019 11:52:39 -0600 Subject: [PATCH 047/616] Added 40.timex resolution (#430) * Unfinished push until recognizers-text is updated. * Added 40.timex-resolution --- samples/40.timex-resolution/README.md | 51 ++++++++++++ samples/40.timex-resolution/ambiguity.py | 78 +++++++++++++++++++ samples/40.timex-resolution/constraints.py | 31 ++++++++ .../language_generation.py | 33 ++++++++ samples/40.timex-resolution/main.py | 23 ++++++ samples/40.timex-resolution/parsing.py | 45 +++++++++++ samples/40.timex-resolution/ranges.py | 51 ++++++++++++ samples/40.timex-resolution/requirements.txt | 3 + samples/40.timex-resolution/resolution.py | 26 +++++++ 9 files changed, 341 insertions(+) create mode 100644 samples/40.timex-resolution/README.md create mode 100644 samples/40.timex-resolution/ambiguity.py create mode 100644 samples/40.timex-resolution/constraints.py create mode 100644 samples/40.timex-resolution/language_generation.py create mode 100644 samples/40.timex-resolution/main.py create mode 100644 samples/40.timex-resolution/parsing.py create mode 100644 samples/40.timex-resolution/ranges.py create mode 100644 samples/40.timex-resolution/requirements.txt create mode 100644 samples/40.timex-resolution/resolution.py diff --git a/samples/40.timex-resolution/README.md b/samples/40.timex-resolution/README.md new file mode 100644 index 000000000..2d6b6b0a8 --- /dev/null +++ b/samples/40.timex-resolution/README.md @@ -0,0 +1,51 @@ +# Timex Resolution + +This sample shows how to use TIMEX expressions. + +## Concepts introduced in this sample + +### What is a TIMEX expression? + +A TIMEX expression is an alpha-numeric expression derived in outline from the standard date-time representation ISO 8601. +The interesting thing about TIMEX expressions is that they can represent various degrees of ambiguity in the date parts. For example, May 29th, is not a +full calendar date because we haven't said which May 29th - it could be this year, last year, any year in fact. +TIMEX has other features such as the ability to represent ranges, date ranges, time ranges and even date-time ranges. + +### Where do TIMEX expressions come from? + +TIMEX expressions are produced as part of the output of running a DateTimeRecognizer against some natural language input. As the same +Recognizers are run in LUIS the result returned in the JSON from a call to LUIS also contains the TIMEX expressions. + +### What can the library do? + +It turns out that TIMEX expressions are not that simple to work with in code. This library attempts to address that. One helpful way to +think about a TIMEX expression is as a partially filled property bag. The properties might be such things as "day of week" or "year." +Basically the more properties we have captured in the expression the less ambiguity we have. + +The library can do various things: + +- Parse TIMEX expressions to give you the properties contained there in. +- Generate TIMEX expressions based on setting raw properties. +- Generate natural language from the TIMEX expression. (This is logically the reverse of the Recognizer.) +- Resolve TIMEX expressions to produce example date-times. (This produces the same result as the Recognizer (and therefore LUIS)). +- Evaluate TIMEX expressions against constraints such that new more precise TIMEX expressions are produced. + +### Where is the source code? + +The TIMEX expression library is contained in the same GitHub repo as the recognizers. Refer to the further reading section below. + +## 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\40.timex-resolution` folder +- In the terminal, type `pip install -r requirements.txt` +- In the terminal, type `python main.py` + +## Further reading + +- [TIMEX](https://en.wikipedia.org/wiki/TimeML#TIMEX3) +- [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) +- [Recognizers Text](https://github.com/Microsoft/recognizers-text) diff --git a/samples/40.timex-resolution/ambiguity.py b/samples/40.timex-resolution/ambiguity.py new file mode 100644 index 000000000..a412b2f55 --- /dev/null +++ b/samples/40.timex-resolution/ambiguity.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from recognizers_date_time import recognize_datetime, Culture + + +class Ambiguity: + """ + TIMEX expressions are designed to represent ambiguous rather than definite dates. For + example: "Monday" could be any Monday ever. "May 5th" could be any one of the possible May + 5th in the past or the future. TIMEX does not represent ambiguous times. So if the natural + language mentioned 4 o'clock it could be either 4AM or 4PM. For that the recognizer (and by + extension LUIS) would return two TIMEX expressions. A TIMEX expression can include a date and + time parts. So ambiguity of date can be combined with multiple results. Code that deals with + TIMEX expressions is frequently dealing with sets of TIMEX expressions. + """ + + @staticmethod + def date_ambiguity(): + # Run the recognizer. + results = recognize_datetime( + "Either Saturday or Sunday would work.", Culture.English + ) + + # We should find two results in this example. + for result in results: + # The resolution includes two example values: going backwards and forwards from NOW in the calendar. + # Each result includes a TIMEX expression that captures the inherent date but not time ambiguity. + # We are interested in the distinct set of TIMEX expressions. + # There is also either a "value" property on each value or "start" and "end". + distinct_timex_expressions = { + value["timex"] + for value in result.resolution["values"] + if "timex" in value + } + print(f"{result.text} ({','.join(distinct_timex_expressions)})") + + @staticmethod + def time_ambiguity(): + # Run the recognizer. + results = recognize_datetime( + "We would like to arrive at 4 o'clock or 5 o'clock.", Culture.English + ) + + # We should find two results in this example. + for result in results: + # The resolution includes two example values: one for AM and one for PM. + # Each result includes a TIMEX expression that captures the inherent date but not time ambiguity. + # We are interested in the distinct set of TIMEX expressions. + distinct_timex_expressions = { + value["timex"] + for value in result.resolution["values"] + if "timex" in value + } + + # TIMEX expressions don't capture time ambiguity so there will be two distinct expressions for each result. + print(f"{result.text} ({','.join(distinct_timex_expressions)})") + + @staticmethod + def date_time_ambiguity(): + # Run the recognizer. + results = recognize_datetime( + "It will be ready Wednesday at 5 o'clock.", Culture.English + ) + + # We should find a single result in this example. + for result in results: + # The resolution includes four example values: backwards and forward in the calendar and then AM and PM. + # Each result includes a TIMEX expression that captures the inherent date but not time ambiguity. + # We are interested in the distinct set of TIMEX expressions. + distinct_timex_expressions = { + value["timex"] + for value in result.resolution["values"] + if "timex" in value + } + + # TIMEX expressions don't capture time ambiguity so there will be two distinct expressions for each result. + print(f"{result.text} ({','.join(distinct_timex_expressions)})") diff --git a/samples/40.timex-resolution/constraints.py b/samples/40.timex-resolution/constraints.py new file mode 100644 index 000000000..21e8d2190 --- /dev/null +++ b/samples/40.timex-resolution/constraints.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime + +from datatypes_timex_expression import TimexRangeResolver, TimexCreator + + +class Constraints: + """ + The TimexRangeResolved can be used in application logic to apply constraints to a set of TIMEX expressions. + The constraints themselves are TIMEX expressions. This is designed to appear a little like a database join, + of course its a little less generic than that because dates can be complicated things. + """ + + @staticmethod + def examples(): + """ + When you give the recognizer the text "Wednesday 4 o'clock" you get these distinct TIMEX values back. + But our bot logic knows that whatever the user says it should be evaluated against the constraints of + a week from today with respect to the date part and in the evening with respect to the time part. + """ + + resolutions = TimexRangeResolver.evaluate( + ["XXXX-WXX-3T04", "XXXX-WXX-3T16"], + [TimexCreator.week_from_today(), TimexCreator.EVENING], + ) + + today = datetime.datetime.now() + for resolution in resolutions: + print(resolution.to_natural_language(today)) diff --git a/samples/40.timex-resolution/language_generation.py b/samples/40.timex-resolution/language_generation.py new file mode 100644 index 000000000..c8b156521 --- /dev/null +++ b/samples/40.timex-resolution/language_generation.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime + +from datatypes_timex_expression import Timex + + +class LanguageGeneration: + """ + This language generation capabilities are the logical opposite of what the recognizer does. + As an experiment try feeding the result of language generation back into a recognizer. + You should get back the same TIMEX expression in the result. + """ + + @staticmethod + def examples(): + LanguageGeneration.__describe(Timex("2019-05-29")) + LanguageGeneration.__describe(Timex("XXXX-WXX-6")) + LanguageGeneration.__describe(Timex("XXXX-WXX-6T16")) + LanguageGeneration.__describe(Timex("T12")) + + LanguageGeneration.__describe(Timex.from_date(datetime.datetime.now())) + LanguageGeneration.__describe( + Timex.from_date(datetime.datetime.now() + datetime.timedelta(days=1)) + ) + + @staticmethod + def __describe(timex: Timex): + # Note natural language is often relative, for example the sentence "Yesterday all my troubles seemed so far + # away." Having your bot say something like "next Wednesday" in a response can make it sound more natural. + reference_date = datetime.datetime.now() + print(f"{timex.timex_value()} : {timex.to_natural_language(reference_date)}") diff --git a/samples/40.timex-resolution/main.py b/samples/40.timex-resolution/main.py new file mode 100644 index 000000000..1079efd7a --- /dev/null +++ b/samples/40.timex-resolution/main.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from ambiguity import Ambiguity +from constraints import Constraints +from language_generation import LanguageGeneration +from parsing import Parsing +from ranges import Ranges +from resolution import Resolution + +if __name__ == "__main__": + # Creating TIMEX expressions from natural language using the Recognizer package. + Ambiguity.date_ambiguity() + Ambiguity.time_ambiguity() + Ambiguity.date_time_ambiguity() + Ranges.date_range() + Ranges.time_range() + + # Manipulating TIMEX expressions in code using the TIMEX Datatype package. + Parsing.examples() + LanguageGeneration.examples() + Resolution.examples() + Constraints.examples() diff --git a/samples/40.timex-resolution/parsing.py b/samples/40.timex-resolution/parsing.py new file mode 100644 index 000000000..194dc97cc --- /dev/null +++ b/samples/40.timex-resolution/parsing.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datatypes_timex_expression import Timex, Constants + + +class Parsing: + """ + The Timex class takes a TIMEX expression as a string argument in its constructor. + This pulls all the component parts of the expression into properties on this object. You can + then manipulate the TIMEX expression via those properties. + The "types" property infers a datetimeV2 type from the underlying set of properties. + If you take a TIMEX with date components and add time components you add the + inferred type datetime (its still a date). + Logic can be written against the inferred type, perhaps to have the bot ask the user for + disambiguation. + """ + + @staticmethod + def __describe(timex_pattern: str): + timex = Timex(timex_pattern) + + print(timex.timex_value(), end=" ") + + if Constants.TIMEX_TYPES_DATE in timex.types: + if Constants.TIMEX_TYPES_DEFINITE in timex.types: + print("We have a definite calendar date.", end=" ") + else: + print("We have a date but there is some ambiguity.", end=" ") + + if Constants.TIMEX_TYPES_TIME in timex.types: + print("We have a time.") + else: + print("") + + @staticmethod + def examples(): + """ + Print information an various TimeX expressions. + :return: None + """ + Parsing.__describe("2017-05-29") + Parsing.__describe("XXXX-WXX-6") + Parsing.__describe("XXXX-WXX-6T16") + Parsing.__describe("T12") diff --git a/samples/40.timex-resolution/ranges.py b/samples/40.timex-resolution/ranges.py new file mode 100644 index 000000000..1bae92ce0 --- /dev/null +++ b/samples/40.timex-resolution/ranges.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from recognizers_date_time import recognize_datetime +from recognizers_text import Culture + + +class Ranges: + """ + TIMEX expressions can represent date and time ranges. Here are a couple of examples. + """ + + @staticmethod + def date_range(): + # Run the recognizer. + results = recognize_datetime( + "Some time in the next two weeks.", Culture.English + ) + + # We should find a single result in this example. + for result in results: + # The resolution includes a single value because there is no ambiguity. + # We are interested in the distinct set of TIMEX expressions. + distinct_timex_expressions = { + value["timex"] + for value in result.resolution["values"] + if "timex" in value + } + + # The TIMEX expression can also capture the notion of range. + print(f"{result.text} ({','.join(distinct_timex_expressions)})") + + @staticmethod + def time_range(): + # Run the recognizer. + results = recognize_datetime( + "Some time between 6pm and 6:30pm.", Culture.English + ) + + # We should find a single result in this example. + for result in results: + # The resolution includes a single value because there is no ambiguity. + # We are interested in the distinct set of TIMEX expressions. + distinct_timex_expressions = { + value["timex"] + for value in result.resolution["values"] + if "timex" in value + } + + # The TIMEX expression can also capture the notion of range. + print(f"{result.text} ({','.join(distinct_timex_expressions)})") diff --git a/samples/40.timex-resolution/requirements.txt b/samples/40.timex-resolution/requirements.txt new file mode 100644 index 000000000..26579538e --- /dev/null +++ b/samples/40.timex-resolution/requirements.txt @@ -0,0 +1,3 @@ +recognizers-text>=1.0.2a2 +datatypes-timex-expression>=1.0.2a2 + diff --git a/samples/40.timex-resolution/resolution.py b/samples/40.timex-resolution/resolution.py new file mode 100644 index 000000000..4e42f5e88 --- /dev/null +++ b/samples/40.timex-resolution/resolution.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime + +from datatypes_timex_expression import TimexResolver + + +class Resolution: + """ + Given the TIMEX expressions it is easy to create the computed example values that the recognizer gives. + """ + + @staticmethod + def examples(): + # When you give the recognizer the text "Wednesday 4 o'clock" you get these distinct TIMEX values back. + + today = datetime.datetime.now() + resolution = TimexResolver.resolve(["XXXX-WXX-3T04", "XXXX-WXX-3T16"], today) + + print(f"Resolution Values: {len(resolution.values)}") + + for value in resolution.values: + print(value.timex) + print(value.type) + print(value.value) From 3a2e2faf854d586969f7a8617a085633aba8d015 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 14 Nov 2019 14:57:06 -0600 Subject: [PATCH 048/616] Added 42.scaleout (#435) --- samples/42.scaleout/README.md | 36 +++ samples/42.scaleout/app.py | 96 +++++++ samples/42.scaleout/bots/__init__.py | 6 + samples/42.scaleout/bots/scaleout_bot.py | 45 ++++ samples/42.scaleout/config.py | 18 ++ .../template-with-preexisting-rg.json | 242 ++++++++++++++++++ samples/42.scaleout/dialogs/__init__.py | 6 + samples/42.scaleout/dialogs/root_dialog.py | 56 ++++ samples/42.scaleout/helpers/__init__.py | 6 + samples/42.scaleout/helpers/dialog_helper.py | 19 ++ samples/42.scaleout/host/__init__.py | 7 + samples/42.scaleout/host/dialog_host.py | 72 ++++++ .../42.scaleout/host/dialog_host_adapter.py | 32 +++ samples/42.scaleout/requirements.txt | 4 + samples/42.scaleout/store/__init__.py | 9 + samples/42.scaleout/store/blob_store.py | 51 ++++ samples/42.scaleout/store/memory_store.py | 29 +++ samples/42.scaleout/store/ref_accessor.py | 37 +++ samples/42.scaleout/store/store.py | 32 +++ 19 files changed, 803 insertions(+) create mode 100644 samples/42.scaleout/README.md create mode 100644 samples/42.scaleout/app.py create mode 100644 samples/42.scaleout/bots/__init__.py create mode 100644 samples/42.scaleout/bots/scaleout_bot.py create mode 100644 samples/42.scaleout/config.py create mode 100644 samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 samples/42.scaleout/dialogs/__init__.py create mode 100644 samples/42.scaleout/dialogs/root_dialog.py create mode 100644 samples/42.scaleout/helpers/__init__.py create mode 100644 samples/42.scaleout/helpers/dialog_helper.py create mode 100644 samples/42.scaleout/host/__init__.py create mode 100644 samples/42.scaleout/host/dialog_host.py create mode 100644 samples/42.scaleout/host/dialog_host_adapter.py create mode 100644 samples/42.scaleout/requirements.txt create mode 100644 samples/42.scaleout/store/__init__.py create mode 100644 samples/42.scaleout/store/blob_store.py create mode 100644 samples/42.scaleout/store/memory_store.py create mode 100644 samples/42.scaleout/store/ref_accessor.py create mode 100644 samples/42.scaleout/store/store.py diff --git a/samples/42.scaleout/README.md b/samples/42.scaleout/README.md new file mode 100644 index 000000000..e9b8d103c --- /dev/null +++ b/samples/42.scaleout/README.md @@ -0,0 +1,36 @@ +# Scale Out + +Bot Framework v4 bot Scale Out sample + +This bot has been created using [Bot Framework](https://dev.botframework.com), is shows how to use a custom storage solution that supports a deployment scaled out across multiple machines. + +The custom storage solution is implemented against memory for testing purposes and against Azure Blob Storage. The sample shows how storage solutions with different policies can be implemented and integrated with the framework. The solution makes use of the standard HTTP ETag/If-Match mechanisms commonly found on cloud storage technologies. + +## 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\42.scaleout` 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 + +## 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) +- [Implementing custom storage for you bot](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-custom-storage?view=azure-bot-service-4.0) +- [Bot Storage](https://docs.microsoft.com/en-us/azure/bot-service/dotnet/bot-builder-dotnet-state?view=azure-bot-service-3.0&viewFallbackFrom=azure-bot-service-4.0) +- [HTTP ETag](https://en.wikipedia.org/wiki/HTTP_ETag) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/42.scaleout/app.py b/samples/42.scaleout/app.py new file mode 100644 index 000000000..ac780beed --- /dev/null +++ b/samples/42.scaleout/app.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +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 ScaleoutBot + +# Create the loop and Flask app +from dialogs import RootDialog +from store import MemoryStore + +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 +STORAGE = MemoryStore() +# Use BlobStore to test with Azure Blob storage. +# STORAGE = BlobStore(app.config["BLOB_ACCOUNT_NAME"], app.config["BLOB_KEY"], app.config["BLOB_CONTAINER"]) +DIALOG = RootDialog() +BOT = ScaleoutBot(STORAGE, DIALOG) + +# 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 diff --git a/samples/42.scaleout/bots/__init__.py b/samples/42.scaleout/bots/__init__.py new file mode 100644 index 000000000..b1886b216 --- /dev/null +++ b/samples/42.scaleout/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .scaleout_bot import ScaleoutBot + +__all__ = ["ScaleoutBot"] diff --git a/samples/42.scaleout/bots/scaleout_bot.py b/samples/42.scaleout/bots/scaleout_bot.py new file mode 100644 index 000000000..83489cd47 --- /dev/null +++ b/samples/42.scaleout/bots/scaleout_bot.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, TurnContext +from botbuilder.dialogs import Dialog + +from host import DialogHost +from store import Store + + +class ScaleoutBot(ActivityHandler): + """ + This bot runs Dialogs that send message Activities in a way that can be scaled out with a multi-machine deployment. + The bot logic makes use of the standard HTTP ETag/If-Match mechanism for optimistic locking. This mechanism + is commonly supported on cloud storage technologies from multiple vendors including teh Azure Blob Storage + service. A full implementation against Azure Blob Storage is included in this sample. + """ + + def __init__(self, store: Store, dialog: Dialog): + self.store = store + self.dialog = dialog + + async def on_message_activity(self, turn_context: TurnContext): + # Create the storage key for this conversation. + key = f"{turn_context.activity.channel_id}/conversations/{turn_context.activity.conversation.id}" + + # The execution sits in a loop because there might be a retry if the save operation fails. + while True: + # Load any existing state associated with this key + old_state, e_tag = await self.store.load(key) + + # Run the dialog system with the old state and inbound activity, the result is a new state and outbound + # activities. + activities, new_state = await DialogHost.run( + self.dialog, turn_context.activity, old_state + ) + + # Save the updated state associated with this key. + success = await self.store.save(key, new_state, e_tag) + if success: + if activities: + # This is an actual send on the TurnContext we were given and so will actual do a send this time. + await turn_context.send_activities(activities) + + break diff --git a/samples/42.scaleout/config.py b/samples/42.scaleout/config.py new file mode 100644 index 000000000..5737815c9 --- /dev/null +++ b/samples/42.scaleout/config.py @@ -0,0 +1,18 @@ +#!/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", "") + BLOB_ACCOUNT_NAME = "tboehrestorage" + BLOB_KEY = "A7tc3c9T/n67iDYO7Lx19sTjnA+DD3bR/HQ4yPhJuyVXO1yJ8mYzDOXsBhJrjldh7zKMjE9Wc6PrM1It4nlGPw==" + BLOB_CONTAINER = "dialogs" diff --git a/samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json b/samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..bff8c096d --- /dev/null +++ b/samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} \ No newline at end of file diff --git a/samples/42.scaleout/dialogs/__init__.py b/samples/42.scaleout/dialogs/__init__.py new file mode 100644 index 000000000..d97c50169 --- /dev/null +++ b/samples/42.scaleout/dialogs/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .root_dialog import RootDialog + +__all__ = ["RootDialog"] diff --git a/samples/42.scaleout/dialogs/root_dialog.py b/samples/42.scaleout/dialogs/root_dialog.py new file mode 100644 index 000000000..e849ba02b --- /dev/null +++ b/samples/42.scaleout/dialogs/root_dialog.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + NumberPrompt, + PromptOptions, +) + + +class RootDialog(ComponentDialog): + def __init__(self): + super(RootDialog, self).__init__(RootDialog.__name__) + + self.add_dialog(self.__create_waterfall()) + self.add_dialog(NumberPrompt("number")) + + self.initial_dialog_id = "waterfall" + + def __create_waterfall(self) -> WaterfallDialog: + return WaterfallDialog("waterfall", [self.__step1, self.__step2, self.__step3]) + + async def __step1(self, step_context: WaterfallStepContext) -> DialogTurnResult: + return await step_context.prompt( + "number", PromptOptions(prompt=MessageFactory.text("Enter a number.")) + ) + + async def __step2(self, step_context: WaterfallStepContext) -> DialogTurnResult: + first: int = step_context.result + step_context.values["first"] = first + + return await step_context.prompt( + "number", + PromptOptions( + prompt=MessageFactory.text(f"I have {first}, now enter another number") + ), + ) + + async def __step3(self, step_context: WaterfallStepContext) -> DialogTurnResult: + first: int = step_context.values["first"] + second: int = step_context.result + + await step_context.prompt( + "number", + PromptOptions( + prompt=MessageFactory.text( + f"The result of the first minus the second is {first - second}." + ) + ), + ) + + return await step_context.end_dialog() diff --git a/samples/42.scaleout/helpers/__init__.py b/samples/42.scaleout/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/42.scaleout/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/42.scaleout/helpers/dialog_helper.py b/samples/42.scaleout/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/42.scaleout/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/42.scaleout/host/__init__.py b/samples/42.scaleout/host/__init__.py new file mode 100644 index 000000000..3ce168e54 --- /dev/null +++ b/samples/42.scaleout/host/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_host import DialogHost +from .dialog_host_adapter import DialogHostAdapter + +__all__ = ["DialogHost", "DialogHostAdapter"] diff --git a/samples/42.scaleout/host/dialog_host.py b/samples/42.scaleout/host/dialog_host.py new file mode 100644 index 000000000..b7cfe1692 --- /dev/null +++ b/samples/42.scaleout/host/dialog_host.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json + +from jsonpickle import encode +from jsonpickle.unpickler import Unpickler + +from botbuilder.core import TurnContext +from botbuilder.dialogs import Dialog, ComponentDialog +from botbuilder.schema import Activity + +from helpers.dialog_helper import DialogHelper +from host.dialog_host_adapter import DialogHostAdapter +from store import RefAccessor + + +class DialogHost: + """ + The essential code for running a dialog. The execution of the dialog is treated here as a pure function call. + The input being the existing (or old) state and the inbound Activity and the result being the updated (or new) + state and the Activities that should be sent. The assumption is that this code can be re-run without causing any + unintended or harmful side-effects, for example, any outbound service calls made directly from the + dialog implementation should be idempotent. + """ + + @staticmethod + async def run(dialog: Dialog, activity: Activity, old_state) -> (): + """ + A function to run a dialog while buffering the outbound Activities. + """ + + # A custom adapter and corresponding TurnContext that buffers any messages sent. + adapter = DialogHostAdapter() + turn_context = TurnContext(adapter, activity) + + # Run the dialog using this TurnContext with the existing state. + new_state = await DialogHost.__run_turn(dialog, turn_context, old_state) + + # The result is a set of activities to send and a replacement state. + return adapter.activities, new_state + + @staticmethod + async def __run_turn(dialog: Dialog, turn_context: TurnContext, state): + """ + Execute the turn of the bot. The functionality here closely resembles that which is found in the + Bot.on_turn method in an implementation that is using the regular BotFrameworkAdapter. + Also here in this example the focus is explicitly on Dialogs but the pattern could be adapted + to other conversation modeling abstractions. + """ + # If we have some state, deserialize it. (This mimics the shape produced by BotState.cs.) + dialog_state_property = ( + state[ComponentDialog.persisted_dialog_state] if state else None + ) + dialog_state = ( + None + if not dialog_state_property + else Unpickler().restore(json.loads(dialog_state_property)) + ) + + # A custom accessor is used to pass a handle on the state to the dialog system. + accessor = RefAccessor(dialog_state) + + # Run the dialog. + await DialogHelper.run_dialog(dialog, turn_context, accessor) + + # Serialize the result (available as Value on the accessor), and put its value back into a new json object. + return { + ComponentDialog.persisted_dialog_state: None + if not accessor.value + else encode(accessor.value) + } diff --git a/samples/42.scaleout/host/dialog_host_adapter.py b/samples/42.scaleout/host/dialog_host_adapter.py new file mode 100644 index 000000000..ab7151c0f --- /dev/null +++ b/samples/42.scaleout/host/dialog_host_adapter.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import Activity, ConversationReference + + +class DialogHostAdapter(BotAdapter): + """ + This custom BotAdapter supports scenarios that only Send Activities. Update and Delete Activity + are not supported. + Rather than sending the outbound Activities directly as the BotFrameworkAdapter does this class + buffers them in a list. The list is exposed as a public property. + """ + + def __init__(self): + super(DialogHostAdapter, self).__init__() + self.activities = [] + + async def send_activities(self, context: TurnContext, activities: List[Activity]): + self.activities.extend(activities) + return [] + + async def update_activity(self, context: TurnContext, activity: Activity): + raise NotImplementedError + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + raise NotImplementedError diff --git a/samples/42.scaleout/requirements.txt b/samples/42.scaleout/requirements.txt new file mode 100644 index 000000000..4760c7682 --- /dev/null +++ b/samples/42.scaleout/requirements.txt @@ -0,0 +1,4 @@ +jsonpickle +botbuilder-core>=4.4.0b1 +azure>=4.0.0 +flask>=1.0.3 diff --git a/samples/42.scaleout/store/__init__.py b/samples/42.scaleout/store/__init__.py new file mode 100644 index 000000000..0aaa4235a --- /dev/null +++ b/samples/42.scaleout/store/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .store import Store +from .memory_store import MemoryStore +from .blob_store import BlobStore +from .ref_accessor import RefAccessor + +__all__ = ["Store", "MemoryStore", "BlobStore", "RefAccessor"] diff --git a/samples/42.scaleout/store/blob_store.py b/samples/42.scaleout/store/blob_store.py new file mode 100644 index 000000000..c17ebd2c6 --- /dev/null +++ b/samples/42.scaleout/store/blob_store.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json + +from azure.storage.blob import BlockBlobService, PublicAccess +from jsonpickle import encode +from jsonpickle.unpickler import Unpickler + +from store.store import Store + + +class BlobStore(Store): + """ + An implementation of the ETag aware Store interface against Azure Blob Storage. + """ + + def __init__(self, account_name: str, account_key: str, container_name: str): + self.container_name = container_name + self.client = BlockBlobService( + account_name=account_name, account_key=account_key + ) + + async def load(self, key: str) -> (): + self.client.create_container(self.container_name) + self.client.set_container_acl( + self.container_name, public_access=PublicAccess.Container + ) + + if not self.client.exists(container_name=self.container_name, blob_name=key): + return None, None + + blob = self.client.get_blob_to_text( + container_name=self.container_name, blob_name=key + ) + return Unpickler().restore(json.loads(blob.content)), blob.properties.etag + + async def save(self, key: str, content, e_tag: str): + self.client.create_container(self.container_name) + self.client.set_container_acl( + self.container_name, public_access=PublicAccess.Container + ) + + self.client.create_blob_from_text( + container_name=self.container_name, + blob_name=key, + text=encode(content), + if_match=e_tag, + ) + + return True diff --git a/samples/42.scaleout/store/memory_store.py b/samples/42.scaleout/store/memory_store.py new file mode 100644 index 000000000..d72293422 --- /dev/null +++ b/samples/42.scaleout/store/memory_store.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import uuid +from typing import Tuple + +from store.store import Store + + +class MemoryStore(Store): + """ + Implementation of the IStore abstraction intended for testing. + """ + + def __init__(self): + # dict of Tuples + self.store = {} + + async def load(self, key: str) -> (): + return self.store[key] if key in self.store else (None, None) + + async def save(self, key: str, content, e_tag: str) -> bool: + if e_tag: + value: Tuple = self.store[key] + if value and value[1] != e_tag: + return False + + self.store[key] = (content, str(uuid.uuid4())) + return True diff --git a/samples/42.scaleout/store/ref_accessor.py b/samples/42.scaleout/store/ref_accessor.py new file mode 100644 index 000000000..45bb5d4a4 --- /dev/null +++ b/samples/42.scaleout/store/ref_accessor.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext + + +class RefAccessor(StatePropertyAccessor): + """ + This is an accessor for any object. By definition objects (as opposed to values) + are returned by reference in the GetAsync call on the accessor. As such the SetAsync + call is never used. The actual act of saving any state to an external store therefore + cannot be encapsulated in the Accessor implementation itself. And so to facilitate this + the state itself is available as a public property on this class. The reason its here is + because the caller of the constructor could pass in null for the state, in which case + the factory provided on the GetAsync call will be used. + """ + + def __init__(self, value): + self.value = value + self.name = type(value).__name__ + + async def get( + self, turn_context: TurnContext, default_value_or_factory=None + ) -> object: + if not self.value: + if not default_value_or_factory: + raise Exception("key not found") + + self.value = default_value_or_factory() + + return self.value + + async def delete(self, turn_context: TurnContext) -> None: + pass + + async def set(self, turn_context: TurnContext, value) -> None: + pass diff --git a/samples/42.scaleout/store/store.py b/samples/42.scaleout/store/store.py new file mode 100644 index 000000000..4d13e0889 --- /dev/null +++ b/samples/42.scaleout/store/store.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + + +class Store(ABC): + """ + An ETag aware store definition. + The interface is defined in terms of JObject to move serialization out of the storage layer + while still indicating it is JSON, a fact the store may choose to make use of. + """ + + @abstractmethod + async def load(self, key: str) -> (): + """ + Loads a value from the Store. + :param key: + :return: (object, etag) + """ + raise NotImplementedError + + @abstractmethod + async def save(self, key: str, content, e_tag: str) -> bool: + """ + Saves a values to the Store if the etag matches. + :param key: + :param content: + :param e_tag: + :return: True if the content was saved. + """ + raise NotImplementedError From 73ff35a87cb3fa1047309abf63479ac65ffc26ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 15 Nov 2019 11:32:17 -0800 Subject: [PATCH 049/616] Pinned pytest version (#438) --- libraries/botframework-connector/tests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botframework-connector/tests/requirements.txt b/libraries/botframework-connector/tests/requirements.txt index ca88e209f..34e7b4527 100644 --- a/libraries/botframework-connector/tests/requirements.txt +++ b/libraries/botframework-connector/tests/requirements.txt @@ -1,4 +1,4 @@ pytest-cov>=2.6.0 -pytest>=4.3.0 +pytest==5.2.2 azure-devtools>=0.4.1 pytest-asyncio \ No newline at end of file From 33469f1e4fd2aac3f6615105f1b883b5edd2e3b9 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 20 Nov 2019 17:03:52 -0800 Subject: [PATCH 050/616] echo with aiohttp --- samples/02.echo-bot/app.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/samples/02.echo-bot/app.py b/samples/02.echo-bot/app.py index 5cc960eb8..241f51e4e 100644 --- a/samples/02.echo-bot/app.py +++ b/samples/02.echo-bot/app.py @@ -1,24 +1,22 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import asyncio import sys from datetime import datetime -from flask import Flask, request, Response +from aiohttp import web +from aiohttp.web import Request, Response from botbuilder.core import BotFrameworkAdapterSettings, TurnContext, BotFrameworkAdapter from botbuilder.schema import Activity, ActivityTypes from bots import EchoBot +from config import DefaultConfig -# Create the loop and Flask app -LOOP = asyncio.get_event_loop() -app = Flask(__name__, instance_relative_config=True) -app.config.from_object("config.DefaultConfig") +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"]) +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) ADAPTER = BotFrameworkAdapter(SETTINGS) @@ -52,31 +50,30 @@ async def on_error(context: TurnContext, error: Exception): BOT = EchoBot() # Listen for incoming requests on /api/messages -@app.route("/api/messages", methods=["POST"]) -def messages(): +async def messages(req: Request) -> Response: # Main bot message handler. - if "application/json" in request.headers["Content-Type"]: - body = request.json + if "application/json" in req.headers["Content-Type"]: + body = await req.json() else: return Response(status=415) activity = Activity().deserialize(body) auth_header = ( - request.headers["Authorization"] if "Authorization" in request.headers else "" + req.headers["Authorization"] if "Authorization" in req.headers else "" ) try: - task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, BOT.on_turn) - ) - LOOP.run_until_complete(task) + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) return Response(status=201) except Exception as exception: raise exception +APP = web.Application() +APP.router.add_post("/api/messages", messages) + if __name__ == "__main__": try: - app.run(debug=False, port=app.config["PORT"]) # nosec debug - except Exception as exception: - raise exception + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error From 6f814042eaa66c8a7faea2eda0b1e433e73ff157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 25 Nov 2019 15:38:06 -0800 Subject: [PATCH 051/616] Auth changes for skills (#420) * auth changes for skills, tests pending * Moving imports due to circular dependencies * unit tests for oauth changes * Moving test dependency to correct requirement file * Removed useless todo note * solved PR comments --- .../auth/authentication_configuration.py | 9 + .../connector/auth/channel_validation.py | 14 +- .../connector/auth/emulator_validation.py | 26 +-- .../connector/auth/endorsements_validator.py | 6 +- .../auth/enterprise_channel_validation.py | 7 +- .../auth/government_channel_validation.py | 12 +- .../connector/auth/jwt_token_extractor.py | 42 ++++- .../connector/auth/jwt_token_validation.py | 78 ++++++++- .../auth/microsoft_app_credentials.py | 10 +- .../connector/auth/skill_validation.py | 157 +++++++++++++++++ .../connector/auth/verify_options.py | 15 +- .../tests/requirements.txt | 3 +- .../botframework-connector/tests/test_auth.py | 48 ++++-- .../tests/test_microsoft_app_credentials.py | 33 ++++ .../tests/test_skill_validation.py | 159 ++++++++++++++++++ 15 files changed, 558 insertions(+), 61 deletions(-) create mode 100644 libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py create mode 100644 libraries/botframework-connector/botframework/connector/auth/skill_validation.py create mode 100644 libraries/botframework-connector/tests/test_microsoft_app_credentials.py create mode 100644 libraries/botframework-connector/tests/test_skill_validation.py diff --git a/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py b/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py new file mode 100644 index 000000000..f60cff190 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + + +class AuthenticationConfiguration: + def __init__(self, required_endorsements: List[str] = None): + self.required_endorsements = required_endorsements or [] diff --git a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py index 5ea008233..ee0bc0315 100644 --- a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py @@ -1,5 +1,6 @@ import asyncio +from .authentication_configuration import AuthenticationConfiguration from .verify_options import VerifyOptions from .constants import Constants from .jwt_token_extractor import JwtTokenExtractor @@ -30,6 +31,7 @@ async def authenticate_channel_token_with_service_url( credentials: CredentialProvider, service_url: str, channel_id: str, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: """ Validate the incoming Auth Header @@ -48,7 +50,7 @@ async def authenticate_channel_token_with_service_url( """ identity = await asyncio.ensure_future( ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id + auth_header, credentials, channel_id, auth_configuration ) ) @@ -63,7 +65,10 @@ async def authenticate_channel_token_with_service_url( @staticmethod async def authenticate_channel_token( - auth_header: str, credentials: CredentialProvider, channel_id: str + auth_header: str, + credentials: CredentialProvider, + channel_id: str, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: """ Validate the incoming Auth Header @@ -78,6 +83,7 @@ async def authenticate_channel_token( :return: A valid ClaimsIdentity. :raises Exception: """ + auth_configuration = auth_configuration or AuthenticationConfiguration() metadata_endpoint = ( ChannelValidation.open_id_metadata_endpoint if ChannelValidation.open_id_metadata_endpoint @@ -91,7 +97,9 @@ async def authenticate_channel_token( ) identity = await asyncio.ensure_future( - token_extractor.get_identity_from_auth_header(auth_header, channel_id) + token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements + ) ) return await ChannelValidation.validate_identity(identity, credentials) diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 37e376cd7..07c895340 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import asyncio import jwt @@ -44,27 +47,14 @@ def is_token_from_emulator(auth_header: str) -> bool: :return: True, if the token was issued by the Emulator. Otherwise, false. """ - # The Auth Header generally looks like this: - # "Bearer eyJ0e[...Big Long String...]XAiO" - if not auth_header: - # No token. Can't be an emulator token. - return False + from .jwt_token_validation import ( # pylint: disable=import-outside-toplevel + JwtTokenValidation, + ) - parts = auth_header.split(" ") - if len(parts) != 2: - # Emulator tokens MUST have exactly 2 parts. - # If we don't have 2 parts, it's not an emulator token + if not JwtTokenValidation.is_valid_token_format(auth_header): return False - auth_scheme = parts[0] - bearer_token = parts[1] - - # We now have an array that should be: - # [0] = "Bearer" - # [1] = "[Big Long String]" - if auth_scheme != "Bearer": - # The scheme from the emulator MUST be "Bearer" - return False + bearer_token = auth_header.split(" ")[1] # Parse the Big Long String into an actual token. token = jwt.decode(bearer_token, verify=False) diff --git a/libraries/botframework-connector/botframework/connector/auth/endorsements_validator.py b/libraries/botframework-connector/botframework/connector/auth/endorsements_validator.py index a9c234972..46e93234a 100644 --- a/libraries/botframework-connector/botframework/connector/auth/endorsements_validator.py +++ b/libraries/botframework-connector/botframework/connector/auth/endorsements_validator.py @@ -6,10 +6,10 @@ class EndorsementsValidator: @staticmethod - def validate(channel_id: str, endorsements: List[str]): + def validate(expected_endorsement: str, endorsements: List[str]): # If the Activity came in and doesn't have a Channel ID then it's making no # assertions as to who endorses it. This means it should pass. - if not channel_id: + if not expected_endorsement: return True if endorsements is None: @@ -31,5 +31,5 @@ def validate(channel_id: str, endorsements: List[str]): # of scope, tokens from WebChat have about 10 endorsements, and # tokens coming from Teams have about 20. - endorsement_present = channel_id in endorsements + endorsement_present = expected_endorsement in endorsements return endorsement_present diff --git a/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py index 5124b65ed..4495e00ba 100644 --- a/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py @@ -3,6 +3,7 @@ from abc import ABC +from .authentication_configuration import AuthenticationConfiguration from .authentication_constants import AuthenticationConstants from .channel_validation import ChannelValidation from .claims_identity import ClaimsIdentity @@ -26,6 +27,7 @@ async def authenticate_channel_token( credentials: CredentialProvider, channel_id: str, channel_service: str, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: endpoint = ( ChannelValidation.open_id_metadata_endpoint @@ -41,7 +43,7 @@ async def authenticate_channel_token( ) identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header( - auth_header, channel_id + auth_header, channel_id, auth_configuration.required_endorsements ) return await EnterpriseChannelValidation.validate_identity( identity, credentials @@ -54,9 +56,10 @@ async def authenticate_channel_token_with_service_url( service_url: str, channel_id: str, channel_service: str, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: identity: ClaimsIdentity = await EnterpriseChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, channel_service + auth_header, credentials, channel_id, channel_service, auth_configuration ) service_url_claim: str = identity.get_claim_value( diff --git a/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py index f4226be79..6bfd9e012 100644 --- a/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py @@ -3,6 +3,7 @@ from abc import ABC +from .authentication_configuration import AuthenticationConfiguration from .authentication_constants import AuthenticationConstants from .claims_identity import ClaimsIdentity from .credential_provider import CredentialProvider @@ -24,8 +25,12 @@ class GovernmentChannelValidation(ABC): @staticmethod async def authenticate_channel_token( - auth_header: str, credentials: CredentialProvider, channel_id: str + auth_header: str, + credentials: CredentialProvider, + channel_id: str, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: + auth_configuration = auth_configuration or AuthenticationConfiguration() endpoint = ( GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT if GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT @@ -38,7 +43,7 @@ async def authenticate_channel_token( ) identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header( - auth_header, channel_id + auth_header, channel_id, auth_configuration.required_endorsements ) return await GovernmentChannelValidation.validate_identity( identity, credentials @@ -50,9 +55,10 @@ async def authenticate_channel_token_with_service_url( credentials: CredentialProvider, service_url: str, channel_id: str, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: identity: ClaimsIdentity = await GovernmentChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id + auth_header, credentials, channel_id, auth_configuration ) service_url_claim: str = identity.get_claim_value( diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py index 043c0eccb..6f2cd5869 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py @@ -3,6 +3,7 @@ import json from datetime import datetime, timedelta +from typing import List import requests from jwt.algorithms import RSAAlgorithm import jwt @@ -33,17 +34,23 @@ def get_open_id_metadata(metadata_url: str): return metadata async def get_identity_from_auth_header( - self, auth_header: str, channel_id: str + self, auth_header: str, channel_id: str, required_endorsements: List[str] = None ) -> ClaimsIdentity: if not auth_header: return None parts = auth_header.split(" ") if len(parts) == 2: - return await self.get_identity(parts[0], parts[1], channel_id) + return await self.get_identity( + parts[0], parts[1], channel_id, required_endorsements + ) return None async def get_identity( - self, schema: str, parameter: str, channel_id + self, + schema: str, + parameter: str, + channel_id: str, + required_endorsements: List[str] = None, ) -> ClaimsIdentity: # No header in correct scheme or no token if schema != "Bearer" or not parameter: @@ -54,7 +61,9 @@ async def get_identity( return None try: - return await self._validate_token(parameter, channel_id) + return await self._validate_token( + parameter, channel_id, required_endorsements + ) except Exception as error: raise error @@ -64,9 +73,12 @@ def _has_allowed_issuer(self, jwt_token: str) -> bool: if issuer in self.validation_parameters.issuer: return True - return issuer is self.validation_parameters.issuer + return issuer == self.validation_parameters.issuer - async def _validate_token(self, jwt_token: str, channel_id: str) -> ClaimsIdentity: + async def _validate_token( + self, jwt_token: str, channel_id: str, required_endorsements: List[str] = None + ) -> ClaimsIdentity: + required_endorsements = required_endorsements or [] headers = jwt.get_unverified_header(jwt_token) # Update the signing tokens from the last refresh @@ -74,9 +86,18 @@ async def _validate_token(self, jwt_token: str, channel_id: str) -> ClaimsIdenti metadata = await self.open_id_metadata.get(key_id) if key_id and metadata.endorsements: + # Verify that channelId is included in endorsements if not EndorsementsValidator.validate(channel_id, metadata.endorsements): raise Exception("Could not validate endorsement key") + # Verify that additional endorsements are satisfied. + # If no additional endorsements are expected, the requirement is satisfied as well + for endorsement in required_endorsements: + if not EndorsementsValidator.validate( + endorsement, metadata.endorsements + ): + raise Exception("Could not validate endorsement key") + if headers.get("alg", None) not in self.validation_parameters.algorithms: raise Exception("Token signing algorithm not in allowed list") @@ -84,7 +105,14 @@ async def _validate_token(self, jwt_token: str, channel_id: str) -> ClaimsIdenti "verify_aud": False, "verify_exp": not self.validation_parameters.ignore_expiration, } - decoded_payload = jwt.decode(jwt_token, metadata.public_key, options=options) + + decoded_payload = jwt.decode( + jwt_token, + metadata.public_key, + leeway=self.validation_parameters.clock_tolerance, + options=options, + ) + claims = ClaimsIdentity(decoded_payload, True) return claims diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index b67789a36..91035413c 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -1,5 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Dict + from botbuilder.schema import Activity +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants from .emulator_validation import EmulatorValidation from .enterprise_channel_validation import EnterpriseChannelValidation from .channel_validation import ChannelValidation @@ -8,6 +14,7 @@ from .claims_identity import ClaimsIdentity from .government_constants import GovernmentConstants from .government_channel_validation import GovernmentChannelValidation +from .skill_validation import SkillValidation class JwtTokenValidation: @@ -61,13 +68,21 @@ async def validate_auth_header( channel_service: str, channel_id: str, service_url: str = None, + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: if not auth_header: raise ValueError("argument auth_header is null") - using_emulator = EmulatorValidation.is_token_from_emulator(auth_header) + if SkillValidation.is_skill_token(auth_header): + return await SkillValidation.authenticate_channel_token( + auth_header, + credentials, + channel_service, + channel_id, + auth_configuration, + ) - if using_emulator: + if EmulatorValidation.is_token_from_emulator(auth_header): return await EmulatorValidation.authenticate_emulator_token( auth_header, credentials, channel_service, channel_id ) @@ -76,31 +91,44 @@ async def validate_auth_header( if not channel_service: if service_url: return await ChannelValidation.authenticate_channel_token_with_service_url( - auth_header, credentials, service_url, channel_id + auth_header, + credentials, + service_url, + channel_id, + auth_configuration, ) return await ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id + auth_header, credentials, channel_id, auth_configuration ) if JwtTokenValidation.is_government(channel_service): if service_url: return await GovernmentChannelValidation.authenticate_channel_token_with_service_url( - auth_header, credentials, service_url, channel_id + auth_header, + credentials, + service_url, + channel_id, + auth_configuration, ) return await GovernmentChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id + auth_header, credentials, channel_id, auth_configuration ) # Otherwise use Enterprise Channel Validation if service_url: return await EnterpriseChannelValidation.authenticate_channel_token_with_service_url( - auth_header, credentials, service_url, channel_id, channel_service + auth_header, + credentials, + service_url, + channel_id, + channel_service, + auth_configuration, ) return await EnterpriseChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, channel_service + auth_header, credentials, channel_id, channel_service, auth_configuration ) @staticmethod @@ -109,3 +137,37 @@ def is_government(channel_service: str) -> bool: channel_service and channel_service.lower() == GovernmentConstants.CHANNEL_SERVICE ) + + @staticmethod + def get_app_id_from_claims(claims: Dict[str, object]) -> bool: + app_id = None + + # Depending on Version, the is either in the + # appid claim (Version 1) or the Authorized Party claim (Version 2). + token_version = claims.get(AuthenticationConstants.VERSION_CLAIM) + + if not token_version or token_version == "1.0": + # either no Version or a version of "1.0" means we should look for + # the claim in the "appid" claim. + app_id = claims.get(AuthenticationConstants.APP_ID_CLAIM) + elif token_version == "2.0": + app_id = claims.get(AuthenticationConstants.AUTHORIZED_PARTY) + + return app_id + + @staticmethod + def is_valid_token_format(auth_header: str) -> bool: + if not auth_header: + # No token. Can't be an emulator token. + return False + + parts = auth_header.split(" ") + if len(parts) != 2: + # Emulator tokens MUST have exactly 2 parts. + # If we don't have 2 parts, it's not an emulator token + return False + + auth_scheme = parts[0] + + # The scheme MUST be "Bearer" + return auth_scheme == "Bearer" diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 5998e87c3..317293ede 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -59,7 +59,13 @@ class MicrosoftAppCredentials(Authentication): } cache = {} - def __init__(self, app_id: str, password: str, channel_auth_tenant: str = None): + def __init__( + self, + app_id: str, + password: str, + channel_auth_tenant: str = None, + oauth_scope: str = None, + ): """ Initializes a new instance of MicrosoftAppCredentials class :param app_id: The Microsoft app ID. @@ -80,7 +86,7 @@ def __init__(self, app_id: str, password: str, channel_auth_tenant: str = None): + tenant + Constants.TO_CHANNEL_FROM_BOT_TOKEN_ENDPOINT_PATH ) - self.oauth_scope = AUTH_SETTINGS["refreshScope"] + self.oauth_scope = oauth_scope or AUTH_SETTINGS["refreshScope"] self.token_cache_key = app_id + "-cache" # pylint: disable=arguments-differ diff --git a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py new file mode 100644 index 000000000..8eafe0a43 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import timedelta +from typing import Dict + +import jwt + +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants +from .claims_identity import ClaimsIdentity +from .credential_provider import CredentialProvider +from .government_constants import GovernmentConstants +from .verify_options import VerifyOptions +from .jwt_token_extractor import JwtTokenExtractor + + +class SkillValidation: + # TODO: Remove circular dependcies after C# refactor + # pylint: disable=import-outside-toplevel + + """ + Validates JWT tokens sent to and from a Skill. + """ + + _token_validation_parameters = VerifyOptions( + issuer=[ + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", # Auth v3.1, 1.0 token + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", # Auth v3.1, 2.0 token + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", # Auth v3.2, 1.0 token + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", # Auth v3.2, 2.0 token + "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", # Auth for US Gov, 1.0 token + "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", # Auth for US Gov, 2.0 token + ], + audience=None, + clock_tolerance=timedelta(minutes=5), + ignore_expiration=False, + ) + + @staticmethod + def is_skill_token(auth_header: str) -> bool: + """ + Determines if a given Auth header is from from a skill to bot or bot to skill request. + :param auth_header: Bearer Token, in the "Bearer [Long String]" Format. + :return bool: + """ + from .jwt_token_validation import JwtTokenValidation + + if not JwtTokenValidation.is_valid_token_format(auth_header): + return False + + bearer_token = auth_header.split(" ")[1] + + # Parse the Big Long String into an actual token. + token = jwt.decode(bearer_token, verify=False) + return SkillValidation.is_skill_claim(token) + + @staticmethod + def is_skill_claim(claims: Dict[str, object]) -> bool: + """ + Checks if the given list of claims represents a skill. + :param claims: A dict of claims. + :return bool: + """ + if AuthenticationConstants.VERSION_CLAIM not in claims: + return False + + audience = claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + + # The audience is https://api.botframework.com and not an appId. + if ( + not audience + or audience == AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + ): + return False + + from .jwt_token_validation import JwtTokenValidation + + app_id = JwtTokenValidation.get_app_id_from_claims(claims) + + if not app_id: + return False + + # Skill claims must contain and app ID and the AppID must be different than the audience. + return app_id != audience + + @staticmethod + async def authenticate_channel_token( + auth_header: str, + credentials: CredentialProvider, + channel_service: str, + channel_id: str, + auth_configuration: AuthenticationConfiguration, + ) -> ClaimsIdentity: + if auth_configuration is None: + raise Exception( + "auth_configuration cannot be None in SkillValidation.authenticate_channel_token" + ) + + from .jwt_token_validation import JwtTokenValidation + + open_id_metadata_url = ( + GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL + if channel_service and JwtTokenValidation.is_government(channel_service) + else AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL + ) + + token_extractor = JwtTokenExtractor( + SkillValidation._token_validation_parameters, + open_id_metadata_url, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements + ) + await SkillValidation._validate_identity(identity, credentials) + + return identity + + @staticmethod + async def _validate_identity( + identity: ClaimsIdentity, credentials: CredentialProvider + ): + if not identity: + # No valid identity. Not Authorized. + raise PermissionError("Invalid Identity") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Token Not Authenticated") + + version_claim = identity.claims.get(AuthenticationConstants.VERSION_CLAIM) + if not version_claim: + # No version claim + raise PermissionError( + f"'{AuthenticationConstants.VERSION_CLAIM}' claim is required on skill Tokens." + ) + + # Look for the "aud" claim, but only if issued from the Bot Framework + audience_claim = identity.claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + if not audience_claim: + # Claim is not present or doesn't have a value. Not Authorized. + raise PermissionError( + f"'{AuthenticationConstants.AUDIENCE_CLAIM}' claim is required on skill Tokens." + ) + + if not await credentials.is_valid_appid(audience_claim): + # The AppId is not valid. Not Authorized. + raise PermissionError("Invalid audience.") + + from .jwt_token_validation import JwtTokenValidation + + app_id = JwtTokenValidation.get_app_id_from_claims(identity.claims) + if not app_id: + # Invalid AppId + raise PermissionError("Invalid app_id.") diff --git a/libraries/botframework-connector/botframework/connector/auth/verify_options.py b/libraries/botframework-connector/botframework/connector/auth/verify_options.py index 9bec402f7..5a49e5a04 100644 --- a/libraries/botframework-connector/botframework/connector/auth/verify_options.py +++ b/libraries/botframework-connector/botframework/connector/auth/verify_options.py @@ -1,6 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import timedelta +from typing import List, Union + + class VerifyOptions: def __init__(self, issuer, audience, clock_tolerance, ignore_expiration): - self.issuer = issuer - self.audience = audience - self.clock_tolerance = clock_tolerance - self.ignore_expiration = ignore_expiration + self.issuer: Union[List[str], str] = issuer or [] + self.audience: str = audience + self.clock_tolerance: Union[int, timedelta] = clock_tolerance or 0 + self.ignore_expiration: bool = ignore_expiration or False diff --git a/libraries/botframework-connector/tests/requirements.txt b/libraries/botframework-connector/tests/requirements.txt index 34e7b4527..0c8169787 100644 --- a/libraries/botframework-connector/tests/requirements.txt +++ b/libraries/botframework-connector/tests/requirements.txt @@ -1,4 +1,5 @@ pytest-cov>=2.6.0 pytest==5.2.2 azure-devtools>=0.4.1 -pytest-asyncio \ No newline at end of file +pytest-asyncio==0.10.0 +ddt==1.2.1 \ No newline at end of file diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index 2418558fb..635f00c50 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -1,18 +1,21 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - +import uuid import pytest from botbuilder.schema import Activity -from botframework.connector.auth import JwtTokenValidation -from botframework.connector.auth import SimpleCredentialProvider -from botframework.connector.auth import EmulatorValidation -from botframework.connector.auth import EnterpriseChannelValidation -from botframework.connector.auth import ChannelValidation -from botframework.connector.auth import ClaimsIdentity -from botframework.connector.auth import MicrosoftAppCredentials -from botframework.connector.auth import GovernmentConstants -from botframework.connector.auth import GovernmentChannelValidation +from botframework.connector.auth import ( + AuthenticationConstants, + JwtTokenValidation, + SimpleCredentialProvider, + EmulatorValidation, + EnterpriseChannelValidation, + ChannelValidation, + ClaimsIdentity, + MicrosoftAppCredentials, + GovernmentConstants, + GovernmentChannelValidation, +) async def jwt_token_validation_validate_auth_header_with_channel_service_succeeds( @@ -381,3 +384,28 @@ async def test_enterprise_channel_validation_wrong_audience_fails(self): credentials, ) assert "Unauthorized" in str(excinfo.value) + + def test_get_app_id_from_claims(self): + v1_claims = {} + v2_claims = {} + + app_id = str(uuid.uuid4()) + + # Empty list + assert not JwtTokenValidation.get_app_id_from_claims(v1_claims) + + # AppId there but no version (assumes v1) + v1_claims[AuthenticationConstants.APP_ID_CLAIM] = app_id + assert JwtTokenValidation.get_app_id_from_claims(v1_claims) == app_id + + # AppId there with v1 version + v1_claims[AuthenticationConstants.VERSION_CLAIM] = "1.0" + assert JwtTokenValidation.get_app_id_from_claims(v1_claims) == app_id + + # v2 version but no azp + v2_claims[AuthenticationConstants.VERSION_CLAIM] = "2.0" + assert not JwtTokenValidation.get_app_id_from_claims(v2_claims) + + # v2 version but no azp + v2_claims[AuthenticationConstants.AUTHORIZED_PARTY] = app_id + assert JwtTokenValidation.get_app_id_from_claims(v2_claims) == app_id diff --git a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py new file mode 100644 index 000000000..900fd927b --- /dev/null +++ b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py @@ -0,0 +1,33 @@ +import aiounittest + +from botframework.connector.auth import AuthenticationConstants, MicrosoftAppCredentials + + +class TestMicrosoftAppCredentials(aiounittest.AsyncTestCase): + async def test_app_credentials(self): + default_scope_case_1 = MicrosoftAppCredentials("some_app", "some_password") + assert ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + == default_scope_case_1.oauth_scope + ) + + # Use with default scope + default_scope_case_2 = MicrosoftAppCredentials( + "some_app", "some_password", "some_tenant" + ) + assert ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + == default_scope_case_2.oauth_scope + ) + + custom_scope = "some_scope" + custom_scope_case_1 = MicrosoftAppCredentials( + "some_app", "some_password", oauth_scope=custom_scope + ) + assert custom_scope_case_1.oauth_scope == custom_scope + + # Use with default scope + custom_scope_case_2 = MicrosoftAppCredentials( + "some_app", "some_password", "some_tenant", custom_scope + ) + assert custom_scope_case_2.oauth_scope == custom_scope diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py new file mode 100644 index 000000000..a32625050 --- /dev/null +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -0,0 +1,159 @@ +import uuid +from asyncio import Future +from unittest.mock import Mock, DEFAULT +import aiounittest +from ddt import data, ddt, unpack + +from botframework.connector.auth import ( + AuthenticationConstants, + ClaimsIdentity, + CredentialProvider, + SkillValidation, +) + + +def future_builder(return_val: object) -> Future: + result = Future() + result.set_result(return_val) + return result + + +@ddt +class TestSkillValidation(aiounittest.AsyncTestCase): + def test_is_skill_claim_test(self): + claims = {} + audience = str(uuid.uuid4()) + app_id = str(uuid.uuid4()) + + # Empty list of claims + assert not SkillValidation.is_skill_claim(claims) + + # No Audience claim + claims[AuthenticationConstants.VERSION_CLAIM] = "1.0" + assert not SkillValidation.is_skill_claim(claims) + + # Emulator Audience claim + claims[ + AuthenticationConstants.AUDIENCE_CLAIM + ] = AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + assert not SkillValidation.is_skill_claim(claims) + + # No AppId claim + del claims[AuthenticationConstants.AUDIENCE_CLAIM] + claims[AuthenticationConstants.AUDIENCE_CLAIM] = audience + assert not SkillValidation.is_skill_claim(claims) + + # AppId != Audience + claims[AuthenticationConstants.APP_ID_CLAIM] = audience + assert not SkillValidation.is_skill_claim(claims) + + # All checks pass, should be good now + del claims[AuthenticationConstants.AUDIENCE_CLAIM] + claims[AuthenticationConstants.AUDIENCE_CLAIM] = app_id + assert SkillValidation.is_skill_claim(claims) + + # pylint: disable=line-too-long + @data( + (False, "Failed on: Null string", None), + (False, "Failed on: Empty string", ""), + (False, "Failed on: No token part", "Bearer"), + ( + False, + "Failed on: No bearer part", + "ew0KICAiYWxnIjogIlJTMjU2IiwNCiAgImtpZCI6ICJKVzNFWGRudy13WTJFcUxyV1RxUTJyVWtCLWciLA0KICAieDV0IjogIkpXM0VYZG53LXdZMkVxTHJXVHFRMnJVa0ItZyIsDQogICJ0eXAiOiAiSldUIg0KfQ.ew0KICAic2VydmljZXVybCI6ICJodHRwczovL2RpcmVjdGxpbmUuYm90ZnJhbWV3b3JrLmNvbS8iLA0KICAibmJmIjogMTU3MTE5MDM0OCwNCiAgImV4cCI6IDE1NzExOTA5NDgsDQogICJpc3MiOiAiaHR0cHM6Ly9hcGkuYm90ZnJhbWV3b3JrLmNvbSIsDQogICJhdWQiOiAiNGMwMDM5ZTUtNjgxNi00OGU4LWIzMTMtZjc3NjkxZmYxYzVlIg0KfQ.cEVHmQCTjL9HVHGk91sja5CqjgvM7B-nArkOg4bE83m762S_le94--GBb0_7aAy6DCdvkZP0d4yWwbpfOkukEXixCDZQM2kWPcOo6lz_VIuXxHFlZAGrTvJ1QkBsg7vk-6_HR8XSLJQZoWrVhE-E_dPj4GPBKE6s1aNxYytzazbKRAEYa8Cn4iVtuYbuj4XfH8PMDv5aC0APNvfgTGk-BlIiP6AGdo4JYs62lUZVSAYg5VLdBcJYMYcKt-h2n1saeapFDVHx_tdpRuke42M4RpGH_wzICeWC5tTExWEkQWApU85HRA5zzk4OpTv17Ct13JCvQ7cD5x9RK5f7CMnbhQ", + ), + ( + False, + "Failed on: Invalid scheme", + "Potato ew0KICAiYWxnIjogIlJTMjU2IiwNCiAgImtpZCI6ICJKVzNFWGRudy13WTJFcUxyV1RxUTJyVWtCLWciLA0KICAieDV0IjogIkpXM0VYZG53LXdZMkVxTHJXVHFRMnJVa0ItZyIsDQogICJ0eXAiOiAiSldUIg0KfQ.ew0KICAic2VydmljZXVybCI6ICJodHRwczovL2RpcmVjdGxpbmUuYm90ZnJhbWV3b3JrLmNvbS8iLA0KICAibmJmIjogMTU3MTE5MDM0OCwNCiAgImV4cCI6IDE1NzExOTA5NDgsDQogICJpc3MiOiAiaHR0cHM6Ly9hcGkuYm90ZnJhbWV3b3JrLmNvbSIsDQogICJhdWQiOiAiNGMwMDM5ZTUtNjgxNi00OGU4LWIzMTMtZjc3NjkxZmYxYzVlIg0KfQ.cEVHmQCTjL9HVHGk91sja5CqjgvM7B-nArkOg4bE83m762S_le94--GBb0_7aAy6DCdvkZP0d4yWwbpfOkukEXixCDZQM2kWPcOo6lz_VIuXxHFlZAGrTvJ1QkBsg7vk-6_HR8XSLJQZoWrVhE-E_dPj4GPBKE6s1aNxYytzazbKRAEYa8Cn4iVtuYbuj4XfH8PMDv5aC0APNvfgTGk-BlIiP6AGdo4JYs62lUZVSAYg5VLdBcJYMYcKt-h2n1saeapFDVHx_tdpRuke42M4RpGH_wzICeWC5tTExWEkQWApU85HRA5zzk4OpTv17Ct13JCvQ7cD5x9RK5f7CMnbhQ", + ), + ( + False, + "Failed on: To bot v2 from webchat", + "Bearer ew0KICAiYWxnIjogIlJTMjU2IiwNCiAgImtpZCI6ICJKVzNFWGRudy13WTJFcUxyV1RxUTJyVWtCLWciLA0KICAieDV0IjogIkpXM0VYZG53LXdZMkVxTHJXVHFRMnJVa0ItZyIsDQogICJ0eXAiOiAiSldUIg0KfQ.ew0KICAic2VydmljZXVybCI6ICJodHRwczovL2RpcmVjdGxpbmUuYm90ZnJhbWV3b3JrLmNvbS8iLA0KICAibmJmIjogMTU3MTE5MDM0OCwNCiAgImV4cCI6IDE1NzExOTA5NDgsDQogICJpc3MiOiAiaHR0cHM6Ly9hcGkuYm90ZnJhbWV3b3JrLmNvbSIsDQogICJhdWQiOiAiNGMwMDM5ZTUtNjgxNi00OGU4LWIzMTMtZjc3NjkxZmYxYzVlIg0KfQ.cEVHmQCTjL9HVHGk91sja5CqjgvM7B-nArkOg4bE83m762S_le94--GBb0_7aAy6DCdvkZP0d4yWwbpfOkukEXixCDZQM2kWPcOo6lz_VIuXxHFlZAGrTvJ1QkBsg7vk-6_HR8XSLJQZoWrVhE-E_dPj4GPBKE6s1aNxYytzazbKRAEYa8Cn4iVtuYbuj4XfH8PMDv5aC0APNvfgTGk-BlIiP6AGdo4JYs62lUZVSAYg5VLdBcJYMYcKt-h2n1saeapFDVHx_tdpRuke42M4RpGH_wzICeWC5tTExWEkQWApU85HRA5zzk4OpTv17Ct13JCvQ7cD5x9RK5f7CMnbhQ", + ), + ( + False, + "Failed on: To bot v1 token from emulator", + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiI0YzMzYzQyMS1mN2QzLTRiNmMtOTkyYi0zNmU3ZTZkZTg3NjEiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9kNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIvIiwiaWF0IjoxNTcxMTg5ODczLCJuYmYiOjE1NzExODk4NzMsImV4cCI6MTU3MTE5Mzc3MywiYWlvIjoiNDJWZ1lLaWJGUDIyMUxmL0NjL1Yzai8zcGF2RUFBPT0iLCJhcHBpZCI6IjRjMzNjNDIxLWY3ZDMtNGI2Yy05OTJiLTM2ZTdlNmRlODc2MSIsImFwcGlkYWNyIjoiMSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0L2Q2ZDQ5NDIwLWYzOWItNGRmNy1hMWRjLWQ1OWE5MzU4NzFkYi8iLCJ0aWQiOiJkNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIiLCJ1dGkiOiJOdXJ3bTVOQnkwR2duT3dKRnFVREFBIiwidmVyIjoiMS4wIn0.GcKs3XZ_4GONVsAoPYI7otqUZPoNN8pULUnlJMxQa-JKXRKV0KtvTAdcMsfYudYxbz7HwcNYerFT1q3RZAimJFtfF4x_sMN23yEVxsQmYQrsf2YPmEsbCfNiEx0YEoWUdS38R1N0Iul2P_P_ZB7XreG4aR5dT6lY5TlXbhputv9pi_yAU7PB1aLuB05phQme5NwJEY22pUfx5pe1wVHogI0JyNLi-6gdoSL63DJ32tbQjr2DNYilPVtLsUkkz7fTky5OKd4p7FmG7P5EbEK4H5j04AGe_nIFs-X6x_FIS_5OSGK4LGA2RPnqa-JYpngzlNWVkUbnuH10AovcAprgdg", + ), + ( + False, + "Failed on: To bot v2 token from emulator", + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiI0YzAwMzllNS02ODE2LTQ4ZTgtYjMxMy1mNzc2OTFmZjFjNWUiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vZDZkNDk0MjAtZjM5Yi00ZGY3LWExZGMtZDU5YTkzNTg3MWRiL3YyLjAiLCJpYXQiOjE1NzExODkwMTEsIm5iZiI6MTU3MTE4OTAxMSwiZXhwIjoxNTcxMTkyOTExLCJhaW8iOiI0MlZnWUxnYWxmUE90Y2IxaEoxNzJvbmxIc3ZuQUFBPSIsImF6cCI6IjRjMDAzOWU1LTY4MTYtNDhlOC1iMzEzLWY3NzY5MWZmMWM1ZSIsImF6cGFjciI6IjEiLCJ0aWQiOiJkNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIiLCJ1dGkiOiJucEVxVTFoR1pVbXlISy1MUVdJQ0FBIiwidmVyIjoiMi4wIn0.CXcPx7LfatlRsOX4QG-jaC-guwcY3PFxpFICqwfoOTxAjHpeJNFXOpFeA3Qb5VKM6Yw5LyA9eraL5QDJB_4uMLCCKErPXMyoSm8Hw-GGZkHgFV5ciQXSXhE-IfOinqHE_0Lkt_VLR2q6ekOncnJeCR111QCqt3D8R0Ud0gvyLv_oONxDtqg7HUgNGEfioB-BDnBsO4RN7NGrWQFbyPxPmhi8a_Xc7j5Bb9jeiiIQbVaWkIrrPN31aWY1tEZLvdN0VluYlOa0EBVrzpXXZkIyWx99mpklg0lsy7mRyjuM1xydmyyGkzbiCKtODOanf8UwTjkTg5XTIluxe79_hVk2JQ", + ), + ( + True, + "Failed on: To skill valid v1 token", + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiI0YzMzYzQyMS1mN2QzLTRiNmMtOTkyYi0zNmU3ZTZkZTg3NjEiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9kNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIvIiwiaWF0IjoxNTcxMTg5NjMwLCJuYmYiOjE1NzExODk2MzAsImV4cCI6MTU3MTE5MzUzMCwiYWlvIjoiNDJWZ1lJZzY1aDFXTUVPd2JmTXIwNjM5V1lLckFBPT0iLCJhcHBpZCI6IjRjMDAzOWU1LTY4MTYtNDhlOC1iMzEzLWY3NzY5MWZmMWM1ZSIsImFwcGlkYWNyIjoiMSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0L2Q2ZDQ5NDIwLWYzOWItNGRmNy1hMWRjLWQ1OWE5MzU4NzFkYi8iLCJ0aWQiOiJkNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIiLCJ1dGkiOiJhWlpOUTY3RjRVNnNmY3d0S0R3RUFBIiwidmVyIjoiMS4wIn0.Yogk9fptxxJKO8jRkk6FrlLQsAulNNgoa0Lqv2JPkswyyizse8kcwQhxOaZOotY0UBduJ-pCcrejk6k4_O_ZReYXKz8biL9Q7Z02cU9WUMvuIGpAhttz8v0VlVSyaEJVJALc5B-U6XVUpZtG9LpE6MVror_0WMnT6T9Ijf9SuxUvdVCcmAJyZuoqudodseuFI-jtCpImEapZp0wVN4BUodrBacMbTeYjdZyAbNVBqF5gyzDztMKZR26HEz91gqulYZvJJZOJO6ejnm0j62s1tqvUVRBywvnSOon-MV0Xt2Vm0irhv6ipzTXKwWhT9rGHSLj0g8r6NqWRyPRFqLccvA", + ), + ( + True, + "Failed on: To skill valid v2 token", + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiI0YzAwMzllNS02ODE2LTQ4ZTgtYjMxMy1mNzc2OTFmZjFjNWUiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vZDZkNDk0MjAtZjM5Yi00ZGY3LWExZGMtZDU5YTkzNTg3MWRiL3YyLjAiLCJpYXQiOjE1NzExODk3NTUsIm5iZiI6MTU3MTE4OTc1NSwiZXhwIjoxNTcxMTkzNjU1LCJhaW8iOiI0MlZnWUpnZDROZkZKeG1tMTdPaVMvUk8wZll2QUE9PSIsImF6cCI6IjRjMzNjNDIxLWY3ZDMtNGI2Yy05OTJiLTM2ZTdlNmRlODc2MSIsImF6cGFjciI6IjEiLCJ0aWQiOiJkNmQ0OTQyMC1mMzliLTRkZjctYTFkYy1kNTlhOTM1ODcxZGIiLCJ1dGkiOiJMc2ZQME9JVkNVS1JzZ1IyYlFBQkFBIiwidmVyIjoiMi4wIn0.SggsEbEyXDYcg6EdhK-RA1y6S97z4hwEccXc6a3ymnHP-78frZ3N8rPLsqLoK5QPGA_cqOXsX1zduA4vlFSy3MfTV_npPfsyWa1FIse96-2_3qa9DIP8bhvOHXEVZeq-r-0iF972waFyPPC_KVYWnIgAcunGhFWvLhhOUx9dPgq7824qTq45ma1rOqRoYbhhlRn6PJDymIin5LeOzDGJJ8YVLnFUgntc6_4z0P_fnuMktzar88CUTtGvR4P7XNJhS8v9EwYQujglsJNXg7LNcwV7qOxDYWJtT_UMuMAts9ctD6FkuTGX_-6FTqmdUPPUS4RWwm4kkl96F_dXnos9JA", + ), + ) + @unpack + def test_is_skill_token_test(self, expected: bool, message: str, token: str): + assert SkillValidation.is_skill_token(token) == expected, message + + async def test_identity_validation(self): + # pylint: disable=protected-access + mock_credentials = Mock(spec=CredentialProvider) + audience = str(uuid.uuid4()) + app_id = str(uuid.uuid4()) + mock_identity = Mock(spec=ClaimsIdentity) + claims = {} + + # Null identity + with self.assertRaises(PermissionError) as exception: + await SkillValidation._validate_identity(None, mock_credentials) + assert str(exception.exception), "Invalid Identity" + + mock_identity.is_authenticated = False + # not authenticated identity + with self.assertRaises(PermissionError) as exception: + await SkillValidation._validate_identity(mock_identity, mock_credentials) + assert str(exception.exception), "Token Not Authenticated" + + # No version claims + mock_identity.is_authenticated = True + mock_identity.claims = claims + with self.assertRaises(PermissionError) as exception: + await SkillValidation._validate_identity(mock_identity, mock_credentials) + assert ( + str(exception.exception) + == f"'{AuthenticationConstants.VERSION_CLAIM}' claim is required on skill Tokens." + ) + + # No audience claim + claims[AuthenticationConstants.VERSION_CLAIM] = "1.0" + with self.assertRaises(PermissionError) as exception: + await SkillValidation._validate_identity(mock_identity, mock_credentials) + assert ( + str(exception.exception) + == f"'{AuthenticationConstants.AUDIENCE_CLAIM}' claim is required on skill Tokens." + ) + + # Invalid AppId in audience + + def validate_appid(app_id: str): + assert isinstance(app_id, str) + return DEFAULT + + claims[AuthenticationConstants.AUDIENCE_CLAIM] = audience + mock_credentials.is_valid_appid.side_effect = validate_appid + mock_credentials.is_valid_appid.return_value = future_builder(return_val=False) + with self.assertRaises(PermissionError) as exception: + await SkillValidation._validate_identity(mock_identity, mock_credentials) + assert str(exception.exception), "Invalid audience." + + # Invalid AppId in in app_id or azp + mock_credentials.is_valid_appid.return_value = future_builder(return_val=True) + with self.assertRaises(PermissionError) as exception: + await SkillValidation._validate_identity(mock_identity, mock_credentials) + assert str(exception.exception), "Invalid app_id." + + # All checks pass (no exception) + claims[AuthenticationConstants.APP_ID_CLAIM] = app_id + await SkillValidation._validate_identity(mock_identity, mock_credentials) From 259493c81458ca8592051908946f85f1e4a0882e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 25 Nov 2019 17:35:44 -0800 Subject: [PATCH 052/616] added claims validator (#447) * added claims validator * Solved PR comments * claims are dict not Claim --- .../botframework/connector/auth/__init__.py | 2 + .../auth/authentication_configuration.py | 9 +- .../connector/auth/jwt_token_validation.py | 91 +++++++++++-------- .../botframework-connector/tests/test_auth.py | 25 +++++ 4 files changed, 89 insertions(+), 38 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index 3dd269e1b..45b23659a 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -11,6 +11,7 @@ # pylint: disable=missing-docstring from .microsoft_app_credentials import * +from .claims_identity import * from .jwt_token_validation import * from .credential_provider import * from .channel_validation import * @@ -18,3 +19,4 @@ from .jwt_token_extractor import * from .government_constants import * from .authentication_constants import * +from .authentication_configuration import * diff --git a/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py b/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py index f60cff190..59642d9ff 100644 --- a/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py +++ b/libraries/botframework-connector/botframework/connector/auth/authentication_configuration.py @@ -1,9 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List +from typing import Awaitable, Callable, Dict, List class AuthenticationConfiguration: - def __init__(self, required_endorsements: List[str] = None): + def __init__( + self, + required_endorsements: List[str] = None, + claims_validator: Callable[[List[Dict]], Awaitable] = None, + ): self.required_endorsements = required_endorsements or [] + self.claims_validator = claims_validator diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index 91035413c..d3b1c86c3 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Dict +from typing import Dict, List from botbuilder.schema import Activity @@ -73,63 +73,82 @@ async def validate_auth_header( if not auth_header: raise ValueError("argument auth_header is null") - if SkillValidation.is_skill_token(auth_header): - return await SkillValidation.authenticate_channel_token( - auth_header, - credentials, - channel_service, - channel_id, - auth_configuration, - ) - - if EmulatorValidation.is_token_from_emulator(auth_header): - return await EmulatorValidation.authenticate_emulator_token( - auth_header, credentials, channel_service, channel_id - ) - - # If the channel is Public Azure - if not channel_service: - if service_url: - return await ChannelValidation.authenticate_channel_token_with_service_url( + async def get_claims() -> ClaimsIdentity: + if SkillValidation.is_skill_token(auth_header): + return await SkillValidation.authenticate_channel_token( auth_header, credentials, - service_url, + channel_service, channel_id, auth_configuration, ) - return await ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) + if EmulatorValidation.is_token_from_emulator(auth_header): + return await EmulatorValidation.authenticate_emulator_token( + auth_header, credentials, channel_service, channel_id + ) + + # If the channel is Public Azure + if not channel_service: + if service_url: + return await ChannelValidation.authenticate_channel_token_with_service_url( + auth_header, + credentials, + service_url, + channel_id, + auth_configuration, + ) + + return await ChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration + ) - if JwtTokenValidation.is_government(channel_service): + if JwtTokenValidation.is_government(channel_service): + if service_url: + return await GovernmentChannelValidation.authenticate_channel_token_with_service_url( + auth_header, + credentials, + service_url, + channel_id, + auth_configuration, + ) + + return await GovernmentChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration + ) + + # Otherwise use Enterprise Channel Validation if service_url: - return await GovernmentChannelValidation.authenticate_channel_token_with_service_url( + return await EnterpriseChannelValidation.authenticate_channel_token_with_service_url( auth_header, credentials, service_url, channel_id, + channel_service, auth_configuration, ) - return await GovernmentChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) - - # Otherwise use Enterprise Channel Validation - if service_url: - return await EnterpriseChannelValidation.authenticate_channel_token_with_service_url( + return await EnterpriseChannelValidation.authenticate_channel_token( auth_header, credentials, - service_url, channel_id, channel_service, auth_configuration, ) - return await EnterpriseChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, channel_service, auth_configuration - ) + claims = await get_claims() + + if claims: + await JwtTokenValidation.validate_claims(auth_configuration, claims.claims) + + return claims + + @staticmethod + async def validate_claims( + auth_config: AuthenticationConfiguration, claims: List[Dict] + ): + if auth_config and auth_config.claims_validator: + await auth_config.claims_validator(claims) @staticmethod def is_government(channel_service: str) -> bool: diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index 635f00c50..83e88d985 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -1,10 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import uuid +from typing import Dict, List +from unittest.mock import Mock + import pytest from botbuilder.schema import Activity from botframework.connector.auth import ( + AuthenticationConfiguration, AuthenticationConstants, JwtTokenValidation, SimpleCredentialProvider, @@ -40,6 +44,27 @@ class TestAuth: True ) + @pytest.mark.asyncio + async def test_claims_validation(self): + claims: List[Dict] = [] + default_auth_config = AuthenticationConfiguration() + + # No validator should pass. + await JwtTokenValidation.validate_claims(default_auth_config, claims) + + # ClaimsValidator configured but no exception should pass. + mock_validator = Mock() + auth_with_validator = AuthenticationConfiguration( + claims_validator=mock_validator + ) + + # Configure IClaimsValidator to fail + mock_validator.side_effect = PermissionError("Invalid claims.") + with pytest.raises(PermissionError) as excinfo: + await JwtTokenValidation.validate_claims(auth_with_validator, claims) + + assert "Invalid claims." in str(excinfo.value) + @pytest.mark.asyncio async def test_connector_auth_header_correct_app_id_and_service_url_should_validate( self, From e5254b6580805adcc1682fe2fdafc972e4036a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 26 Nov 2019 11:22:26 -0800 Subject: [PATCH 053/616] added ChannelProvider (#451) --- .../botframework/connector/auth/__init__.py | 5 +- .../connector/auth/channel_provider.py | 24 ++++++ .../connector/auth/emulator_validation.py | 15 ++-- .../auth/enterprise_channel_validation.py | 16 +++- .../connector/auth/jwt_token_validation.py | 33 +++++--- .../connector/auth/simple_channel_provider.py | 25 ++++++ .../connector/auth/skill_validation.py | 12 ++- .../botframework-connector/tests/test_auth.py | 77 ++++++++++++++++++- 8 files changed, 180 insertions(+), 27 deletions(-) create mode 100644 libraries/botframework-connector/botframework/connector/auth/channel_provider.py create mode 100644 libraries/botframework-connector/botframework/connector/auth/simple_channel_provider.py diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index 45b23659a..6d6b0b63c 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -9,7 +9,9 @@ # regenerated. # -------------------------------------------------------------------------- # pylint: disable=missing-docstring - +from .government_constants import * +from .channel_provider import * +from .simple_channel_provider import * from .microsoft_app_credentials import * from .claims_identity import * from .jwt_token_validation import * @@ -17,6 +19,5 @@ from .channel_validation import * from .emulator_validation import * from .jwt_token_extractor import * -from .government_constants import * from .authentication_constants import * from .authentication_configuration import * diff --git a/libraries/botframework-connector/botframework/connector/auth/channel_provider.py b/libraries/botframework-connector/botframework/connector/auth/channel_provider.py new file mode 100644 index 000000000..9c75b10d8 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/channel_provider.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + + +class ChannelProvider(ABC): + """ + ChannelProvider interface. This interface allows Bots to provide their own + implementation for the configuration parameters to connect to a Bot. + Framework channel service. + """ + + @abstractmethod + async def get_channel_service(self) -> str: + raise NotImplementedError() + + @abstractmethod + def is_government(self) -> bool: + raise NotImplementedError() + + @abstractmethod + def is_public_azure(self) -> bool: + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 07c895340..2657e6222 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. import asyncio +from typing import Union + import jwt from .jwt_token_extractor import JwtTokenExtractor @@ -10,6 +12,7 @@ from .credential_provider import CredentialProvider from .claims_identity import ClaimsIdentity from .government_constants import GovernmentConstants +from .channel_provider import ChannelProvider class EmulatorValidation: @@ -82,7 +85,7 @@ def is_token_from_emulator(auth_header: str) -> bool: async def authenticate_emulator_token( auth_header: str, credentials: CredentialProvider, - channel_service: str, + channel_service_or_provider: Union[str, ChannelProvider], channel_id: str, ) -> ClaimsIdentity: """ Validate the incoming Auth Header @@ -101,12 +104,14 @@ async def authenticate_emulator_token( # pylint: disable=import-outside-toplevel from .jwt_token_validation import JwtTokenValidation + if isinstance(channel_service_or_provider, ChannelProvider): + is_gov = channel_service_or_provider.is_government() + else: + is_gov = JwtTokenValidation.is_government(channel_service_or_provider) + open_id_metadata = ( GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL - if ( - channel_service is not None - and JwtTokenValidation.is_government(channel_service) - ) + if is_gov else Constants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL ) diff --git a/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py index 4495e00ba..54eb9ab80 100644 --- a/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py @@ -2,10 +2,12 @@ # Licensed under the MIT License. from abc import ABC +from typing import Union from .authentication_configuration import AuthenticationConfiguration from .authentication_constants import AuthenticationConstants from .channel_validation import ChannelValidation +from .channel_provider import ChannelProvider from .claims_identity import ClaimsIdentity from .credential_provider import CredentialProvider from .jwt_token_extractor import JwtTokenExtractor @@ -26,9 +28,13 @@ async def authenticate_channel_token( auth_header: str, credentials: CredentialProvider, channel_id: str, - channel_service: str, + channel_service_or_provider: Union[str, ChannelProvider], auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: + channel_service = channel_service_or_provider + if isinstance(channel_service_or_provider, ChannelProvider): + channel_service = await channel_service_or_provider.get_channel_service() + endpoint = ( ChannelValidation.open_id_metadata_endpoint if ChannelValidation.open_id_metadata_endpoint @@ -55,11 +61,15 @@ async def authenticate_channel_token_with_service_url( credentials: CredentialProvider, service_url: str, channel_id: str, - channel_service: str, + channel_service_or_provider: Union[str, ChannelProvider], auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: identity: ClaimsIdentity = await EnterpriseChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, channel_service, auth_configuration + auth_header, + credentials, + channel_id, + channel_service_or_provider, + auth_configuration, ) service_url_claim: str = identity.get_claim_value( diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index d3b1c86c3..ef080c5d4 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Dict, List +from typing import Dict, List, Union from botbuilder.schema import Activity @@ -15,6 +15,7 @@ from .government_constants import GovernmentConstants from .government_channel_validation import GovernmentChannelValidation from .skill_validation import SkillValidation +from .channel_provider import ChannelProvider class JwtTokenValidation: @@ -25,7 +26,7 @@ async def authenticate_request( activity: Activity, auth_header: str, credentials: CredentialProvider, - channel_service: str = "", + channel_service_or_provider: Union[str, ChannelProvider] = "", ) -> ClaimsIdentity: """Authenticates the request and sets the service url in the set of trusted urls. :param activity: The incoming Activity from the Bot Framework or the Emulator @@ -51,7 +52,7 @@ async def authenticate_request( claims_identity = await JwtTokenValidation.validate_auth_header( auth_header, credentials, - channel_service, + channel_service_or_provider, activity.channel_id, activity.service_url, ) @@ -65,7 +66,7 @@ async def authenticate_request( async def validate_auth_header( auth_header: str, credentials: CredentialProvider, - channel_service: str, + channel_service_or_provider: Union[str, ChannelProvider], channel_id: str, service_url: str = None, auth_configuration: AuthenticationConfiguration = None, @@ -78,18 +79,30 @@ async def get_claims() -> ClaimsIdentity: return await SkillValidation.authenticate_channel_token( auth_header, credentials, - channel_service, + channel_service_or_provider, channel_id, auth_configuration, ) if EmulatorValidation.is_token_from_emulator(auth_header): return await EmulatorValidation.authenticate_emulator_token( - auth_header, credentials, channel_service, channel_id + auth_header, credentials, channel_service_or_provider, channel_id ) + is_public = ( + not channel_service_or_provider + or isinstance(channel_service_or_provider, ChannelProvider) + and channel_service_or_provider.is_public_azure() + ) + is_gov = ( + isinstance(channel_service_or_provider, ChannelProvider) + and channel_service_or_provider.is_public_azure() + or isinstance(channel_service_or_provider, str) + and JwtTokenValidation.is_government(channel_service_or_provider) + ) + # If the channel is Public Azure - if not channel_service: + if is_public: if service_url: return await ChannelValidation.authenticate_channel_token_with_service_url( auth_header, @@ -103,7 +116,7 @@ async def get_claims() -> ClaimsIdentity: auth_header, credentials, channel_id, auth_configuration ) - if JwtTokenValidation.is_government(channel_service): + if is_gov: if service_url: return await GovernmentChannelValidation.authenticate_channel_token_with_service_url( auth_header, @@ -124,7 +137,7 @@ async def get_claims() -> ClaimsIdentity: credentials, service_url, channel_id, - channel_service, + channel_service_or_provider, auth_configuration, ) @@ -132,7 +145,7 @@ async def get_claims() -> ClaimsIdentity: auth_header, credentials, channel_id, - channel_service, + channel_service_or_provider, auth_configuration, ) diff --git a/libraries/botframework-connector/botframework/connector/auth/simple_channel_provider.py b/libraries/botframework-connector/botframework/connector/auth/simple_channel_provider.py new file mode 100644 index 000000000..a64998833 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/simple_channel_provider.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .channel_provider import ChannelProvider +from .government_constants import GovernmentConstants + + +class SimpleChannelProvider(ChannelProvider): + """ + ChannelProvider interface. This interface allows Bots to provide their own + implementation for the configuration parameters to connect to a Bot. + Framework channel service. + """ + + def __init__(self, channel_service: str = None): + self.channel_service = channel_service + + async def get_channel_service(self) -> str: + return self.channel_service + + def is_government(self) -> bool: + return self.channel_service == GovernmentConstants.CHANNEL_SERVICE + + def is_public_azure(self) -> bool: + return not self.channel_service diff --git a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py index 8eafe0a43..a9028b34e 100644 --- a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from datetime import timedelta -from typing import Dict +from typing import Dict, Union import jwt @@ -13,6 +13,7 @@ from .government_constants import GovernmentConstants from .verify_options import VerifyOptions from .jwt_token_extractor import JwtTokenExtractor +from .channel_provider import ChannelProvider class SkillValidation: @@ -88,7 +89,7 @@ def is_skill_claim(claims: Dict[str, object]) -> bool: async def authenticate_channel_token( auth_header: str, credentials: CredentialProvider, - channel_service: str, + channel_service_or_provider: Union[str, ChannelProvider], channel_id: str, auth_configuration: AuthenticationConfiguration, ) -> ClaimsIdentity: @@ -99,9 +100,14 @@ async def authenticate_channel_token( from .jwt_token_validation import JwtTokenValidation + if isinstance(channel_service_or_provider, ChannelProvider): + is_gov = channel_service_or_provider.is_government() + else: + is_gov = JwtTokenValidation.is_government(channel_service_or_provider) + open_id_metadata_url = ( GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL - if channel_service and JwtTokenValidation.is_government(channel_service) + if is_gov else AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL ) diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index 83e88d985..a05b88796 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import uuid -from typing import Dict, List +from typing import Dict, List, Union from unittest.mock import Mock import pytest @@ -19,23 +19,33 @@ MicrosoftAppCredentials, GovernmentConstants, GovernmentChannelValidation, + SimpleChannelProvider, + ChannelProvider, ) async def jwt_token_validation_validate_auth_header_with_channel_service_succeeds( - app_id: str, pwd: str, channel_service: str, header: str = None + app_id: str, + pwd: str, + channel_service_or_provider: Union[str, ChannelProvider], + header: str = None, ): if header is None: header = f"Bearer {MicrosoftAppCredentials(app_id, pwd).get_access_token()}" credentials = SimpleCredentialProvider(app_id, pwd) result = await JwtTokenValidation.validate_auth_header( - header, credentials, channel_service, "", "https://webchat.botframework.com/" + header, + credentials, + channel_service_or_provider, + "", + "https://webchat.botframework.com/", ) assert result.is_authenticated +# TODO: Consider changing to unittest to use ddt for Credentials tests class TestAuth: EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS.ignore_expiration = ( True @@ -82,7 +92,15 @@ async def test_connector_auth_header_correct_app_id_and_service_url_should_valid header, credentials, "", "https://webchat.botframework.com/" ) + result_with_provider = await JwtTokenValidation.validate_auth_header( + header, + credentials, + SimpleChannelProvider(), + "https://webchat.botframework.com/", + ) + assert result + assert result_with_provider @pytest.mark.asyncio async def test_connector_auth_header_with_different_bot_app_id_should_not_validate( @@ -103,6 +121,15 @@ async def test_connector_auth_header_with_different_bot_app_id_should_not_valida ) assert "Unauthorized" in str(excinfo.value) + with pytest.raises(Exception) as excinfo2: + await JwtTokenValidation.validate_auth_header( + header, + credentials, + SimpleChannelProvider(), + "https://webchat.botframework.com/", + ) + assert "Unauthorized" in str(excinfo2.value) + @pytest.mark.asyncio async def test_connector_auth_header_and_no_credential_should_not_validate(self): header = ( @@ -118,6 +145,15 @@ async def test_connector_auth_header_and_no_credential_should_not_validate(self) ) assert "Unauthorized" in str(excinfo.value) + with pytest.raises(Exception) as excinfo2: + await JwtTokenValidation.validate_auth_header( + header, + credentials, + SimpleChannelProvider(), + "https://webchat.botframework.com/", + ) + assert "Unauthorized" in str(excinfo2.value) + @pytest.mark.asyncio async def test_empty_header_and_no_credential_should_throw(self): header = "" @@ -126,6 +162,12 @@ async def test_empty_header_and_no_credential_should_throw(self): await JwtTokenValidation.validate_auth_header(header, credentials, "", None) assert "auth_header" in str(excinfo.value) + with pytest.raises(Exception) as excinfo2: + await JwtTokenValidation.validate_auth_header( + header, credentials, SimpleChannelProvider(), None + ) + assert "auth_header" in str(excinfo2.value) + @pytest.mark.asyncio async def test_emulator_msa_header_correct_app_id_and_service_url_should_validate( self, @@ -143,10 +185,19 @@ async def test_emulator_msa_header_correct_app_id_and_service_url_should_validat header, credentials, "", "https://webchat.botframework.com/" ) + result_with_provider = await JwtTokenValidation.validate_auth_header( + header, + credentials, + SimpleChannelProvider(), + "https://webchat.botframework.com/", + ) + assert result + assert result_with_provider @pytest.mark.asyncio async def test_emulator_msa_header_and_no_credential_should_not_validate(self): + # pylint: disable=protected-access header = ( "Bearer " + MicrosoftAppCredentials( @@ -158,7 +209,13 @@ async def test_emulator_msa_header_and_no_credential_should_not_validate(self): ) with pytest.raises(Exception) as excinfo: await JwtTokenValidation.validate_auth_header(header, credentials, "", None) - assert "Unauthorized" in excinfo + assert "Unauthorized" in str(excinfo._excinfo) + + with pytest.raises(Exception) as excinfo2: + await JwtTokenValidation.validate_auth_header( + header, credentials, SimpleChannelProvider(), None + ) + assert "Unauthorized" in str(excinfo2._excinfo) # Tests with a valid Token and service url; and ensures that Service url is added to Trusted service url list. @pytest.mark.asyncio @@ -262,6 +319,12 @@ async def test_emulator_auth_header_correct_app_id_and_service_url_with_gov_chan GovernmentConstants.CHANNEL_SERVICE, ) + await jwt_token_validation_validate_auth_header_with_channel_service_succeeds( + "2cd87869-38a0-4182-9251-d056e8f0ac24", # emulator creds + "2.30Vs3VQLKt974F", + SimpleChannelProvider(GovernmentConstants.CHANNEL_SERVICE), + ) + @pytest.mark.asyncio async def test_emulator_auth_header_correct_app_id_and_service_url_with_private_channel_service_should_validate( self, @@ -272,6 +335,12 @@ async def test_emulator_auth_header_correct_app_id_and_service_url_with_private_ "TheChannel", ) + await jwt_token_validation_validate_auth_header_with_channel_service_succeeds( + "2cd87869-38a0-4182-9251-d056e8f0ac24", # emulator creds + "2.30Vs3VQLKt974F", + SimpleChannelProvider("TheChannel"), + ) + @pytest.mark.asyncio async def test_government_channel_validation_succeeds(self): credentials = SimpleCredentialProvider( From 2bfacd5dd1c151114b8b2baf7b0c0bcc8205095a Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Tue, 26 Nov 2019 16:29:01 -0800 Subject: [PATCH 054/616] Merging Teams in so far (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial commit for Teams work * initial commit for Teams * adding teams activity handler, team info, and teams channel account classes * adding conversation update scenario * fixing linting issues * updating classes to use standard attrs * cleaning up PR feedback * adding line * adding another blank line * adding mentions bot and fixing bug for resource response IDs * Threading helper workaround * Corrected case of "teams" folder name in core. Corrected __init__.py in schema so TeamsChannelAccount was defined. * adding mention bot updating mention bot cleaning up linter removing readme, removing self from on_error * resolving merge conflict * adding mention bot cleaning up linter * updating linting * adding mention bot updating mention bot cleaning up linter removing readme, removing self from on_error * resolving merge conflict * adding mention bot cleaning up linter * updating linting * Added 43.complex-dialog * Pinned dependencies in all libraries * adding activity update and delete * adding list for activities * cleaning up config * Pinned dependencies in libraries (missed some setup.py) * modify echo to work out of the box w/ ARM template * Added 47.inspection (#381) * Added 47.inspection, corrected README in 45.state-management * Changed the on_error function to be unbound for consistency. * ChoiceFactory.for_channel was erroneously returning a List instead of an Activity (#383) * Refactored to unbound on_error methods when accessing outer app.py va… (#385) * Refactored to unbound on_error methods when accessing outer app.py variables. * Removed unused imports * Added 16.proactive-messages (#413) * Added 19.custom-dialogs (#411) * Fix ChoicePrompt ListStyle.none when set via PromptOptions (#373) * fix ChoicePrompt none style when set via options * black compat * Added 18.bot-authentication (#419) * Added 17.multilingual-bot * Added 23.facebook-events sample * 23.facebook-events: on_error is now an unbound function * Partial 15.handling-attachments * Removing unnecesary encoding * Added 15.handling-attachments * 17.multilingual-bot suggested corrections * 15.handling-attachments suggested corrections * pylint and black, suggested corrections. * pylint and black changes. No logic changes. (#427) * Fixes #425: Using incorrect BotState (#426) * Added send_activities and updated the logic * pylint: Added send_activities and updated the logic * pylint: Added send_activities and updated the logic * black formatter: Added send_activities and updated the logic * Added 11.qnamaker (#429) * Added 40.timex resolution (#430) * Unfinished push until recognizers-text is updated. * Added 40.timex-resolution * Added 42.scaleout (#435) * Pinned pytest version (#438) * updating linting * fixing linting * initial commit for Teams work * initial commit for Teams * adding teams activity handler, team info, and teams channel account classes * adding conversation update scenario * fixing linting issues * updating classes to use standard attrs * cleaning up PR feedback * adding line * adding another blank line * Corrected case of "teams" folder name in core. Corrected __init__.py in schema so TeamsChannelAccount was defined. * removing extension file * resovling conflict * more merge conflict resolution * fixing linting * fixing conflicts * adding updated teams activity handler * updating None check * updating activity handler and fixing spacing issue * updating activity handler and tests * updating teams activity handler * removing constant * adding tests and removing constant * moving scenarios to root * updating attr check, using .seralize(), removing return * rerunnign black * updating names * updating loop to downcast * member not memeber * adding s --- .../botbuilder/core/bot_framework_adapter.py | 2 +- .../botbuilder/core/teams/__init__.py | 10 + .../core/teams/teams_activity_handler.py | 396 ++++++++++++++++++ .../teams/test_teams_activity_handler.py | 100 +++++ .../schema/_connector_client_enums.py | 1 + .../botbuilder/schema/teams/__init__.py | 15 + .../botbuilder/schema/teams/channel_info.py | 13 + .../schema/teams/notification_info.py | 12 + .../botbuilder/schema/teams/team_info.py | 14 + .../schema/teams/teams_channel_account.py | 31 ++ .../schema/teams/teams_channel_data.py | 30 ++ .../botbuilder/schema/teams/tenant_info.py | 12 + .../activity-update-and-delete/README.md | 30 ++ scenarios/activity-update-and-delete/app.py | 92 ++++ .../bots/__init__.py | 6 + .../bots/activity_update_and_delete_bot.py | 33 ++ .../activity-update-and-delete/config.py | 13 + .../requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 43 ++ .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes scenarios/conversation-update/README.md | 30 ++ scenarios/conversation-update/app.py | 92 ++++ .../conversation-update/bots/__init__.py | 6 + .../bots/conversation_update_bot.py | 56 +++ scenarios/conversation-update/config.py | 13 + .../conversation-update/requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 43 ++ .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes scenarios/mentions/README.md | 30 ++ scenarios/mentions/app.py | 92 ++++ scenarios/mentions/bots/__init__.py | 6 + scenarios/mentions/bots/mention_bot.py | 21 + scenarios/mentions/config.py | 13 + scenarios/mentions/requirements.txt | 2 + .../mentions/teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../mentions/teams_app_manifest/manifest.json | 43 ++ .../mentions/teams_app_manifest/outline.png | Bin 0 -> 383 bytes scenarios/message-reactions/README.md | 30 ++ scenarios/message-reactions/activity_log.py | 27 ++ scenarios/message-reactions/app.py | 94 +++++ scenarios/message-reactions/bots/__init__.py | 6 + .../bots/message_reaction_bot.py | 60 +++ scenarios/message-reactions/config.py | 13 + scenarios/message-reactions/requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 43 ++ .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes .../message-reactions/threading_helper.py | 169 ++++++++ 50 files changed, 1747 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/teams/__init__.py create mode 100644 libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py create mode 100644 libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py create mode 100644 scenarios/activity-update-and-delete/README.md create mode 100644 scenarios/activity-update-and-delete/app.py create mode 100644 scenarios/activity-update-and-delete/bots/__init__.py create mode 100644 scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py create mode 100644 scenarios/activity-update-and-delete/config.py create mode 100644 scenarios/activity-update-and-delete/requirements.txt create mode 100644 scenarios/activity-update-and-delete/teams_app_manifest/color.png create mode 100644 scenarios/activity-update-and-delete/teams_app_manifest/manifest.json create mode 100644 scenarios/activity-update-and-delete/teams_app_manifest/outline.png create mode 100644 scenarios/conversation-update/README.md create mode 100644 scenarios/conversation-update/app.py create mode 100644 scenarios/conversation-update/bots/__init__.py create mode 100644 scenarios/conversation-update/bots/conversation_update_bot.py create mode 100644 scenarios/conversation-update/config.py create mode 100644 scenarios/conversation-update/requirements.txt create mode 100644 scenarios/conversation-update/teams_app_manifest/color.png create mode 100644 scenarios/conversation-update/teams_app_manifest/manifest.json create mode 100644 scenarios/conversation-update/teams_app_manifest/outline.png create mode 100644 scenarios/mentions/README.md create mode 100644 scenarios/mentions/app.py create mode 100644 scenarios/mentions/bots/__init__.py create mode 100644 scenarios/mentions/bots/mention_bot.py create mode 100644 scenarios/mentions/config.py create mode 100644 scenarios/mentions/requirements.txt create mode 100644 scenarios/mentions/teams_app_manifest/color.png create mode 100644 scenarios/mentions/teams_app_manifest/manifest.json create mode 100644 scenarios/mentions/teams_app_manifest/outline.png create mode 100644 scenarios/message-reactions/README.md create mode 100644 scenarios/message-reactions/activity_log.py create mode 100644 scenarios/message-reactions/app.py create mode 100644 scenarios/message-reactions/bots/__init__.py create mode 100644 scenarios/message-reactions/bots/message_reaction_bot.py create mode 100644 scenarios/message-reactions/config.py create mode 100644 scenarios/message-reactions/requirements.txt create mode 100644 scenarios/message-reactions/teams_app_manifest/color.png create mode 100644 scenarios/message-reactions/teams_app_manifest/manifest.json create mode 100644 scenarios/message-reactions/teams_app_manifest/outline.png create mode 100644 scenarios/message-reactions/threading_helper.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 5a38be990..a7727956d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -12,8 +12,8 @@ ConversationAccount, ConversationParameters, ConversationReference, - ResourceResponse, TokenResponse, + ResourceResponse, ) from botframework.connector import Channels, EmulatorApiClient from botframework.connector.aio import ConnectorClient diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py new file mode 100644 index 000000000..6683b49a0 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py @@ -0,0 +1,10 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .teams_activity_handler import TeamsActivityHandler + +__all__ = ["TeamsActivityHandler"] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py new file mode 100644 index 000000000..04d7389aa --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -0,0 +1,396 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from http import HTTPStatus +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount +from botbuilder.core.turn_context import TurnContext +from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter +from botbuilder.schema.teams import ( + TeamInfo, + ChannelInfo, + TeamsChannelData, + TeamsChannelAccount, +) +from botframework.connector import Channels + + +class TeamsActivityHandler(ActivityHandler): + async def on_turn(self, turn_context: TurnContext): + if turn_context is None: + raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") + + if not getattr(turn_context, "activity", None): + raise TypeError( + "ActivityHandler.on_turn(): turn_context must have a non-None activity." + ) + + if not getattr(turn_context.activity, "type", None): + raise TypeError( + "ActivityHandler.on_turn(): turn_context activity must have a non-None type." + ) + + if turn_context.activity.type == ActivityTypes.invoke: + invoke_response = await self.on_invoke_activity(turn_context) + if invoke_response and not turn_context.turn_state.get( + BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access + ): + await turn_context.send_activity( + Activity(value=invoke_response, type=ActivityTypes.invoke_response) + ) + return + + await super().on_turn(turn_context) + + async def on_invoke_activity(self, turn_context: TurnContext): + try: + if ( + not turn_context.activity.name + and turn_context.activity.channel_id == Channels.ms_teams + ): + return await self.on_teams_card_action_invoke_activity(turn_context) + + if turn_context.activity.name == "signin/verifyState": + await self.on_teams_signin_verify_state_activity(turn_context) + return self._create_invoke_response() + + if turn_context.activity.name == "fileConsent/invoke": + return await self.on_teams_file_consent_activity( + turn_context, turn_context.activity.value + ) + + if turn_context.activity.name == "actionableMessage/executeAction": + await self.on_teams_o365_connector_card_action_activity( + turn_context, turn_context.activity.value + ) + return self._create_invoke_response() + + if turn_context.activity.name == "composeExtension/queryLink": + return self._create_invoke_response( + await self.on_teams_app_based_link_query_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "composeExtension/query": + return self._create_invoke_response( + await self.on_teams_messaging_extension_query_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "composeExtension/selectItem": + return self._create_invoke_response( + await self.on_teams_messaging_extension_select_item_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "composeExtension/submitAction": + return self._create_invoke_response( + await self.on_teams_messaging_extension_submit_action_dispatch_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "composeExtension/fetchTask": + return self._create_invoke_response( + await self.on_teams_messaging_extension_fetch_task_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "composeExtension/querySettingUrl": + return self._create_invoke_response( + await self.on_teams_messaging_extension_configuration_query_settings_url_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "composeExtension/setting": + await self.on_teams_messaging_extension_configuration_setting_activity( + turn_context, turn_context.activity.value + ) + return self._create_invoke_response() + + if turn_context.activity.name == "composeExtension/onCardButtonClicked": + await self.on_teams_messaging_extension_card_button_clicked_activity( + turn_context, turn_context.activity.value + ) + return self._create_invoke_response() + + if turn_context.activity.name == "task/fetch": + return self._create_invoke_response( + await self.on_teams_task_module_fetch_activity( + turn_context, turn_context.activity.value + ) + ) + + if turn_context.activity.name == "task/submit": + return self._create_invoke_response( + await self.on_teams_task_module_submit_activity( + turn_context, turn_context.activity.value + ) + ) + + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + except _InvokeResponseException as err: + return err.create_invoke_response() + + async def on_teams_card_action_invoke_activity(self, turn_context: TurnContext): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_signin_verify_state_activity(self, turn_context: TurnContext): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_file_consent_activity( + self, turn_context: TurnContext, file_consent_card_response + ): + if file_consent_card_response.action == "accept": + await self.on_teams_file_consent_accept_activity( + turn_context, file_consent_card_response + ) + return self._create_invoke_response() + + if file_consent_card_response.action == "decline": + await self.on_teams_file_consent_decline_activity( + turn_context, file_consent_card_response + ) + return self._create_invoke_response() + + raise _InvokeResponseException( + HTTPStatus.BAD_REQUEST, + f"{file_consent_card_response.action} is not a supported Action.", + ) + + async def on_teams_file_consent_accept_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, file_consent_card_response + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, file_consent_card_response + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_o365_connector_card_action_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, query + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_app_based_link_query_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, query + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_query_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, query + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_select_item_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, query + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_submit_action_dispatch_activity( + self, turn_context: TurnContext, action + ): + if not action: + return await self.on_teams_messaging_extension_submit_action_activity( + turn_context, action + ) + + if action.bot_message_preview_action == "edit": + return await self.on_teams_messaging_extension_bot_message_preview_edit_activity( + turn_context, action + ) + + if action.bot_message_preview_action == "send": + return await self.on_teams_messaging_extension_bot_message_send_activity( + turn_context, action + ) + + raise _InvokeResponseException( + status_code=HTTPStatus.BAD_REQUEST, + body=f"{action.bot_message_preview_action} is not a supported BotMessagePreviewAction", + ) + + async def on_teams_messaging_extension_bot_message_preview_edit_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, action + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_bot_message_send_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, action + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_submit_action_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, action + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_fetch_task_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, task_module_request + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_configuration_query_settings_url_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, query + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_configuration_setting_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, settings + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_messaging_extension_card_button_clicked_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, card_data + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_task_module_fetch_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, task_module_request + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_teams_task_module_submit_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext, task_module_request + ): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + + async def on_conversation_update_activity(self, turn_context: TurnContext): + if turn_context.activity.channel_id == Channels.ms_teams: + channel_data = TeamsChannelData(**turn_context.activity.channel_data) + + if turn_context.activity.members_added: + return await self.on_teams_members_added_dispatch_activity( + turn_context.activity.members_added, channel_data.team, turn_context + ) + + if turn_context.activity.members_removed: + return await self.on_teams_members_removed_dispatch_activity( + turn_context.activity.members_removed, + channel_data.team, + turn_context, + ) + + if channel_data: + if channel_data.event_type == "channelCreated": + return await self.on_teams_channel_created_activity( + channel_data.channel, channel_data.team, turn_context + ) + if channel_data.event_type == "channelDeleted": + return await self.on_teams_channel_deleted_activity( + channel_data.channel, channel_data.team, turn_context + ) + if channel_data.event_type == "channelRenamed": + return await self.on_teams_channel_renamed_activity( + channel_data.channel, channel_data.team, turn_context + ) + if channel_data.event_type == "teamRenamed": + return await self.on_teams_team_renamed_activity( + channel_data.team, turn_context + ) + return await super().on_conversation_update_activity(turn_context) + + return await super().on_conversation_update_activity(turn_context) + + async def on_teams_channel_created_activity( # pylint: disable=unused-argument + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return + + async def on_teams_team_renamed_activity( # pylint: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + return + + async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-argument + self, + members_added: [ChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ): + """ + team_members = {} + team_members_added = [] + for member in members_added: + if member.additional_properties != {}: + team_members_added.append(TeamsChannelAccount(member)) + else: + if team_members == {}: + result = await TeamsInfo.get_members_async(turn_context) + team_members = { i.id : i for i in result } + + if member.id in team_members: + team_members_added.append(member) + else: + newTeamsChannelAccount = TeamsChannelAccount( + id=member.id, + name = member.name, + aad_object_id = member.aad_object_id, + role = member.role + ) + team_members_added.append(newTeamsChannelAccount) + + return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) + """ + for member in members_added: + new_account_json = member.seralize() + del new_account_json["additional_properties"] + member = TeamsChannelAccount(**new_account_json) + return await self.on_teams_members_added_activity(members_added, turn_context) + + async def on_teams_members_added_activity( + self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext + ): + teams_members_added = [ChannelAccount(member) for member in teams_members_added] + return super().on_members_added_activity(teams_members_added, turn_context) + + async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument + self, + members_removed: [ChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ): + teams_members_removed = [] + for member in members_removed: + new_account_json = member.seralize() + del new_account_json["additional_properties"] + teams_members_removed.append(TeamsChannelAccount(**new_account_json)) + + return await self.on_teams_members_removed_activity( + teams_members_removed, turn_context + ) + + async def on_teams_members_removed_activity( + self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + ): + members_removed = [ChannelAccount(member) for member in teams_members_removed] + return super().on_members_removed_activity(members_removed, turn_context) + + async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return # Task.CompleteTask + + async def on_teams_channel_renamed_activity( # pylint: disable=unused-argument + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return # Task.CompleteTask + + @staticmethod + def _create_invoke_response(body: object = None) -> InvokeResponse: + return InvokeResponse(status=int(HTTPStatus.OK), body=body) + + +class _InvokeResponseException(Exception): + def __init__(self, status_code: HTTPStatus, body: object = None): + super(_InvokeResponseException, self).__init__() + self._status_code = status_code + self._body = body + + def create_invoke_response(self) -> InvokeResponse: + return InvokeResponse(status=int(self._status_code), body=self._body) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py new file mode 100644 index 000000000..87b092e09 --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -0,0 +1,100 @@ +from typing import List + +import aiounittest +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.core.teams import TeamsActivityHandler +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationReference, + MessageReaction, + ResourceResponse, +) + + +class TestingTeamsActivityHandler(TeamsActivityHandler): + def __init__(self): + self.record: List[str] = [] + + async def on_message_activity(self, turn_context: TurnContext): + self.record.append("on_message_activity") + return await super().on_message_activity(turn_context) + + async def on_members_added_activity( + self, members_added: ChannelAccount, turn_context: TurnContext + ): + self.record.append("on_members_added_activity") + return await super().on_members_added_activity(members_added, turn_context) + + async def on_members_removed_activity( + self, members_removed: ChannelAccount, turn_context: TurnContext + ): + self.record.append("on_members_removed_activity") + return await super().on_members_removed_activity(members_removed, turn_context) + + async def on_message_reaction_activity(self, turn_context: TurnContext): + self.record.append("on_message_reaction_activity") + return await super().on_message_reaction_activity(turn_context) + + async def on_reactions_added( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + self.record.append("on_reactions_added") + return await super().on_reactions_added(message_reactions, turn_context) + + async def on_reactions_removed( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + self.record.append("on_reactions_removed") + return await super().on_reactions_removed(message_reactions, turn_context) + + async def on_token_response_event(self, turn_context: TurnContext): + self.record.append("on_token_response_event") + return await super().on_token_response_event(turn_context) + + async def on_event(self, turn_context: TurnContext): + self.record.append("on_event") + return await super().on_event(turn_context) + + async def on_unrecognized_activity_type(self, turn_context: TurnContext): + self.record.append("on_unrecognized_activity_type") + return await super().on_unrecognized_activity_type(turn_context) + + +class NotImplementedAdapter(BotAdapter): + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + raise NotImplementedError() + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + raise NotImplementedError() + + async def update_activity(self, context: TurnContext, activity: Activity): + raise NotImplementedError() + + +class TestTeamsActivityHandler(aiounittest.AsyncTestCase): + async def test_message_reaction(self): + # Note the code supports multiple adds and removes in the same activity though + # a channel may decide to send separate activities for each. For example, Teams + # sends separate activities each with a single add and a single remove. + + # Arrange + activity = Activity( + type=ActivityTypes.message_reaction, + reactions_added=[MessageReaction(type="sad")], + ) + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_message_reaction_activity" + assert bot.record[1] == "on_reactions_added" diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index 605600aa9..300ccddd8 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -27,6 +27,7 @@ class ActivityTypes(str, Enum): end_of_conversation = "endOfConversation" event = "event" invoke = "invoke" + invoke_response = "invokeResponse" delete_user_data = "deleteUserData" message_update = "messageUpdate" message_delete = "messageDelete" diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py new file mode 100644 index 000000000..d299ec66c --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -0,0 +1,15 @@ +from .team_info import TeamInfo +from .notification_info import NotificationInfo +from .tenant_info import TenantInfo +from .channel_info import ChannelInfo +from .teams_channel_data import TeamsChannelData +from .teams_channel_account import TeamsChannelAccount + +__all__ = [ + "TeamInfo", + "ChannelInfo", + "TeamsChannelData", + "TeamsChannelAccount", + "TenantInfo", + "NotificationInfo", +] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py new file mode 100644 index 000000000..6125698c3 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. + + +class ChannelInfo(object): + def __init__(self, id="", name=""): + self.id = id + self.name = name diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py new file mode 100644 index 000000000..dd55a69c7 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. + + +class NotificationInfo: + def __init__(self, alert: bool = False): + self.alert = alert diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py new file mode 100644 index 000000000..316ae89c2 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. + + +class TeamInfo: + def __init__(self, id="", name="", aadGroupId=""): + self.id = id + self.name = name + self.aad_group_id = aadGroupId diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py new file mode 100644 index 000000000..a2354effd --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. + +from botbuilder.schema import ChannelAccount + + +class TeamsChannelAccount(ChannelAccount): + def __init__( + self, + id="", + name="", + aad_object_id="", + role="", + given_name="", + surname="", + email="", + userPrincipalName="", + ): + super().__init__( + **{"id": id, "name": name, "aad_object_id": aad_object_id, "role": role} + ) + self.given_name = given_name + self.surname = surname + self.email = email + # This isn't camel_cased because the JSON that makes this object isn't camel_case + self.user_principal_name = userPrincipalName diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py new file mode 100644 index 000000000..24001d00c --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. + +from botbuilder.schema.teams import ChannelInfo, TeamInfo, NotificationInfo, TenantInfo + + +class TeamsChannelData: + def __init__( + self, + channel: ChannelInfo = None, + eventType="", + team: TeamInfo = None, + notification: NotificationInfo = None, + tenant: TenantInfo = None, + ): + self.channel = ChannelInfo(**channel) if channel is not None else ChannelInfo() + # This is not camel case because the JSON that makes this object isn't + self.event_type = eventType + self.team = TeamInfo(**team) if team is not None else TeamInfo() + self.notification = ( + NotificationInfo(**notification) + if notification is not None + else NotificationInfo() + ) + self.tenant = TenantInfo(**tenant) if tenant is not None else TenantInfo() diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py new file mode 100644 index 000000000..2b47e81a0 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. + + +class TenantInfo: + def __init__(self, id=""): + self._id = id diff --git a/scenarios/activity-update-and-delete/README.md b/scenarios/activity-update-and-delete/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/scenarios/activity-update-and-delete/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/activity-update-and-delete/app.py b/scenarios/activity-update-and-delete/app.py new file mode 100644 index 000000000..166cee39d --- /dev/null +++ b/scenarios/activity-update-and-delete/app.py @@ -0,0 +1,92 @@ +# 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 ActivitiyUpdateAndDeleteBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) +ACTIVITY_IDS = [] +# Create the Bot +BOT = ActivitiyUpdateAndDeleteBot(ACTIVITY_IDS) + +# 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/activity-update-and-delete/bots/__init__.py b/scenarios/activity-update-and-delete/bots/__init__.py new file mode 100644 index 000000000..e6c728a12 --- /dev/null +++ b/scenarios/activity-update-and-delete/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .activity_update_and_delete_bot import ActivitiyUpdateAndDeleteBot + +__all__ = ["ActivitiyUpdateAndDeleteBot"] diff --git a/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py b/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py new file mode 100644 index 000000000..350cec8c2 --- /dev/null +++ b/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory, TurnContext, ActivityHandler + + +class ActivitiyUpdateAndDeleteBot(ActivityHandler): + def __init__(self, activity_ids): + self.activity_ids = activity_ids + + async def on_message_activity(self, turn_context: TurnContext): + TurnContext.remove_recipient_mention(turn_context.activity) + if turn_context.activity.text == "delete": + for activity in self.activity_ids: + await turn_context.delete_activity(activity) + + self.activity_ids = [] + else: + await self._send_message_and_log_activity_id( + turn_context, turn_context.activity.text + ) + + for activity_id in self.activity_ids: + new_activity = MessageFactory.text(turn_context.activity.text) + new_activity.id = activity_id + await turn_context.update_activity(new_activity) + + async def _send_message_and_log_activity_id( + self, turn_context: TurnContext, text: str + ): + reply_activity = MessageFactory.text(text) + resource_response = await turn_context.send_activity(reply_activity) + self.activity_ids.append(resource_response.id) diff --git a/scenarios/activity-update-and-delete/config.py b/scenarios/activity-update-and-delete/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/activity-update-and-delete/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/activity-update-and-delete/requirements.txt b/scenarios/activity-update-and-delete/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/activity-update-and-delete/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/color.png b/scenarios/activity-update-and-delete/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZPx$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/conversation-update/teams_app_manifest/color.png b/scenarios/conversation-update/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZPx$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z{turn_context.activity.from_property.name}", + "type": "mention", + } + + mention_object = Mention(**mention_data) + + reply_activity = MessageFactory.text(f"Hello {mention_object.text}") + reply_activity.entities = [mention_object] + await turn_context.send_activity(reply_activity) diff --git a/scenarios/mentions/config.py b/scenarios/mentions/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/mentions/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/mentions/requirements.txt b/scenarios/mentions/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/mentions/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/mentions/teams_app_manifest/color.png b/scenarios/mentions/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "MentionBot", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "MentionBot", + "full": "MentionBot" + }, + "description": { + "short": "MentionBot", + "full": "MentionBot" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] +} \ No newline at end of file diff --git a/scenarios/mentions/teams_app_manifest/outline.png b/scenarios/mentions/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Activity: + if not activity_id: + raise TypeError("activity_id is required for ActivityLog.find") + + items = await self._storage.read([activity_id]) + return items[activity_id] if len(items) >= 1 else None diff --git a/scenarios/message-reactions/app.py b/scenarios/message-reactions/app.py new file mode 100644 index 000000000..f92c64c3c --- /dev/null +++ b/scenarios/message-reactions/app.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +from datetime import datetime +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, + MemoryStorage, +) +from botbuilder.schema import Activity, ActivityTypes +from activity_log import ActivityLog +from bots import MessageReactionBot +from threading_helper import run_coroutine + +# Create the Flask app +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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +MEMORY = MemoryStorage() +ACTIVITY_LOG = ActivityLog(MEMORY) +# Create the Bot +BOT = MessageReactionBot(ACTIVITY_LOG) + +# 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: + print("about to create task") + print("about to run until complete") + run_coroutine(ADAPTER.process_activity(activity, auth_header, BOT.on_turn)) + print("is now complete") + 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/message-reactions/bots/__init__.py b/scenarios/message-reactions/bots/__init__.py new file mode 100644 index 000000000..4c417f70c --- /dev/null +++ b/scenarios/message-reactions/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .message_reaction_bot import MessageReactionBot + +__all__ = ["MessageReactionBot"] diff --git a/scenarios/message-reactions/bots/message_reaction_bot.py b/scenarios/message-reactions/bots/message_reaction_bot.py new file mode 100644 index 000000000..ce8c34cea --- /dev/null +++ b/scenarios/message-reactions/bots/message_reaction_bot.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core import MessageFactory, TurnContext, ActivityHandler +from botbuilder.schema import MessageReaction +from activity_log import ActivityLog + + +class MessageReactionBot(ActivityHandler): + def __init__(self, activity_log: ActivityLog): + self._log = activity_log + + async def on_reactions_added( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + for reaction in message_reactions: + activity = await self._log.find(turn_context.activity.reply_to_id) + if not activity: + await self._send_message_and_log_activity_id( + turn_context, + f"Activity {turn_context.activity.reply_to_id} not found in log", + ) + else: + await self._send_message_and_log_activity_id( + turn_context, + f"You added '{reaction.type}' regarding '{activity.text}'", + ) + return + + async def on_reactions_removed( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + for reaction in message_reactions: + activity = await self._log.find(turn_context.activity.reply_to_id) + if not activity: + await self._send_message_and_log_activity_id( + turn_context, + f"Activity {turn_context.activity.reply_to_id} not found in log", + ) + else: + await self._send_message_and_log_activity_id( + turn_context, + f"You removed '{reaction.type}' regarding '{activity.text}'", + ) + return + + async def on_message_activity(self, turn_context: TurnContext): + await self._send_message_and_log_activity_id( + turn_context, f"echo: {turn_context.activity.text}" + ) + + async def _send_message_and_log_activity_id( + self, turn_context: TurnContext, text: str + ): + reply_activity = MessageFactory.text(text) + resource_response = await turn_context.send_activity(reply_activity) + + await self._log.append(resource_response.id, reply_activity) + return diff --git a/scenarios/message-reactions/config.py b/scenarios/message-reactions/config.py new file mode 100644 index 000000000..480b0647b --- /dev/null +++ b/scenarios/message-reactions/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", "e4c570ca-189d-4fee-a81b-5466be24a557") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "bghqYKJV3709;creKFP8$@@") diff --git a/scenarios/message-reactions/requirements.txt b/scenarios/message-reactions/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/message-reactions/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/message-reactions/teams_app_manifest/color.png b/scenarios/message-reactions/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "MessageReactions", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "MessageReactions", + "full": "MessageReactions" + }, + "description": { + "short": "MessageReactions", + "full": "MessageReactions" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] +} \ No newline at end of file diff --git a/scenarios/message-reactions/teams_app_manifest/outline.png b/scenarios/message-reactions/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z 0 + + try: + pid, status = os.waitpid(expected_pid, 0) + except ChildProcessError: + # The child process is already reaped + # (may happen if waitpid() is called elsewhere). + pid = expected_pid + returncode = 255 + logger.warning( + "Unknown child process pid %d, will report returncode 255", pid + ) + else: + if os.WIFSIGNALED(status): + returncode = -os.WTERMSIG(status) + elif os.WIFEXITED(status): + returncode = os.WEXITSTATUS(status) + else: + returncode = status + + if loop.get_debug(): + logger.debug( + "process %s exited with returncode %s", expected_pid, returncode + ) + + if loop.is_closed(): + logger.warning("Loop %r that handles pid %r is closed", loop, pid) + else: + loop.call_soon_threadsafe(callback, pid, returncode, *args) + + self._threads.pop(expected_pid) + + # add the watcher to the loop policy + asyncio.get_event_loop_policy().set_child_watcher(_Py38ThreadedChildWatcher()) + +__all__ = ["EventLoopThread", "get_event_loop", "stop_event_loop", "run_coroutine"] + +logger = logging.getLogger(__name__) + + +class EventLoopThread(threading.Thread): + loop = None + _count = itertools.count(0) + + def __init__(self): + name = f"{type(self).__name__}-{next(self._count)}" + super().__init__(name=name, daemon=True) + + def __repr__(self): + loop, r, c, d = self.loop, False, True, False + if loop is not None: + r, c, d = loop.is_running(), loop.is_closed(), loop.get_debug() + return ( + f"<{type(self).__name__} {self.name} id={self.ident} " + f"running={r} closed={c} debug={d}>" + ) + + def run(self): + self.loop = loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + loop.run_forever() + finally: + try: + shutdown_asyncgens = loop.shutdown_asyncgens() + except AttributeError: + pass + else: + loop.run_until_complete(shutdown_asyncgens) + loop.close() + asyncio.set_event_loop(None) + + def stop(self): + loop, self.loop = self.loop, None + if loop is None: + return + loop.call_soon_threadsafe(loop.stop) + self.join() + + +_lock = threading.Lock() +_loop_thread = None + + +def get_event_loop(): + global _loop_thread + with _lock: + if _loop_thread is None: + _loop_thread = EventLoopThread() + _loop_thread.start() + return _loop_thread.loop + + +def stop_event_loop(): + global _loop_thread + with _lock: + if _loop_thread is not None: + _loop_thread.stop() + _loop_thread = None + + +def run_coroutine(coro): + return asyncio.run_coroutine_threadsafe(coro, get_event_loop()) From 2b38e872df2d01c78804e78f8f6694cd6bdf6822 Mon Sep 17 00:00:00 2001 From: Michael Richardson <40401643+mdrichardson@users.noreply.github.com> Date: Wed, 27 Nov 2019 15:27:20 -0800 Subject: [PATCH 055/616] Fix compute_hash (#450) * Fix compute_hash * added import * pylint fix --- libraries/botbuilder-core/botbuilder/core/bot_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index dc835a9bd..4e615dda0 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -4,6 +4,7 @@ from abc import abstractmethod from copy import deepcopy from typing import Callable, Dict, Union +from jsonpickle.pickler import Pickler from botbuilder.core.state_property_accessor import StatePropertyAccessor from .turn_context import TurnContext from .storage import Storage @@ -24,8 +25,7 @@ def is_changed(self) -> bool: return self.hash != self.compute_hash(self.state) def compute_hash(self, obj: object) -> str: - # TODO: Should this be compatible with C# JsonConvert.SerializeObject ? - return str(obj) + return str(Pickler().flatten(obj)) class BotState(PropertyManager): From d165c18d30a9a7d93bee0d50eba65922d00c88c9 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 15:58:13 -0800 Subject: [PATCH 056/616] adding gen'd files --- .../botbuilder/schema/teams/__init__.py | 187 +- .../botbuilder/schema/teams/_models.py | 1595 ++++++++++++++ .../botbuilder/schema/teams/_models_py3.py | 1875 +++++++++++++++++ .../botbuilder/schema/teams/channel_info.py | 13 - .../schema/teams/notification_info.py | 12 - .../botbuilder/schema/teams/team_info.py | 14 - .../schema/teams/teams_channel_account.py | 31 - .../schema/teams/teams_channel_data.py | 30 - .../botbuilder/schema/teams/tenant_info.py | 12 - .../tests/test_skill_validation.py | 3 +- 10 files changed, 3649 insertions(+), 123 deletions(-) create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/_models.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index d299ec66c..bae8bf5cf 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -1,15 +1,184 @@ -from .team_info import TeamInfo -from .notification_info import NotificationInfo -from .tenant_info import TenantInfo -from .channel_info import ChannelInfo -from .teams_channel_data import TeamsChannelData -from .teams_channel_account import TeamsChannelAccount +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +try: + from ._models_py3 import AppBasedLinkQuery + from ._models_py3 import ChannelInfo + from ._models_py3 import ConversationList + from ._models_py3 import FileConsentCard + from ._models_py3 import FileConsentCardResponse + from ._models_py3 import FileDownloadInfo + from ._models_py3 import FileInfoCard + from ._models_py3 import FileUploadInfo + from ._models_py3 import MessageActionsPayload + from ._models_py3 import MessageActionsPayloadApp + from ._models_py3 import MessageActionsPayloadAttachment + from ._models_py3 import MessageActionsPayloadBody + from ._models_py3 import MessageActionsPayloadConversation + from ._models_py3 import MessageActionsPayloadFrom + from ._models_py3 import MessageActionsPayloadMention + from ._models_py3 import MessageActionsPayloadReaction + from ._models_py3 import MessageActionsPayloadUser + from ._models_py3 import MessagingExtensionAction + from ._models_py3 import MessagingExtensionActionResponse + from ._models_py3 import MessagingExtensionAttachment + from ._models_py3 import MessagingExtensionParameter + from ._models_py3 import MessagingExtensionQuery + from ._models_py3 import MessagingExtensionQueryOptions + from ._models_py3 import MessagingExtensionResponse + from ._models_py3 import MessagingExtensionResult + from ._models_py3 import MessagingExtensionSuggestedAction + from ._models_py3 import NotificationInfo + from ._models_py3 import O365ConnectorCard + from ._models_py3 import O365ConnectorCardActionBase + from ._models_py3 import O365ConnectorCardActionCard + from ._models_py3 import O365ConnectorCardActionQuery + from ._models_py3 import O365ConnectorCardDateInput + from ._models_py3 import O365ConnectorCardFact + from ._models_py3 import O365ConnectorCardHttpPOST + from ._models_py3 import O365ConnectorCardImage + from ._models_py3 import O365ConnectorCardInputBase + from ._models_py3 import O365ConnectorCardMultichoiceInput + from ._models_py3 import O365ConnectorCardMultichoiceInputChoice + from ._models_py3 import O365ConnectorCardOpenUri + from ._models_py3 import O365ConnectorCardOpenUriTarget + from ._models_py3 import O365ConnectorCardSection + from ._models_py3 import O365ConnectorCardTextInput + from ._models_py3 import O365ConnectorCardViewAction + from ._models_py3 import SigninStateVerificationQuery + from ._models_py3 import TaskModuleContinueResponse + from ._models_py3 import TaskModuleMessageResponse + from ._models_py3 import TaskModuleRequest + from ._models_py3 import TaskModuleRequestContext + from ._models_py3 import TaskModuleResponse + from ._models_py3 import TaskModuleResponseBase + from ._models_py3 import TaskModuleTaskInfo + from ._models_py3 import TeamDetails + from ._models_py3 import TeamInfo + from ._models_py3 import TeamsChannelAccount + from ._models_py3 import TeamsChannelData + from ._models_py3 import TenantInfo +except (SyntaxError, ImportError): + from ._models import AppBasedLinkQuery + from ._models import ChannelInfo + from ._models import ConversationList + from ._models import FileConsentCard + from ._models import FileConsentCardResponse + from ._models import FileDownloadInfo + from ._models import FileInfoCard + from ._models import FileUploadInfo + from ._models import MessageActionsPayload + from ._models import MessageActionsPayloadApp + from ._models import MessageActionsPayloadAttachment + from ._models import MessageActionsPayloadBody + from ._models import MessageActionsPayloadConversation + from ._models import MessageActionsPayloadFrom + from ._models import MessageActionsPayloadMention + from ._models import MessageActionsPayloadReaction + from ._models import MessageActionsPayloadUser + from ._models import MessagingExtensionAction + from ._models import MessagingExtensionActionResponse + from ._models import MessagingExtensionAttachment + from ._models import MessagingExtensionParameter + from ._models import MessagingExtensionQuery + from ._models import MessagingExtensionQueryOptions + from ._models import MessagingExtensionResponse + from ._models import MessagingExtensionResult + from ._models import MessagingExtensionSuggestedAction + from ._models import NotificationInfo + from ._models import O365ConnectorCard + from ._models import O365ConnectorCardActionBase + from ._models import O365ConnectorCardActionCard + from ._models import O365ConnectorCardActionQuery + from ._models import O365ConnectorCardDateInput + from ._models import O365ConnectorCardFact + from ._models import O365ConnectorCardHttpPOST + from ._models import O365ConnectorCardImage + from ._models import O365ConnectorCardInputBase + from ._models import O365ConnectorCardMultichoiceInput + from ._models import O365ConnectorCardMultichoiceInputChoice + from ._models import O365ConnectorCardOpenUri + from ._models import O365ConnectorCardOpenUriTarget + from ._models import O365ConnectorCardSection + from ._models import O365ConnectorCardTextInput + from ._models import O365ConnectorCardViewAction + from ._models import SigninStateVerificationQuery + from ._models import TaskModuleContinueResponse + from ._models import TaskModuleMessageResponse + from ._models import TaskModuleRequest + from ._models import TaskModuleRequestContext + from ._models import TaskModuleResponse + from ._models import TaskModuleResponseBase + from ._models import TaskModuleTaskInfo + from ._models import TeamDetails + from ._models import TeamInfo + from ._models import TeamsChannelAccount + from ._models import TeamsChannelData + from ._models import TenantInfo __all__ = [ - "TeamInfo", + "AppBasedLinkQuery", "ChannelInfo", - "TeamsChannelData", + "ConversationList", + "FileConsentCard", + "FileConsentCardResponse", + "FileDownloadInfo", + "FileInfoCard", + "FileUploadInfo", + "MessageActionsPayload", + "MessageActionsPayloadApp", + "MessageActionsPayloadAttachment", + "MessageActionsPayloadBody", + "MessageActionsPayloadConversation", + "MessageActionsPayloadFrom", + "MessageActionsPayloadMention", + "MessageActionsPayloadReaction", + "MessageActionsPayloadUser", + "MessagingExtensionAction", + "MessagingExtensionActionResponse", + "MessagingExtensionAttachment", + "MessagingExtensionParameter", + "MessagingExtensionQuery", + "MessagingExtensionQueryOptions", + "MessagingExtensionResponse", + "MessagingExtensionResult", + "MessagingExtensionSuggestedAction", + "NotificationInfo", + "O365ConnectorCard", + "O365ConnectorCardActionBase", + "O365ConnectorCardActionCard", + "O365ConnectorCardActionQuery", + "O365ConnectorCardDateInput", + "O365ConnectorCardFact", + "O365ConnectorCardHttpPOST", + "O365ConnectorCardImage", + "O365ConnectorCardInputBase", + "O365ConnectorCardMultichoiceInput", + "O365ConnectorCardMultichoiceInputChoice", + "O365ConnectorCardOpenUri", + "O365ConnectorCardOpenUriTarget", + "O365ConnectorCardSection", + "O365ConnectorCardTextInput", + "O365ConnectorCardViewAction", + "SigninStateVerificationQuery", + "TaskModuleContinueResponse", + "TaskModuleMessageResponse", + "TaskModuleRequest", + "TaskModuleRequestContext", + "TaskModuleResponse", + "TaskModuleResponseBase", + "TaskModuleTaskInfo", + "TeamDetails", + "TeamInfo", "TeamsChannelAccount", + "TeamsChannelData", "TenantInfo", - "NotificationInfo", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py new file mode 100644 index 000000000..5e41c6fd4 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -0,0 +1,1595 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +from msrest.serialization import Model + + +class AppBasedLinkQuery(Model): + """Invoke request body type for app-based link query. + + :param url: Url queried by user + :type url: str + """ + + _attribute_map = { + "url": {"key": "url", "type": "str"}, + } + + def __init__(self, *, url: str = None, **kwargs) -> None: + super(AppBasedLinkQuery, self).__init__(**kwargs) + self.url = url + + +class ChannelInfo(Model): + """A channel info object which describes the channel. + + :param id: Unique identifier representing a channel + :type id: str + :param name: Name of the channel + :type name: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + } + + def __init__(self, **kwargs): + super(ChannelInfo, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.name = kwargs.get("name", None) + + +class ConversationList(Model): + """List of channels under a team. + + :param conversations: + :type conversations: + list[~botframework.connector.teams.models.ChannelInfo] + """ + + _attribute_map = { + "conversations": {"key": "conversations", "type": "[ChannelInfo]"}, + } + + def __init__(self, **kwargs): + super(ConversationList, self).__init__(**kwargs) + self.conversations = kwargs.get("conversations", None) + + +class FileConsentCard(Model): + """File consent card attachment. + + :param description: File description. + :type description: str + :param size_in_bytes: Size of the file to be uploaded in Bytes. + :type size_in_bytes: long + :param accept_context: Context sent back to the Bot if user consented to + upload. This is free flow schema and is sent back in Value field of + Activity. + :type accept_context: object + :param decline_context: Context sent back to the Bot if user declined. + This is free flow schema and is sent back in Value field of Activity. + :type decline_context: object + """ + + _attribute_map = { + "description": {"key": "description", "type": "str"}, + "size_in_bytes": {"key": "sizeInBytes", "type": "long"}, + "accept_context": {"key": "acceptContext", "type": "object"}, + "decline_context": {"key": "declineContext", "type": "object"}, + } + + def __init__(self, **kwargs): + super(FileConsentCard, self).__init__(**kwargs) + self.description = kwargs.get("description", None) + self.size_in_bytes = kwargs.get("size_in_bytes", None) + self.accept_context = kwargs.get("accept_context", None) + self.decline_context = kwargs.get("decline_context", None) + + +class FileConsentCardResponse(Model): + """Represents the value of the invoke activity sent when the user acts on a + file consent card. + + :param action: The action the user took. Possible values include: + 'accept', 'decline' + :type action: str or ~botframework.connector.teams.models.enum + :param context: The context associated with the action. + :type context: object + :param upload_info: If the user accepted the file, contains information + about the file to be uploaded. + :type upload_info: ~botframework.connector.teams.models.FileUploadInfo + """ + + _attribute_map = { + "action": {"key": "action", "type": "str"}, + "context": {"key": "context", "type": "object"}, + "upload_info": {"key": "uploadInfo", "type": "FileUploadInfo"}, + } + + def __init__(self, **kwargs): + super(FileConsentCardResponse, self).__init__(**kwargs) + self.action = kwargs.get("action", None) + self.context = kwargs.get("context", None) + self.upload_info = kwargs.get("upload_info", None) + + +class FileDownloadInfo(Model): + """File download info attachment. + + :param download_url: File download url. + :type download_url: str + :param unique_id: Unique Id for the file. + :type unique_id: str + :param file_type: Type of file. + :type file_type: str + :param etag: ETag for the file. + :type etag: object + """ + + _attribute_map = { + "download_url": {"key": "downloadUrl", "type": "str"}, + "unique_id": {"key": "uniqueId", "type": "str"}, + "file_type": {"key": "fileType", "type": "str"}, + "etag": {"key": "etag", "type": "object"}, + } + + def __init__(self, **kwargs): + super(FileDownloadInfo, self).__init__(**kwargs) + self.download_url = kwargs.get("download_url", None) + self.unique_id = kwargs.get("unique_id", None) + self.file_type = kwargs.get("file_type", None) + self.etag = kwargs.get("etag", None) + + +class FileInfoCard(Model): + """File info card. + + :param unique_id: Unique Id for the file. + :type unique_id: str + :param file_type: Type of file. + :type file_type: str + :param etag: ETag for the file. + :type etag: object + """ + + _attribute_map = { + "unique_id": {"key": "uniqueId", "type": "str"}, + "file_type": {"key": "fileType", "type": "str"}, + "etag": {"key": "etag", "type": "object"}, + } + + def __init__(self, **kwargs): + super(FileInfoCard, self).__init__(**kwargs) + self.unique_id = kwargs.get("unique_id", None) + self.file_type = kwargs.get("file_type", None) + self.etag = kwargs.get("etag", None) + + +class FileUploadInfo(Model): + """Information about the file to be uploaded. + + :param name: Name of the file. + :type name: str + :param upload_url: URL to an upload session that the bot can use to set + the file contents. + :type upload_url: str + :param content_url: URL to file. + :type content_url: str + :param unique_id: ID that uniquely identifies the file. + :type unique_id: str + :param file_type: Type of the file. + :type file_type: str + """ + + _attribute_map = { + "name": {"key": "name", "type": "str"}, + "upload_url": {"key": "uploadUrl", "type": "str"}, + "content_url": {"key": "contentUrl", "type": "str"}, + "unique_id": {"key": "uniqueId", "type": "str"}, + "file_type": {"key": "fileType", "type": "str"}, + } + + def __init__(self, **kwargs): + super(FileUploadInfo, self).__init__(**kwargs) + self.name = kwargs.get("name", None) + self.upload_url = kwargs.get("upload_url", None) + self.content_url = kwargs.get("content_url", None) + self.unique_id = kwargs.get("unique_id", None) + self.file_type = kwargs.get("file_type", None) + + +class MessageActionsPayloadApp(Model): + """Represents an application entity. + + :param application_identity_type: The type of application. Possible values + include: 'aadApplication', 'bot', 'tenantBot', 'office365Connector', + 'webhook' + :type application_identity_type: str or + ~botframework.connector.teams.models.enum + :param id: The id of the application. + :type id: str + :param display_name: The plaintext display name of the application. + :type display_name: str + """ + + _attribute_map = { + "application_identity_type": {"key": "applicationIdentityType", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadApp, self).__init__(**kwargs) + self.application_identity_type = kwargs.get("application_identity_type", None) + self.id = kwargs.get("id", None) + self.display_name = kwargs.get("display_name", None) + + +class MessageActionsPayloadAttachment(Model): + """Represents the attachment in a message. + + :param id: The id of the attachment. + :type id: str + :param content_type: The type of the attachment. + :type content_type: str + :param content_url: The url of the attachment, in case of a external link. + :type content_url: str + :param content: The content of the attachment, in case of a code snippet, + email, or file. + :type content: object + :param name: The plaintext display name of the attachment. + :type name: str + :param thumbnail_url: The url of a thumbnail image that might be embedded + in the attachment, in case of a card. + :type thumbnail_url: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "content_type": {"key": "contentType", "type": "str"}, + "content_url": {"key": "contentUrl", "type": "str"}, + "content": {"key": "content", "type": "object"}, + "name": {"key": "name", "type": "str"}, + "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadAttachment, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.content_type = kwargs.get("content_type", None) + self.content_url = kwargs.get("content_url", None) + self.content = kwargs.get("content", None) + self.name = kwargs.get("name", None) + self.thumbnail_url = kwargs.get("thumbnail_url", None) + + +class MessageActionsPayloadBody(Model): + """Plaintext/HTML representation of the content of the message. + + :param content_type: Type of the content. Possible values include: 'html', + 'text' + :type content_type: str or ~botframework.connector.teams.models.enum + :param content: The content of the body. + :type content: str + """ + + _attribute_map = { + "content_type": {"key": "contentType", "type": "str"}, + "content": {"key": "content", "type": "str"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadBody, self).__init__(**kwargs) + self.content_type = kwargs.get("content_type", None) + self.content = kwargs.get("content", None) + + +class MessageActionsPayloadConversation(Model): + """Represents a team or channel entity. + + :param conversation_identity_type: The type of conversation, whether a + team or channel. Possible values include: 'team', 'channel' + :type conversation_identity_type: str or + ~botframework.connector.teams.models.enum + :param id: The id of the team or channel. + :type id: str + :param display_name: The plaintext display name of the team or channel + entity. + :type display_name: str + """ + + _attribute_map = { + "conversation_identity_type": { + "key": "conversationIdentityType", + "type": "str", + }, + "id": {"key": "id", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadConversation, self).__init__(**kwargs) + self.conversation_identity_type = kwargs.get("conversation_identity_type", None) + self.id = kwargs.get("id", None) + self.display_name = kwargs.get("display_name", None) + + +class MessageActionsPayloadFrom(Model): + """Represents a user, application, or conversation type that either sent or + was referenced in a message. + + :param user: Represents details of the user. + :type user: ~botframework.connector.teams.models.MessageActionsPayloadUser + :param application: Represents details of the app. + :type application: + ~botframework.connector.teams.models.MessageActionsPayloadApp + :param conversation: Represents details of the converesation. + :type conversation: + ~botframework.connector.teams.models.MessageActionsPayloadConversation + """ + + _attribute_map = { + "user": {"key": "user", "type": "MessageActionsPayloadUser"}, + "application": {"key": "application", "type": "MessageActionsPayloadApp"}, + "conversation": { + "key": "conversation", + "type": "MessageActionsPayloadConversation", + }, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadFrom, self).__init__(**kwargs) + self.user = kwargs.get("user", None) + self.application = kwargs.get("application", None) + self.conversation = kwargs.get("conversation", None) + + +class MessageActionsPayloadMention(Model): + """Represents the entity that was mentioned in the message. + + :param id: The id of the mentioned entity. + :type id: int + :param mention_text: The plaintext display name of the mentioned entity. + :type mention_text: str + :param mentioned: Provides more details on the mentioned entity. + :type mentioned: + ~botframework.connector.teams.models.MessageActionsPayloadFrom + """ + + _attribute_map = { + "id": {"key": "id", "type": "int"}, + "mention_text": {"key": "mentionText", "type": "str"}, + "mentioned": {"key": "mentioned", "type": "MessageActionsPayloadFrom"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadMention, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.mention_text = kwargs.get("mention_text", None) + self.mentioned = kwargs.get("mentioned", None) + + +class MessageActionsPayloadReaction(Model): + """Represents the reaction of a user to a message. + + :param reaction_type: The type of reaction given to the message. Possible + values include: 'like', 'heart', 'laugh', 'surprised', 'sad', 'angry' + :type reaction_type: str or ~botframework.connector.teams.models.enum + :param created_date_time: Timestamp of when the user reacted to the + message. + :type created_date_time: str + :param user: The user with which the reaction is associated. + :type user: ~botframework.connector.teams.models.MessageActionsPayloadFrom + """ + + _attribute_map = { + "reaction_type": {"key": "reactionType", "type": "str"}, + "created_date_time": {"key": "createdDateTime", "type": "str"}, + "user": {"key": "user", "type": "MessageActionsPayloadFrom"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadReaction, self).__init__(**kwargs) + self.reaction_type = kwargs.get("reaction_type", None) + self.created_date_time = kwargs.get("created_date_time", None) + self.user = kwargs.get("user", None) + + +class MessageActionsPayloadUser(Model): + """Represents a user entity. + + :param user_identity_type: The identity type of the user. Possible values + include: 'aadUser', 'onPremiseAadUser', 'anonymousGuest', 'federatedUser' + :type user_identity_type: str or ~botframework.connector.teams.models.enum + :param id: The id of the user. + :type id: str + :param display_name: The plaintext display name of the user. + :type display_name: str + """ + + _attribute_map = { + "user_identity_type": {"key": "userIdentityType", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayloadUser, self).__init__(**kwargs) + self.user_identity_type = kwargs.get("user_identity_type", None) + self.id = kwargs.get("id", None) + self.display_name = kwargs.get("display_name", None) + + +class MessageActionsPayload(Model): + """Represents the individual message within a chat or channel where a message + actions is taken. + + :param id: Unique id of the message. + :type id: str + :param reply_to_id: Id of the parent/root message of the thread. + :type reply_to_id: str + :param message_type: Type of message - automatically set to message. + Possible values include: 'message' + :type message_type: str or ~botframework.connector.teams.models.enum + :param created_date_time: Timestamp of when the message was created. + :type created_date_time: str + :param last_modified_date_time: Timestamp of when the message was edited + or updated. + :type last_modified_date_time: str + :param deleted: Indicates whether a message has been soft deleted. + :type deleted: bool + :param subject: Subject line of the message. + :type subject: str + :param summary: Summary text of the message that could be used for + notifications. + :type summary: str + :param importance: The importance of the message. Possible values include: + 'normal', 'high', 'urgent' + :type importance: str or ~botframework.connector.teams.models.enum + :param locale: Locale of the message set by the client. + :type locale: str + :param from_property: Sender of the message. + :type from_property: + ~botframework.connector.teams.models.MessageActionsPayloadFrom + :param body: Plaintext/HTML representation of the content of the message. + :type body: ~botframework.connector.teams.models.MessageActionsPayloadBody + :param attachment_layout: How the attachment(s) are displayed in the + message. + :type attachment_layout: str + :param attachments: Attachments in the message - card, image, file, etc. + :type attachments: + list[~botframework.connector.teams.models.MessageActionsPayloadAttachment] + :param mentions: List of entities mentioned in the message. + :type mentions: + list[~botframework.connector.teams.models.MessageActionsPayloadMention] + :param reactions: Reactions for the message. + :type reactions: + list[~botframework.connector.teams.models.MessageActionsPayloadReaction] + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "reply_to_id": {"key": "replyToId", "type": "str"}, + "message_type": {"key": "messageType", "type": "str"}, + "created_date_time": {"key": "createdDateTime", "type": "str"}, + "last_modified_date_time": {"key": "lastModifiedDateTime", "type": "str"}, + "deleted": {"key": "deleted", "type": "bool"}, + "subject": {"key": "subject", "type": "str"}, + "summary": {"key": "summary", "type": "str"}, + "importance": {"key": "importance", "type": "str"}, + "locale": {"key": "locale", "type": "str"}, + "from_property": {"key": "from", "type": "MessageActionsPayloadFrom"}, + "body": {"key": "body", "type": "MessageActionsPayloadBody"}, + "attachment_layout": {"key": "attachmentLayout", "type": "str"}, + "attachments": { + "key": "attachments", + "type": "[MessageActionsPayloadAttachment]", + }, + "mentions": {"key": "mentions", "type": "[MessageActionsPayloadMention]"}, + "reactions": {"key": "reactions", "type": "[MessageActionsPayloadReaction]"}, + } + + def __init__(self, **kwargs): + super(MessageActionsPayload, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.reply_to_id = kwargs.get("reply_to_id", None) + self.message_type = kwargs.get("message_type", None) + self.created_date_time = kwargs.get("created_date_time", None) + self.last_modified_date_time = kwargs.get("last_modified_date_time", None) + self.deleted = kwargs.get("deleted", None) + self.subject = kwargs.get("subject", None) + self.summary = kwargs.get("summary", None) + self.importance = kwargs.get("importance", None) + self.locale = kwargs.get("locale", None) + self.from_property = kwargs.get("from_property", None) + self.body = kwargs.get("body", None) + self.attachment_layout = kwargs.get("attachment_layout", None) + self.attachments = kwargs.get("attachments", None) + self.mentions = kwargs.get("mentions", None) + self.reactions = kwargs.get("reactions", None) + + +class MessagingExtensionAction(TaskModuleRequest): + """Messaging extension action. + + :param data: User input data. Free payload with key-value pairs. + :type data: object + :param context: Current user context, i.e., the current theme + :type context: + ~botframework.connector.teams.models.TaskModuleRequestContext + :param command_id: Id of the command assigned by Bot + :type command_id: str + :param command_context: The context from which the command originates. + Possible values include: 'message', 'compose', 'commandbox' + :type command_context: str or ~botframework.connector.teams.models.enum + :param bot_message_preview_action: Bot message preview action taken by + user. Possible values include: 'edit', 'send' + :type bot_message_preview_action: str or + ~botframework.connector.teams.models.enum + :param bot_activity_preview: + :type bot_activity_preview: + list[~botframework.connector.teams.models.Activity] + :param message_payload: Message content sent as part of the command + request. + :type message_payload: + ~botframework.connector.teams.models.MessageActionsPayload + """ + + _attribute_map = { + "data": {"key": "data", "type": "object"}, + "context": {"key": "context", "type": "TaskModuleRequestContext"}, + "command_id": {"key": "commandId", "type": "str"}, + "command_context": {"key": "commandContext", "type": "str"}, + "bot_message_preview_action": {"key": "botMessagePreviewAction", "type": "str"}, + "bot_activity_preview": {"key": "botActivityPreview", "type": "[Activity]"}, + "message_payload": {"key": "messagePayload", "type": "MessageActionsPayload"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionAction, self).__init__(**kwargs) + self.command_id = kwargs.get("command_id", None) + self.command_context = kwargs.get("command_context", None) + self.bot_message_preview_action = kwargs.get("bot_message_preview_action", None) + self.bot_activity_preview = kwargs.get("bot_activity_preview", None) + self.message_payload = kwargs.get("message_payload", None) + + +class MessagingExtensionActionResponse(Model): + """Response of messaging extension action. + + :param task: The JSON for the Adaptive card to appear in the task module. + :type task: ~botframework.connector.teams.models.TaskModuleResponseBase + :param compose_extension: + :type compose_extension: + ~botframework.connector.teams.models.MessagingExtensionResult + """ + + _attribute_map = { + "task": {"key": "task", "type": "TaskModuleResponseBase"}, + "compose_extension": { + "key": "composeExtension", + "type": "MessagingExtensionResult", + }, + } + + def __init__(self, **kwargs): + super(MessagingExtensionActionResponse, self).__init__(**kwargs) + self.task = kwargs.get("task", None) + self.compose_extension = kwargs.get("compose_extension", None) + + +class MessagingExtensionAttachment(Attachment): + """Messaging extension attachment. + + :param content_type: mimetype/Contenttype for the file + :type content_type: str + :param content_url: Content Url + :type content_url: str + :param content: Embedded content + :type content: object + :param name: (OPTIONAL) The name of the attachment + :type name: str + :param thumbnail_url: (OPTIONAL) Thumbnail associated with attachment + :type thumbnail_url: str + :param preview: + :type preview: ~botframework.connector.teams.models.Attachment + """ + + _attribute_map = { + "content_type": {"key": "contentType", "type": "str"}, + "content_url": {"key": "contentUrl", "type": "str"}, + "content": {"key": "content", "type": "object"}, + "name": {"key": "name", "type": "str"}, + "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, + "preview": {"key": "preview", "type": "Attachment"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionAttachment, self).__init__(**kwargs) + self.preview = kwargs.get("preview", None) + + +class MessagingExtensionParameter(Model): + """Messaging extension query parameters. + + :param name: Name of the parameter + :type name: str + :param value: Value of the parameter + :type value: object + """ + + _attribute_map = { + "name": {"key": "name", "type": "str"}, + "value": {"key": "value", "type": "object"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionParameter, self).__init__(**kwargs) + self.name = kwargs.get("name", None) + self.value = kwargs.get("value", None) + + +class MessagingExtensionQuery(Model): + """Messaging extension query. + + :param command_id: Id of the command assigned by Bot + :type command_id: str + :param parameters: Parameters for the query + :type parameters: + list[~botframework.connector.teams.models.MessagingExtensionParameter] + :param query_options: + :type query_options: + ~botframework.connector.teams.models.MessagingExtensionQueryOptions + :param state: State parameter passed back to the bot after + authentication/configuration flow + :type state: str + """ + + _attribute_map = { + "command_id": {"key": "commandId", "type": "str"}, + "parameters": {"key": "parameters", "type": "[MessagingExtensionParameter]"}, + "query_options": { + "key": "queryOptions", + "type": "MessagingExtensionQueryOptions", + }, + "state": {"key": "state", "type": "str"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionQuery, self).__init__(**kwargs) + self.command_id = kwargs.get("command_id", None) + self.parameters = kwargs.get("parameters", None) + self.query_options = kwargs.get("query_options", None) + self.state = kwargs.get("state", None) + + +class MessagingExtensionQueryOptions(Model): + """Messaging extension query options. + + :param skip: Number of entities to skip + :type skip: int + :param count: Number of entities to fetch + :type count: int + """ + + _attribute_map = { + "skip": {"key": "skip", "type": "int"}, + "count": {"key": "count", "type": "int"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionQueryOptions, self).__init__(**kwargs) + self.skip = kwargs.get("skip", None) + self.count = kwargs.get("count", None) + + +class MessagingExtensionResponse(Model): + """Messaging extension response. + + :param compose_extension: + :type compose_extension: + ~botframework.connector.teams.models.MessagingExtensionResult + """ + + _attribute_map = { + "compose_extension": { + "key": "composeExtension", + "type": "MessagingExtensionResult", + }, + } + + def __init__(self, **kwargs): + super(MessagingExtensionResponse, self).__init__(**kwargs) + self.compose_extension = kwargs.get("compose_extension", None) + + +class MessagingExtensionResult(Model): + """Messaging extension result. + + :param attachment_layout: Hint for how to deal with multiple attachments. + Possible values include: 'list', 'grid' + :type attachment_layout: str or ~botframework.connector.teams.models.enum + :param type: The type of the result. Possible values include: 'result', + 'auth', 'config', 'message', 'botMessagePreview' + :type type: str or ~botframework.connector.teams.models.enum + :param attachments: (Only when type is result) Attachments + :type attachments: + list[~botframework.connector.teams.models.MessagingExtensionAttachment] + :param suggested_actions: + :type suggested_actions: + ~botframework.connector.teams.models.MessagingExtensionSuggestedAction + :param text: (Only when type is message) Text + :type text: str + :param activity_preview: (Only when type is botMessagePreview) Message + activity to preview + :type activity_preview: ~botframework.connector.teams.models.Activity + """ + + _attribute_map = { + "attachment_layout": {"key": "attachmentLayout", "type": "str"}, + "type": {"key": "type", "type": "str"}, + "attachments": {"key": "attachments", "type": "[MessagingExtensionAttachment]"}, + "suggested_actions": { + "key": "suggestedActions", + "type": "MessagingExtensionSuggestedAction", + }, + "text": {"key": "text", "type": "str"}, + "activity_preview": {"key": "activityPreview", "type": "Activity"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionResult, self).__init__(**kwargs) + self.attachment_layout = kwargs.get("attachment_layout", None) + self.type = kwargs.get("type", None) + self.attachments = kwargs.get("attachments", None) + self.suggested_actions = kwargs.get("suggested_actions", None) + self.text = kwargs.get("text", None) + self.activity_preview = kwargs.get("activity_preview", None) + + +class MessagingExtensionSuggestedAction(Model): + """Messaging extension Actions (Only when type is auth or config). + + :param actions: Actions + :type actions: list[~botframework.connector.teams.models.CardAction] + """ + + _attribute_map = { + "actions": {"key": "actions", "type": "[CardAction]"}, + } + + def __init__(self, **kwargs): + super(MessagingExtensionSuggestedAction, self).__init__(**kwargs) + self.actions = kwargs.get("actions", None) + + +class NotificationInfo(Model): + """Specifies if a notification is to be sent for the mentions. + + :param alert: true if notification is to be sent to the user, false + otherwise. + :type alert: bool + """ + + _attribute_map = { + "alert": {"key": "alert", "type": "bool"}, + } + + def __init__(self, **kwargs): + super(NotificationInfo, self).__init__(**kwargs) + self.alert = kwargs.get("alert", None) + + +class O365ConnectorCard(Model): + """O365 connector card. + + :param title: Title of the item + :type title: str + :param text: Text for the card + :type text: str + :param summary: Summary for the card + :type summary: str + :param theme_color: Theme color for the card + :type theme_color: str + :param sections: Set of sections for the current card + :type sections: + list[~botframework.connector.teams.models.O365ConnectorCardSection] + :param potential_action: Set of actions for the current card + :type potential_action: + list[~botframework.connector.teams.models.O365ConnectorCardActionBase] + """ + + _attribute_map = { + "title": {"key": "title", "type": "str"}, + "text": {"key": "text", "type": "str"}, + "summary": {"key": "summary", "type": "str"}, + "theme_color": {"key": "themeColor", "type": "str"}, + "sections": {"key": "sections", "type": "[O365ConnectorCardSection]"}, + "potential_action": { + "key": "potentialAction", + "type": "[O365ConnectorCardActionBase]", + }, + } + + def __init__(self, **kwargs): + super(O365ConnectorCard, self).__init__(**kwargs) + self.title = kwargs.get("title", None) + self.text = kwargs.get("text", None) + self.summary = kwargs.get("summary", None) + self.theme_color = kwargs.get("theme_color", None) + self.sections = kwargs.get("sections", None) + self.potential_action = kwargs.get("potential_action", None) + + +class O365ConnectorCardActionBase(Model): + """O365 connector card action base. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardActionBase, self).__init__(**kwargs) + self.type = kwargs.get("type", None) + self.name = kwargs.get("name", None) + self.id = kwargs.get("id", None) + + +class O365ConnectorCardActionCard(O365ConnectorCardActionBase): + """O365 connector card ActionCard action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param inputs: Set of inputs contained in this ActionCard whose each item + can be in any subtype of O365ConnectorCardInputBase + :type inputs: + list[~botframework.connector.teams.models.O365ConnectorCardInputBase] + :param actions: Set of actions contained in this ActionCard whose each + item can be in any subtype of O365ConnectorCardActionBase except + O365ConnectorCardActionCard, as nested ActionCard is forbidden. + :type actions: + list[~botframework.connector.teams.models.O365ConnectorCardActionBase] + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "inputs": {"key": "inputs", "type": "[O365ConnectorCardInputBase]"}, + "actions": {"key": "actions", "type": "[O365ConnectorCardActionBase]"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardActionCard, self).__init__(**kwargs) + self.inputs = kwargs.get("inputs", None) + self.actions = kwargs.get("actions", None) + + +class O365ConnectorCardActionQuery(Model): + """O365 connector card HttpPOST invoke query. + + :param body: The results of body string defined in + IO365ConnectorCardHttpPOST with substituted input values + :type body: str + :param action_id: Action Id associated with the HttpPOST action button + triggered, defined in O365ConnectorCardActionBase. + :type action_id: str + """ + + _attribute_map = { + "body": {"key": "body", "type": "str"}, + "action_id": {"key": "actionId", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardActionQuery, self).__init__(**kwargs) + self.body = kwargs.get("body", None) + self.action_id = kwargs.get("action_id", None) + + +class O365ConnectorCardDateInput(O365ConnectorCardInputBase): + """O365 connector card date input. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + :param include_time: Include time input field. Default value is false + (date only). + :type include_time: bool + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + "include_time": {"key": "includeTime", "type": "bool"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardDateInput, self).__init__(**kwargs) + self.include_time = kwargs.get("include_time", None) + + +class O365ConnectorCardFact(Model): + """O365 connector card fact. + + :param name: Display name of the fact + :type name: str + :param value: Display value for the fact + :type value: str + """ + + _attribute_map = { + "name": {"key": "name", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardFact, self).__init__(**kwargs) + self.name = kwargs.get("name", None) + self.value = kwargs.get("value", None) + + +class O365ConnectorCardHttpPOST(O365ConnectorCardActionBase): + """O365 connector card HttpPOST action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param body: Content to be posted back to bots via invoke + :type body: str + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "body": {"key": "body", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardHttpPOST, self).__init__(**kwargs) + self.body = kwargs.get("body", None) + + +class O365ConnectorCardImage(Model): + """O365 connector card image. + + :param image: URL for the image + :type image: str + :param title: Alternative text for the image + :type title: str + """ + + _attribute_map = { + "image": {"key": "image", "type": "str"}, + "title": {"key": "title", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardImage, self).__init__(**kwargs) + self.image = kwargs.get("image", None) + self.title = kwargs.get("title", None) + + +class O365ConnectorCardInputBase(Model): + """O365 connector card input for ActionCard action. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardInputBase, self).__init__(**kwargs) + self.type = kwargs.get("type", None) + self.id = kwargs.get("id", None) + self.is_required = kwargs.get("is_required", None) + self.title = kwargs.get("title", None) + self.value = kwargs.get("value", None) + + +class O365ConnectorCardMultichoiceInput(O365ConnectorCardInputBase): + """O365 connector card multiple choice input. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + :param choices: Set of choices whose each item can be in any subtype of + O365ConnectorCardMultichoiceInputChoice. + :type choices: + list[~botframework.connector.teams.models.O365ConnectorCardMultichoiceInputChoice] + :param style: Choice item rendering style. Default value is 'compact'. + Possible values include: 'compact', 'expanded' + :type style: str or ~botframework.connector.teams.models.enum + :param is_multi_select: Define if this input field allows multiple + selections. Default value is false. + :type is_multi_select: bool + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + "choices": { + "key": "choices", + "type": "[O365ConnectorCardMultichoiceInputChoice]", + }, + "style": {"key": "style", "type": "str"}, + "is_multi_select": {"key": "isMultiSelect", "type": "bool"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardMultichoiceInput, self).__init__(**kwargs) + self.choices = kwargs.get("choices", None) + self.style = kwargs.get("style", None) + self.is_multi_select = kwargs.get("is_multi_select", None) + + +class O365ConnectorCardMultichoiceInputChoice(Model): + """O365O365 connector card multiple choice input item. + + :param display: The text rendered on ActionCard. + :type display: str + :param value: The value received as results. + :type value: str + """ + + _attribute_map = { + "display": {"key": "display", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardMultichoiceInputChoice, self).__init__(**kwargs) + self.display = kwargs.get("display", None) + self.value = kwargs.get("value", None) + + +class O365ConnectorCardOpenUriTarget(Model): + """O365 connector card OpenUri target. + + :param os: Target operating system. Possible values include: 'default', + 'iOS', 'android', 'windows' + :type os: str or ~botframework.connector.teams.models.enum + :param uri: Target url + :type uri: str + """ + + _attribute_map = { + "os": {"key": "os", "type": "str"}, + "uri": {"key": "uri", "type": "str"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardOpenUriTarget, self).__init__(**kwargs) + self.os = kwargs.get("os", None) + self.uri = kwargs.get("uri", None) + + +class O365ConnectorCardOpenUri(O365ConnectorCardActionBase): + """O365 connector card OpenUri action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param targets: Target os / urls + :type targets: + list[~botframework.connector.teams.models.O365ConnectorCardOpenUriTarget] + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "targets": {"key": "targets", "type": "[O365ConnectorCardOpenUriTarget]"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardOpenUri, self).__init__(**kwargs) + self.targets = kwargs.get("targets", None) + + +class O365ConnectorCardSection(Model): + """O365 connector card section. + + :param title: Title of the section + :type title: str + :param text: Text for the section + :type text: str + :param activity_title: Activity title + :type activity_title: str + :param activity_subtitle: Activity subtitle + :type activity_subtitle: str + :param activity_text: Activity text + :type activity_text: str + :param activity_image: Activity image + :type activity_image: str + :param activity_image_type: Describes how Activity image is rendered. + Possible values include: 'avatar', 'article' + :type activity_image_type: str or + ~botframework.connector.teams.models.enum + :param markdown: Use markdown for all text contents. Default value is + true. + :type markdown: bool + :param facts: Set of facts for the current section + :type facts: + list[~botframework.connector.teams.models.O365ConnectorCardFact] + :param images: Set of images for the current section + :type images: + list[~botframework.connector.teams.models.O365ConnectorCardImage] + :param potential_action: Set of actions for the current section + :type potential_action: + list[~botframework.connector.teams.models.O365ConnectorCardActionBase] + """ + + _attribute_map = { + "title": {"key": "title", "type": "str"}, + "text": {"key": "text", "type": "str"}, + "activity_title": {"key": "activityTitle", "type": "str"}, + "activity_subtitle": {"key": "activitySubtitle", "type": "str"}, + "activity_text": {"key": "activityText", "type": "str"}, + "activity_image": {"key": "activityImage", "type": "str"}, + "activity_image_type": {"key": "activityImageType", "type": "str"}, + "markdown": {"key": "markdown", "type": "bool"}, + "facts": {"key": "facts", "type": "[O365ConnectorCardFact]"}, + "images": {"key": "images", "type": "[O365ConnectorCardImage]"}, + "potential_action": { + "key": "potentialAction", + "type": "[O365ConnectorCardActionBase]", + }, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardSection, self).__init__(**kwargs) + self.title = kwargs.get("title", None) + self.text = kwargs.get("text", None) + self.activity_title = kwargs.get("activity_title", None) + self.activity_subtitle = kwargs.get("activity_subtitle", None) + self.activity_text = kwargs.get("activity_text", None) + self.activity_image = kwargs.get("activity_image", None) + self.activity_image_type = kwargs.get("activity_image_type", None) + self.markdown = kwargs.get("markdown", None) + self.facts = kwargs.get("facts", None) + self.images = kwargs.get("images", None) + self.potential_action = kwargs.get("potential_action", None) + + +class O365ConnectorCardTextInput(O365ConnectorCardInputBase): + """O365 connector card text input. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + :param is_multiline: Define if text input is allowed for multiple lines. + Default value is false. + :type is_multiline: bool + :param max_length: Maximum length of text input. Default value is + unlimited. + :type max_length: float + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + "is_multiline": {"key": "isMultiline", "type": "bool"}, + "max_length": {"key": "maxLength", "type": "float"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardTextInput, self).__init__(**kwargs) + self.is_multiline = kwargs.get("is_multiline", None) + self.max_length = kwargs.get("max_length", None) + + +class O365ConnectorCardViewAction(O365ConnectorCardActionBase): + """O365 connector card ViewAction action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param target: Target urls, only the first url effective for card button + :type target: list[str] + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "target": {"key": "target", "type": "[str]"}, + } + + def __init__(self, **kwargs): + super(O365ConnectorCardViewAction, self).__init__(**kwargs) + self.target = kwargs.get("target", None) + + +class SigninStateVerificationQuery(Model): + """Signin state (part of signin action auth flow) verification invoke query. + + :param state: The state string originally received when the signin web + flow is finished with a state posted back to client via tab SDK + microsoftTeams.authentication.notifySuccess(state) + :type state: str + """ + + _attribute_map = { + "state": {"key": "state", "type": "str"}, + } + + def __init__(self, **kwargs): + super(SigninStateVerificationQuery, self).__init__(**kwargs) + self.state = kwargs.get("state", None) + + +class TaskModuleContinueResponse(TaskModuleResponseBase): + """Task Module Response with continue action. + + :param type: Choice of action options when responding to the task/submit + message. Possible values include: 'message', 'continue' + :type type: str or ~botframework.connector.teams.models.enum + :param value: The JSON for the Adaptive card to appear in the task module. + :type value: ~botframework.connector.teams.models.TaskModuleTaskInfo + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + "value": {"key": "value", "type": "TaskModuleTaskInfo"}, + } + + def __init__(self, **kwargs): + super(TaskModuleContinueResponse, self).__init__(**kwargs) + self.value = kwargs.get("value", None) + + +class TaskModuleMessageResponse(TaskModuleResponseBase): + """Task Module response with message action. + + :param type: Choice of action options when responding to the task/submit + message. Possible values include: 'message', 'continue' + :type type: str or ~botframework.connector.teams.models.enum + :param value: Teams will display the value of value in a popup message + box. + :type value: str + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TaskModuleMessageResponse, self).__init__(**kwargs) + self.value = kwargs.get("value", None) + + +class TaskModuleRequest(Model): + """Task module invoke request value payload. + + :param data: User input data. Free payload with key-value pairs. + :type data: object + :param context: Current user context, i.e., the current theme + :type context: + ~botframework.connector.teams.models.TaskModuleRequestContext + """ + + _attribute_map = { + "data": {"key": "data", "type": "object"}, + "context": {"key": "context", "type": "TaskModuleRequestContext"}, + } + + def __init__(self, **kwargs): + super(TaskModuleRequest, self).__init__(**kwargs) + self.data = kwargs.get("data", None) + self.context = kwargs.get("context", None) + + +class TaskModuleRequestContext(Model): + """Current user context, i.e., the current theme. + + :param theme: + :type theme: str + """ + + _attribute_map = { + "theme": {"key": "theme", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TaskModuleRequestContext, self).__init__(**kwargs) + self.theme = kwargs.get("theme", None) + + +class TaskModuleResponse(Model): + """Envelope for Task Module Response. + + :param task: The JSON for the Adaptive card to appear in the task module. + :type task: ~botframework.connector.teams.models.TaskModuleResponseBase + """ + + _attribute_map = { + "task": {"key": "task", "type": "TaskModuleResponseBase"}, + } + + def __init__(self, **kwargs): + super(TaskModuleResponse, self).__init__(**kwargs) + self.task = kwargs.get("task", None) + + +class TaskModuleResponseBase(Model): + """Base class for Task Module responses. + + :param type: Choice of action options when responding to the task/submit + message. Possible values include: 'message', 'continue' + :type type: str or ~botframework.connector.teams.models.enum + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TaskModuleResponseBase, self).__init__(**kwargs) + self.type = kwargs.get("type", None) + + +class TaskModuleTaskInfo(Model): + """Metadata for a Task Module. + + :param title: Appears below the app name and to the right of the app icon. + :type title: str + :param height: This can be a number, representing the task module's height + in pixels, or a string, one of: small, medium, large. + :type height: object + :param width: This can be a number, representing the task module's width + in pixels, or a string, one of: small, medium, large. + :type width: object + :param url: The URL of what is loaded as an iframe inside the task module. + One of url or card is required. + :type url: str + :param card: The JSON for the Adaptive card to appear in the task module. + :type card: ~botframework.connector.teams.models.Attachment + :param fallback_url: If a client does not support the task module feature, + this URL is opened in a browser tab. + :type fallback_url: str + :param completion_bot_id: If a client does not support the task module + feature, this URL is opened in a browser tab. + :type completion_bot_id: str + """ + + _attribute_map = { + "title": {"key": "title", "type": "str"}, + "height": {"key": "height", "type": "object"}, + "width": {"key": "width", "type": "object"}, + "url": {"key": "url", "type": "str"}, + "card": {"key": "card", "type": "Attachment"}, + "fallback_url": {"key": "fallbackUrl", "type": "str"}, + "completion_bot_id": {"key": "completionBotId", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TaskModuleTaskInfo, self).__init__(**kwargs) + self.title = kwargs.get("title", None) + self.height = kwargs.get("height", None) + self.width = kwargs.get("width", None) + self.url = kwargs.get("url", None) + self.card = kwargs.get("card", None) + self.fallback_url = kwargs.get("fallback_url", None) + self.completion_bot_id = kwargs.get("completion_bot_id", None) + + +class TeamDetails(Model): + """Details related to a team. + + :param id: Unique identifier representing a team + :type id: str + :param name: Name of team. + :type name: str + :param aad_group_id: Azure Active Directory (AAD) Group Id for the team. + :type aad_group_id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "aad_group_id": {"key": "aadGroupId", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TeamDetails, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.name = kwargs.get("name", None) + self.aad_group_id = kwargs.get("aad_group_id", None) + + +class TeamInfo(Model): + """Describes a team. + + :param id: Unique identifier representing a team + :type id: str + :param name: Name of team. + :type name: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TeamInfo, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.name = kwargs.get("name", None) + + +class TeamsChannelAccount(ChannelAccount): + """Teams channel account detailing user Azure Active Directory details. + + :param id: Channel id for the user or bot on this channel (Example: + joe@smith.com, or @joesmith or 123456) + :type id: str + :param name: Display friendly name + :type name: str + :param given_name: Given name part of the user name. + :type given_name: str + :param surname: Surname part of the user name. + :type surname: str + :param email: Email Id of the user. + :type email: str + :param user_principal_name: Unique user principal name + :type user_principal_name: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "given_name": {"key": "givenName", "type": "str"}, + "surname": {"key": "surname", "type": "str"}, + "email": {"key": "email", "type": "str"}, + "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TeamsChannelAccount, self).__init__(**kwargs) + self.given_name = kwargs.get("given_name", None) + self.surname = kwargs.get("surname", None) + self.email = kwargs.get("email", None) + self.user_principal_name = kwargs.get("user_principal_name", None) + + +class TeamsChannelData(Model): + """Channel data specific to messages received in Microsoft Teams. + + :param channel: Information about the channel in which the message was + sent + :type channel: ~botframework.connector.teams.models.ChannelInfo + :param event_type: Type of event. + :type event_type: str + :param team: Information about the team in which the message was sent + :type team: ~botframework.connector.teams.models.TeamInfo + :param notification: Notification settings for the message + :type notification: ~botframework.connector.teams.models.NotificationInfo + :param tenant: Information about the tenant in which the message was sent + :type tenant: ~botframework.connector.teams.models.TenantInfo + """ + + _attribute_map = { + "channel": {"key": "channel", "type": "ChannelInfo"}, + "event_type": {"key": "eventType", "type": "str"}, + "team": {"key": "team", "type": "TeamInfo"}, + "notification": {"key": "notification", "type": "NotificationInfo"}, + "tenant": {"key": "tenant", "type": "TenantInfo"}, + } + + def __init__(self, **kwargs): + super(TeamsChannelData, self).__init__(**kwargs) + self.channel = kwargs.get("channel", None) + self.event_type = kwargs.get("event_type", None) + self.team = kwargs.get("team", None) + self.notification = kwargs.get("notification", None) + self.tenant = kwargs.get("tenant", None) + + +class TenantInfo(Model): + """Describes a tenant. + + :param id: Unique identifier representing a tenant + :type id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TenantInfo, self).__init__(**kwargs) + self.id = kwargs.get("id", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py new file mode 100644 index 000000000..80249f277 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -0,0 +1,1875 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +from msrest.serialization import Model +from botbuilder.schema import Attachment, ChannelAccount + + +class TaskModuleRequest(Model): + """Task module invoke request value payload. + + :param data: User input data. Free payload with key-value pairs. + :type data: object + :param context: Current user context, i.e., the current theme + :type context: + ~botframework.connector.teams.models.TaskModuleRequestContext + """ + + _attribute_map = { + "data": {"key": "data", "type": "object"}, + "context": {"key": "context", "type": "TaskModuleRequestContext"}, + } + + def __init__(self, *, data=None, context=None, **kwargs) -> None: + super(TaskModuleRequest, self).__init__(**kwargs) + self.data = data + self.context = context + + +class AppBasedLinkQuery(Model): + """Invoke request body type for app-based link query. + + :param url: Url queried by user + :type url: str + """ + + _attribute_map = { + "url": {"key": "url", "type": "str"}, + } + + def __init__(self, *, url: str = None, **kwargs) -> None: + super(AppBasedLinkQuery, self).__init__(**kwargs) + self.url = url + + +class ChannelInfo(Model): + """A channel info object which describes the channel. + + :param id: Unique identifier representing a channel + :type id: str + :param name: Name of the channel + :type name: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + } + + def __init__(self, *, id: str = None, name: str = None, **kwargs) -> None: + super(ChannelInfo, self).__init__(**kwargs) + self.id = id + self.name = name + + +class ConversationList(Model): + """List of channels under a team. + + :param conversations: + :type conversations: + list[~botframework.connector.teams.models.ChannelInfo] + """ + + _attribute_map = { + "conversations": {"key": "conversations", "type": "[ChannelInfo]"}, + } + + def __init__(self, *, conversations=None, **kwargs) -> None: + super(ConversationList, self).__init__(**kwargs) + self.conversations = conversations + + +class FileConsentCard(Model): + """File consent card attachment. + + :param description: File description. + :type description: str + :param size_in_bytes: Size of the file to be uploaded in Bytes. + :type size_in_bytes: long + :param accept_context: Context sent back to the Bot if user consented to + upload. This is free flow schema and is sent back in Value field of + Activity. + :type accept_context: object + :param decline_context: Context sent back to the Bot if user declined. + This is free flow schema and is sent back in Value field of Activity. + :type decline_context: object + """ + + _attribute_map = { + "description": {"key": "description", "type": "str"}, + "size_in_bytes": {"key": "sizeInBytes", "type": "long"}, + "accept_context": {"key": "acceptContext", "type": "object"}, + "decline_context": {"key": "declineContext", "type": "object"}, + } + + def __init__( + self, + *, + description: str = None, + size_in_bytes: int = None, + accept_context=None, + decline_context=None, + **kwargs + ) -> None: + super(FileConsentCard, self).__init__(**kwargs) + self.description = description + self.size_in_bytes = size_in_bytes + self.accept_context = accept_context + self.decline_context = decline_context + + +class FileConsentCardResponse(Model): + """Represents the value of the invoke activity sent when the user acts on a + file consent card. + + :param action: The action the user took. Possible values include: + 'accept', 'decline' + :type action: str or ~botframework.connector.teams.models.enum + :param context: The context associated with the action. + :type context: object + :param upload_info: If the user accepted the file, contains information + about the file to be uploaded. + :type upload_info: ~botframework.connector.teams.models.FileUploadInfo + """ + + _attribute_map = { + "action": {"key": "action", "type": "str"}, + "context": {"key": "context", "type": "object"}, + "upload_info": {"key": "uploadInfo", "type": "FileUploadInfo"}, + } + + def __init__( + self, *, action=None, context=None, upload_info=None, **kwargs + ) -> None: + super(FileConsentCardResponse, self).__init__(**kwargs) + self.action = action + self.context = context + self.upload_info = upload_info + + +class FileDownloadInfo(Model): + """File download info attachment. + + :param download_url: File download url. + :type download_url: str + :param unique_id: Unique Id for the file. + :type unique_id: str + :param file_type: Type of file. + :type file_type: str + :param etag: ETag for the file. + :type etag: object + """ + + _attribute_map = { + "download_url": {"key": "downloadUrl", "type": "str"}, + "unique_id": {"key": "uniqueId", "type": "str"}, + "file_type": {"key": "fileType", "type": "str"}, + "etag": {"key": "etag", "type": "object"}, + } + + def __init__( + self, + *, + download_url: str = None, + unique_id: str = None, + file_type: str = None, + etag=None, + **kwargs + ) -> None: + super(FileDownloadInfo, self).__init__(**kwargs) + self.download_url = download_url + self.unique_id = unique_id + self.file_type = file_type + self.etag = etag + + +class FileInfoCard(Model): + """File info card. + + :param unique_id: Unique Id for the file. + :type unique_id: str + :param file_type: Type of file. + :type file_type: str + :param etag: ETag for the file. + :type etag: object + """ + + _attribute_map = { + "unique_id": {"key": "uniqueId", "type": "str"}, + "file_type": {"key": "fileType", "type": "str"}, + "etag": {"key": "etag", "type": "object"}, + } + + def __init__( + self, *, unique_id: str = None, file_type: str = None, etag=None, **kwargs + ) -> None: + super(FileInfoCard, self).__init__(**kwargs) + self.unique_id = unique_id + self.file_type = file_type + self.etag = etag + + +class FileUploadInfo(Model): + """Information about the file to be uploaded. + + :param name: Name of the file. + :type name: str + :param upload_url: URL to an upload session that the bot can use to set + the file contents. + :type upload_url: str + :param content_url: URL to file. + :type content_url: str + :param unique_id: ID that uniquely identifies the file. + :type unique_id: str + :param file_type: Type of the file. + :type file_type: str + """ + + _attribute_map = { + "name": {"key": "name", "type": "str"}, + "upload_url": {"key": "uploadUrl", "type": "str"}, + "content_url": {"key": "contentUrl", "type": "str"}, + "unique_id": {"key": "uniqueId", "type": "str"}, + "file_type": {"key": "fileType", "type": "str"}, + } + + def __init__( + self, + *, + name: str = None, + upload_url: str = None, + content_url: str = None, + unique_id: str = None, + file_type: str = None, + **kwargs + ) -> None: + super(FileUploadInfo, self).__init__(**kwargs) + self.name = name + self.upload_url = upload_url + self.content_url = content_url + self.unique_id = unique_id + self.file_type = file_type + + +class MessageActionsPayloadApp(Model): + """Represents an application entity. + + :param application_identity_type: The type of application. Possible values + include: 'aadApplication', 'bot', 'tenantBot', 'office365Connector', + 'webhook' + :type application_identity_type: str or + ~botframework.connector.teams.models.enum + :param id: The id of the application. + :type id: str + :param display_name: The plaintext display name of the application. + :type display_name: str + """ + + _attribute_map = { + "application_identity_type": {"key": "applicationIdentityType", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__( + self, + *, + application_identity_type=None, + id: str = None, + display_name: str = None, + **kwargs + ) -> None: + super(MessageActionsPayloadApp, self).__init__(**kwargs) + self.application_identity_type = application_identity_type + self.id = id + self.display_name = display_name + + +class MessageActionsPayloadAttachment(Model): + """Represents the attachment in a message. + + :param id: The id of the attachment. + :type id: str + :param content_type: The type of the attachment. + :type content_type: str + :param content_url: The url of the attachment, in case of a external link. + :type content_url: str + :param content: The content of the attachment, in case of a code snippet, + email, or file. + :type content: object + :param name: The plaintext display name of the attachment. + :type name: str + :param thumbnail_url: The url of a thumbnail image that might be embedded + in the attachment, in case of a card. + :type thumbnail_url: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "content_type": {"key": "contentType", "type": "str"}, + "content_url": {"key": "contentUrl", "type": "str"}, + "content": {"key": "content", "type": "object"}, + "name": {"key": "name", "type": "str"}, + "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, + } + + def __init__( + self, + *, + id: str = None, + content_type: str = None, + content_url: str = None, + content=None, + name: str = None, + thumbnail_url: str = None, + **kwargs + ) -> None: + super(MessageActionsPayloadAttachment, self).__init__(**kwargs) + self.id = id + self.content_type = content_type + self.content_url = content_url + self.content = content + self.name = name + self.thumbnail_url = thumbnail_url + + +class MessageActionsPayloadBody(Model): + """Plaintext/HTML representation of the content of the message. + + :param content_type: Type of the content. Possible values include: 'html', + 'text' + :type content_type: str or ~botframework.connector.teams.models.enum + :param content: The content of the body. + :type content: str + """ + + _attribute_map = { + "content_type": {"key": "contentType", "type": "str"}, + "content": {"key": "content", "type": "str"}, + } + + def __init__(self, *, content_type=None, content: str = None, **kwargs) -> None: + super(MessageActionsPayloadBody, self).__init__(**kwargs) + self.content_type = content_type + self.content = content + + +class MessageActionsPayloadConversation(Model): + """Represents a team or channel entity. + + :param conversation_identity_type: The type of conversation, whether a + team or channel. Possible values include: 'team', 'channel' + :type conversation_identity_type: str or + ~botframework.connector.teams.models.enum + :param id: The id of the team or channel. + :type id: str + :param display_name: The plaintext display name of the team or channel + entity. + :type display_name: str + """ + + _attribute_map = { + "conversation_identity_type": { + "key": "conversationIdentityType", + "type": "str", + }, + "id": {"key": "id", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__( + self, + *, + conversation_identity_type=None, + id: str = None, + display_name: str = None, + **kwargs + ) -> None: + super(MessageActionsPayloadConversation, self).__init__(**kwargs) + self.conversation_identity_type = conversation_identity_type + self.id = id + self.display_name = display_name + + +class MessageActionsPayloadFrom(Model): + """Represents a user, application, or conversation type that either sent or + was referenced in a message. + + :param user: Represents details of the user. + :type user: ~botframework.connector.teams.models.MessageActionsPayloadUser + :param application: Represents details of the app. + :type application: + ~botframework.connector.teams.models.MessageActionsPayloadApp + :param conversation: Represents details of the converesation. + :type conversation: + ~botframework.connector.teams.models.MessageActionsPayloadConversation + """ + + _attribute_map = { + "user": {"key": "user", "type": "MessageActionsPayloadUser"}, + "application": {"key": "application", "type": "MessageActionsPayloadApp"}, + "conversation": { + "key": "conversation", + "type": "MessageActionsPayloadConversation", + }, + } + + def __init__( + self, *, user=None, application=None, conversation=None, **kwargs + ) -> None: + super(MessageActionsPayloadFrom, self).__init__(**kwargs) + self.user = user + self.application = application + self.conversation = conversation + + +class MessageActionsPayloadMention(Model): + """Represents the entity that was mentioned in the message. + + :param id: The id of the mentioned entity. + :type id: int + :param mention_text: The plaintext display name of the mentioned entity. + :type mention_text: str + :param mentioned: Provides more details on the mentioned entity. + :type mentioned: + ~botframework.connector.teams.models.MessageActionsPayloadFrom + """ + + _attribute_map = { + "id": {"key": "id", "type": "int"}, + "mention_text": {"key": "mentionText", "type": "str"}, + "mentioned": {"key": "mentioned", "type": "MessageActionsPayloadFrom"}, + } + + def __init__( + self, *, id: int = None, mention_text: str = None, mentioned=None, **kwargs + ) -> None: + super(MessageActionsPayloadMention, self).__init__(**kwargs) + self.id = id + self.mention_text = mention_text + self.mentioned = mentioned + + +class MessageActionsPayloadReaction(Model): + """Represents the reaction of a user to a message. + + :param reaction_type: The type of reaction given to the message. Possible + values include: 'like', 'heart', 'laugh', 'surprised', 'sad', 'angry' + :type reaction_type: str or ~botframework.connector.teams.models.enum + :param created_date_time: Timestamp of when the user reacted to the + message. + :type created_date_time: str + :param user: The user with which the reaction is associated. + :type user: ~botframework.connector.teams.models.MessageActionsPayloadFrom + """ + + _attribute_map = { + "reaction_type": {"key": "reactionType", "type": "str"}, + "created_date_time": {"key": "createdDateTime", "type": "str"}, + "user": {"key": "user", "type": "MessageActionsPayloadFrom"}, + } + + def __init__( + self, *, reaction_type=None, created_date_time: str = None, user=None, **kwargs + ) -> None: + super(MessageActionsPayloadReaction, self).__init__(**kwargs) + self.reaction_type = reaction_type + self.created_date_time = created_date_time + self.user = user + + +class MessageActionsPayloadUser(Model): + """Represents a user entity. + + :param user_identity_type: The identity type of the user. Possible values + include: 'aadUser', 'onPremiseAadUser', 'anonymousGuest', 'federatedUser' + :type user_identity_type: str or ~botframework.connector.teams.models.enum + :param id: The id of the user. + :type id: str + :param display_name: The plaintext display name of the user. + :type display_name: str + """ + + _attribute_map = { + "user_identity_type": {"key": "userIdentityType", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__( + self, + *, + user_identity_type=None, + id: str = None, + display_name: str = None, + **kwargs + ) -> None: + super(MessageActionsPayloadUser, self).__init__(**kwargs) + self.user_identity_type = user_identity_type + self.id = id + self.display_name = display_name + + +class MessageActionsPayload(Model): + """Represents the individual message within a chat or channel where a message + actions is taken. + + :param id: Unique id of the message. + :type id: str + :param reply_to_id: Id of the parent/root message of the thread. + :type reply_to_id: str + :param message_type: Type of message - automatically set to message. + Possible values include: 'message' + :type message_type: str or ~botframework.connector.teams.models.enum + :param created_date_time: Timestamp of when the message was created. + :type created_date_time: str + :param last_modified_date_time: Timestamp of when the message was edited + or updated. + :type last_modified_date_time: str + :param deleted: Indicates whether a message has been soft deleted. + :type deleted: bool + :param subject: Subject line of the message. + :type subject: str + :param summary: Summary text of the message that could be used for + notifications. + :type summary: str + :param importance: The importance of the message. Possible values include: + 'normal', 'high', 'urgent' + :type importance: str or ~botframework.connector.teams.models.enum + :param locale: Locale of the message set by the client. + :type locale: str + :param from_property: Sender of the message. + :type from_property: + ~botframework.connector.teams.models.MessageActionsPayloadFrom + :param body: Plaintext/HTML representation of the content of the message. + :type body: ~botframework.connector.teams.models.MessageActionsPayloadBody + :param attachment_layout: How the attachment(s) are displayed in the + message. + :type attachment_layout: str + :param attachments: Attachments in the message - card, image, file, etc. + :type attachments: + list[~botframework.connector.teams.models.MessageActionsPayloadAttachment] + :param mentions: List of entities mentioned in the message. + :type mentions: + list[~botframework.connector.teams.models.MessageActionsPayloadMention] + :param reactions: Reactions for the message. + :type reactions: + list[~botframework.connector.teams.models.MessageActionsPayloadReaction] + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "reply_to_id": {"key": "replyToId", "type": "str"}, + "message_type": {"key": "messageType", "type": "str"}, + "created_date_time": {"key": "createdDateTime", "type": "str"}, + "last_modified_date_time": {"key": "lastModifiedDateTime", "type": "str"}, + "deleted": {"key": "deleted", "type": "bool"}, + "subject": {"key": "subject", "type": "str"}, + "summary": {"key": "summary", "type": "str"}, + "importance": {"key": "importance", "type": "str"}, + "locale": {"key": "locale", "type": "str"}, + "from_property": {"key": "from", "type": "MessageActionsPayloadFrom"}, + "body": {"key": "body", "type": "MessageActionsPayloadBody"}, + "attachment_layout": {"key": "attachmentLayout", "type": "str"}, + "attachments": { + "key": "attachments", + "type": "[MessageActionsPayloadAttachment]", + }, + "mentions": {"key": "mentions", "type": "[MessageActionsPayloadMention]"}, + "reactions": {"key": "reactions", "type": "[MessageActionsPayloadReaction]"}, + } + + def __init__( + self, + *, + id: str = None, + reply_to_id: str = None, + message_type=None, + created_date_time: str = None, + last_modified_date_time: str = None, + deleted: bool = None, + subject: str = None, + summary: str = None, + importance=None, + locale: str = None, + from_property=None, + body=None, + attachment_layout: str = None, + attachments=None, + mentions=None, + reactions=None, + **kwargs + ) -> None: + super(MessageActionsPayload, self).__init__(**kwargs) + self.id = id + self.reply_to_id = reply_to_id + self.message_type = message_type + self.created_date_time = created_date_time + self.last_modified_date_time = last_modified_date_time + self.deleted = deleted + self.subject = subject + self.summary = summary + self.importance = importance + self.locale = locale + self.from_property = from_property + self.body = body + self.attachment_layout = attachment_layout + self.attachments = attachments + self.mentions = mentions + self.reactions = reactions + + +class MessagingExtensionAction(TaskModuleRequest): + """Messaging extension action. + + :param data: User input data. Free payload with key-value pairs. + :type data: object + :param context: Current user context, i.e., the current theme + :type context: + ~botframework.connector.teams.models.TaskModuleRequestContext + :param command_id: Id of the command assigned by Bot + :type command_id: str + :param command_context: The context from which the command originates. + Possible values include: 'message', 'compose', 'commandbox' + :type command_context: str or ~botframework.connector.teams.models.enum + :param bot_message_preview_action: Bot message preview action taken by + user. Possible values include: 'edit', 'send' + :type bot_message_preview_action: str or + ~botframework.connector.teams.models.enum + :param bot_activity_preview: + :type bot_activity_preview: + list[~botframework.connector.teams.models.Activity] + :param message_payload: Message content sent as part of the command + request. + :type message_payload: + ~botframework.connector.teams.models.MessageActionsPayload + """ + + _attribute_map = { + "data": {"key": "data", "type": "object"}, + "context": {"key": "context", "type": "TaskModuleRequestContext"}, + "command_id": {"key": "commandId", "type": "str"}, + "command_context": {"key": "commandContext", "type": "str"}, + "bot_message_preview_action": {"key": "botMessagePreviewAction", "type": "str"}, + "bot_activity_preview": {"key": "botActivityPreview", "type": "[Activity]"}, + "message_payload": {"key": "messagePayload", "type": "MessageActionsPayload"}, + } + + def __init__( + self, + *, + data=None, + context=None, + command_id: str = None, + command_context=None, + bot_message_preview_action=None, + bot_activity_preview=None, + message_payload=None, + **kwargs + ) -> None: + super(MessagingExtensionAction, self).__init__( + data=data, context=context, **kwargs + ) + self.command_id = command_id + self.command_context = command_context + self.bot_message_preview_action = bot_message_preview_action + self.bot_activity_preview = bot_activity_preview + self.message_payload = message_payload + + +class MessagingExtensionActionResponse(Model): + """Response of messaging extension action. + + :param task: The JSON for the Adaptive card to appear in the task module. + :type task: ~botframework.connector.teams.models.TaskModuleResponseBase + :param compose_extension: + :type compose_extension: + ~botframework.connector.teams.models.MessagingExtensionResult + """ + + _attribute_map = { + "task": {"key": "task", "type": "TaskModuleResponseBase"}, + "compose_extension": { + "key": "composeExtension", + "type": "MessagingExtensionResult", + }, + } + + def __init__(self, *, task=None, compose_extension=None, **kwargs) -> None: + super(MessagingExtensionActionResponse, self).__init__(**kwargs) + self.task = task + self.compose_extension = compose_extension + + +class MessagingExtensionAttachment(Attachment): + """Messaging extension attachment. + + :param content_type: mimetype/Contenttype for the file + :type content_type: str + :param content_url: Content Url + :type content_url: str + :param content: Embedded content + :type content: object + :param name: (OPTIONAL) The name of the attachment + :type name: str + :param thumbnail_url: (OPTIONAL) Thumbnail associated with attachment + :type thumbnail_url: str + :param preview: + :type preview: ~botframework.connector.teams.models.Attachment + """ + + _attribute_map = { + "content_type": {"key": "contentType", "type": "str"}, + "content_url": {"key": "contentUrl", "type": "str"}, + "content": {"key": "content", "type": "object"}, + "name": {"key": "name", "type": "str"}, + "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, + "preview": {"key": "preview", "type": "Attachment"}, + } + + def __init__( + self, + *, + content_type: str = None, + content_url: str = None, + content=None, + name: str = None, + thumbnail_url: str = None, + preview=None, + **kwargs + ) -> None: + super(MessagingExtensionAttachment, self).__init__( + content_type=content_type, + content_url=content_url, + content=content, + name=name, + thumbnail_url=thumbnail_url, + **kwargs + ) + self.preview = preview + + +class MessagingExtensionParameter(Model): + """Messaging extension query parameters. + + :param name: Name of the parameter + :type name: str + :param value: Value of the parameter + :type value: object + """ + + _attribute_map = { + "name": {"key": "name", "type": "str"}, + "value": {"key": "value", "type": "object"}, + } + + def __init__(self, *, name: str = None, value=None, **kwargs) -> None: + super(MessagingExtensionParameter, self).__init__(**kwargs) + self.name = name + self.value = value + + +class MessagingExtensionQuery(Model): + """Messaging extension query. + + :param command_id: Id of the command assigned by Bot + :type command_id: str + :param parameters: Parameters for the query + :type parameters: + list[~botframework.connector.teams.models.MessagingExtensionParameter] + :param query_options: + :type query_options: + ~botframework.connector.teams.models.MessagingExtensionQueryOptions + :param state: State parameter passed back to the bot after + authentication/configuration flow + :type state: str + """ + + _attribute_map = { + "command_id": {"key": "commandId", "type": "str"}, + "parameters": {"key": "parameters", "type": "[MessagingExtensionParameter]"}, + "query_options": { + "key": "queryOptions", + "type": "MessagingExtensionQueryOptions", + }, + "state": {"key": "state", "type": "str"}, + } + + def __init__( + self, + *, + command_id: str = None, + parameters=None, + query_options=None, + state: str = None, + **kwargs + ) -> None: + super(MessagingExtensionQuery, self).__init__(**kwargs) + self.command_id = command_id + self.parameters = parameters + self.query_options = query_options + self.state = state + + +class MessagingExtensionQueryOptions(Model): + """Messaging extension query options. + + :param skip: Number of entities to skip + :type skip: int + :param count: Number of entities to fetch + :type count: int + """ + + _attribute_map = { + "skip": {"key": "skip", "type": "int"}, + "count": {"key": "count", "type": "int"}, + } + + def __init__(self, *, skip: int = None, count: int = None, **kwargs) -> None: + super(MessagingExtensionQueryOptions, self).__init__(**kwargs) + self.skip = skip + self.count = count + + +class MessagingExtensionResponse(Model): + """Messaging extension response. + + :param compose_extension: + :type compose_extension: + ~botframework.connector.teams.models.MessagingExtensionResult + """ + + _attribute_map = { + "compose_extension": { + "key": "composeExtension", + "type": "MessagingExtensionResult", + }, + } + + def __init__(self, *, compose_extension=None, **kwargs) -> None: + super(MessagingExtensionResponse, self).__init__(**kwargs) + self.compose_extension = compose_extension + + +class MessagingExtensionResult(Model): + """Messaging extension result. + + :param attachment_layout: Hint for how to deal with multiple attachments. + Possible values include: 'list', 'grid' + :type attachment_layout: str or ~botframework.connector.teams.models.enum + :param type: The type of the result. Possible values include: 'result', + 'auth', 'config', 'message', 'botMessagePreview' + :type type: str or ~botframework.connector.teams.models.enum + :param attachments: (Only when type is result) Attachments + :type attachments: + list[~botframework.connector.teams.models.MessagingExtensionAttachment] + :param suggested_actions: + :type suggested_actions: + ~botframework.connector.teams.models.MessagingExtensionSuggestedAction + :param text: (Only when type is message) Text + :type text: str + :param activity_preview: (Only when type is botMessagePreview) Message + activity to preview + :type activity_preview: ~botframework.connector.teams.models.Activity + """ + + _attribute_map = { + "attachment_layout": {"key": "attachmentLayout", "type": "str"}, + "type": {"key": "type", "type": "str"}, + "attachments": {"key": "attachments", "type": "[MessagingExtensionAttachment]"}, + "suggested_actions": { + "key": "suggestedActions", + "type": "MessagingExtensionSuggestedAction", + }, + "text": {"key": "text", "type": "str"}, + "activity_preview": {"key": "activityPreview", "type": "Activity"}, + } + + def __init__( + self, + *, + attachment_layout=None, + type=None, + attachments=None, + suggested_actions=None, + text: str = None, + activity_preview=None, + **kwargs + ) -> None: + super(MessagingExtensionResult, self).__init__(**kwargs) + self.attachment_layout = attachment_layout + self.type = type + self.attachments = attachments + self.suggested_actions = suggested_actions + self.text = text + self.activity_preview = activity_preview + + +class MessagingExtensionSuggestedAction(Model): + """Messaging extension Actions (Only when type is auth or config). + + :param actions: Actions + :type actions: list[~botframework.connector.teams.models.CardAction] + """ + + _attribute_map = { + "actions": {"key": "actions", "type": "[CardAction]"}, + } + + def __init__(self, *, actions=None, **kwargs) -> None: + super(MessagingExtensionSuggestedAction, self).__init__(**kwargs) + self.actions = actions + + +class NotificationInfo(Model): + """Specifies if a notification is to be sent for the mentions. + + :param alert: true if notification is to be sent to the user, false + otherwise. + :type alert: bool + """ + + _attribute_map = { + "alert": {"key": "alert", "type": "bool"}, + } + + def __init__(self, *, alert: bool = None, **kwargs) -> None: + super(NotificationInfo, self).__init__(**kwargs) + self.alert = alert + + +class O365ConnectorCard(Model): + """O365 connector card. + + :param title: Title of the item + :type title: str + :param text: Text for the card + :type text: str + :param summary: Summary for the card + :type summary: str + :param theme_color: Theme color for the card + :type theme_color: str + :param sections: Set of sections for the current card + :type sections: + list[~botframework.connector.teams.models.O365ConnectorCardSection] + :param potential_action: Set of actions for the current card + :type potential_action: + list[~botframework.connector.teams.models.O365ConnectorCardActionBase] + """ + + _attribute_map = { + "title": {"key": "title", "type": "str"}, + "text": {"key": "text", "type": "str"}, + "summary": {"key": "summary", "type": "str"}, + "theme_color": {"key": "themeColor", "type": "str"}, + "sections": {"key": "sections", "type": "[O365ConnectorCardSection]"}, + "potential_action": { + "key": "potentialAction", + "type": "[O365ConnectorCardActionBase]", + }, + } + + def __init__( + self, + *, + title: str = None, + text: str = None, + summary: str = None, + theme_color: str = None, + sections=None, + potential_action=None, + **kwargs + ) -> None: + super(O365ConnectorCard, self).__init__(**kwargs) + self.title = title + self.text = text + self.summary = summary + self.theme_color = theme_color + self.sections = sections + self.potential_action = potential_action + + +class O365ConnectorCardInputBase(Model): + """O365 connector card input for ActionCard action. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__( + self, + *, + type=None, + id: str = None, + is_required: bool = None, + title: str = None, + value: str = None, + **kwargs + ) -> None: + super(O365ConnectorCardInputBase, self).__init__(**kwargs) + self.type = type + self.id = id + self.is_required = is_required + self.title = title + self.value = value + + +class O365ConnectorCardActionBase(Model): + """O365 connector card action base. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + } + + def __init__( + self, *, type=None, name: str = None, id: str = None, **kwargs + ) -> None: + super(O365ConnectorCardActionBase, self).__init__(**kwargs) + self.type = type + self.name = name + self.id = id + + +class O365ConnectorCardActionCard(O365ConnectorCardActionBase): + """O365 connector card ActionCard action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param inputs: Set of inputs contained in this ActionCard whose each item + can be in any subtype of O365ConnectorCardInputBase + :type inputs: + list[~botframework.connector.teams.models.O365ConnectorCardInputBase] + :param actions: Set of actions contained in this ActionCard whose each + item can be in any subtype of O365ConnectorCardActionBase except + O365ConnectorCardActionCard, as nested ActionCard is forbidden. + :type actions: + list[~botframework.connector.teams.models.O365ConnectorCardActionBase] + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "inputs": {"key": "inputs", "type": "[O365ConnectorCardInputBase]"}, + "actions": {"key": "actions", "type": "[O365ConnectorCardActionBase]"}, + } + + def __init__( + self, + *, + type=None, + name: str = None, + id: str = None, + inputs=None, + actions=None, + **kwargs + ) -> None: + super(O365ConnectorCardActionCard, self).__init__( + type=type, name=name, id=id, **kwargs + ) + self.inputs = inputs + self.actions = actions + + +class O365ConnectorCardActionQuery(Model): + """O365 connector card HttpPOST invoke query. + + :param body: The results of body string defined in + IO365ConnectorCardHttpPOST with substituted input values + :type body: str + :param action_id: Action Id associated with the HttpPOST action button + triggered, defined in O365ConnectorCardActionBase. + :type action_id: str + """ + + _attribute_map = { + "body": {"key": "body", "type": "str"}, + "action_id": {"key": "actionId", "type": "str"}, + } + + def __init__(self, *, body: str = None, action_id: str = None, **kwargs) -> None: + super(O365ConnectorCardActionQuery, self).__init__(**kwargs) + self.body = body + self.action_id = action_id + + +class O365ConnectorCardDateInput(O365ConnectorCardInputBase): + """O365 connector card date input. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + :param include_time: Include time input field. Default value is false + (date only). + :type include_time: bool + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + "include_time": {"key": "includeTime", "type": "bool"}, + } + + def __init__( + self, + *, + type=None, + id: str = None, + is_required: bool = None, + title: str = None, + value: str = None, + include_time: bool = None, + **kwargs + ) -> None: + super(O365ConnectorCardDateInput, self).__init__( + type=type, + id=id, + is_required=is_required, + title=title, + value=value, + **kwargs + ) + self.include_time = include_time + + +class O365ConnectorCardFact(Model): + """O365 connector card fact. + + :param name: Display name of the fact + :type name: str + :param value: Display value for the fact + :type value: str + """ + + _attribute_map = { + "name": {"key": "name", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, *, name: str = None, value: str = None, **kwargs) -> None: + super(O365ConnectorCardFact, self).__init__(**kwargs) + self.name = name + self.value = value + + +class O365ConnectorCardHttpPOST(O365ConnectorCardActionBase): + """O365 connector card HttpPOST action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param body: Content to be posted back to bots via invoke + :type body: str + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "body": {"key": "body", "type": "str"}, + } + + def __init__( + self, *, type=None, name: str = None, id: str = None, body: str = None, **kwargs + ) -> None: + super(O365ConnectorCardHttpPOST, self).__init__( + type=type, name=name, id=id, **kwargs + ) + self.body = body + + +class O365ConnectorCardImage(Model): + """O365 connector card image. + + :param image: URL for the image + :type image: str + :param title: Alternative text for the image + :type title: str + """ + + _attribute_map = { + "image": {"key": "image", "type": "str"}, + "title": {"key": "title", "type": "str"}, + } + + def __init__(self, *, image: str = None, title: str = None, **kwargs) -> None: + super(O365ConnectorCardImage, self).__init__(**kwargs) + self.image = image + self.title = title + + +class O365ConnectorCardMultichoiceInput(O365ConnectorCardInputBase): + """O365 connector card multiple choice input. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + :param choices: Set of choices whose each item can be in any subtype of + O365ConnectorCardMultichoiceInputChoice. + :type choices: + list[~botframework.connector.teams.models.O365ConnectorCardMultichoiceInputChoice] + :param style: Choice item rendering style. Default value is 'compact'. + Possible values include: 'compact', 'expanded' + :type style: str or ~botframework.connector.teams.models.enum + :param is_multi_select: Define if this input field allows multiple + selections. Default value is false. + :type is_multi_select: bool + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + "choices": { + "key": "choices", + "type": "[O365ConnectorCardMultichoiceInputChoice]", + }, + "style": {"key": "style", "type": "str"}, + "is_multi_select": {"key": "isMultiSelect", "type": "bool"}, + } + + def __init__( + self, + *, + type=None, + id: str = None, + is_required: bool = None, + title: str = None, + value: str = None, + choices=None, + style=None, + is_multi_select: bool = None, + **kwargs + ) -> None: + super(O365ConnectorCardMultichoiceInput, self).__init__( + type=type, + id=id, + is_required=is_required, + title=title, + value=value, + **kwargs + ) + self.choices = choices + self.style = style + self.is_multi_select = is_multi_select + + +class O365ConnectorCardMultichoiceInputChoice(Model): + """O365O365 connector card multiple choice input item. + + :param display: The text rendered on ActionCard. + :type display: str + :param value: The value received as results. + :type value: str + """ + + _attribute_map = { + "display": {"key": "display", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, *, display: str = None, value: str = None, **kwargs) -> None: + super(O365ConnectorCardMultichoiceInputChoice, self).__init__(**kwargs) + self.display = display + self.value = value + + +class O365ConnectorCardOpenUri(O365ConnectorCardActionBase): + """O365 connector card OpenUri action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param targets: Target os / urls + :type targets: + list[~botframework.connector.teams.models.O365ConnectorCardOpenUriTarget] + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "targets": {"key": "targets", "type": "[O365ConnectorCardOpenUriTarget]"}, + } + + def __init__( + self, *, type=None, name: str = None, id: str = None, targets=None, **kwargs + ) -> None: + super(O365ConnectorCardOpenUri, self).__init__( + type=type, name=name, id=id, **kwargs + ) + self.targets = targets + + +class O365ConnectorCardOpenUriTarget(Model): + """O365 connector card OpenUri target. + + :param os: Target operating system. Possible values include: 'default', + 'iOS', 'android', 'windows' + :type os: str or ~botframework.connector.teams.models.enum + :param uri: Target url + :type uri: str + """ + + _attribute_map = { + "os": {"key": "os", "type": "str"}, + "uri": {"key": "uri", "type": "str"}, + } + + def __init__(self, *, os=None, uri: str = None, **kwargs) -> None: + super(O365ConnectorCardOpenUriTarget, self).__init__(**kwargs) + self.os = os + self.uri = uri + + +class O365ConnectorCardSection(Model): + """O365 connector card section. + + :param title: Title of the section + :type title: str + :param text: Text for the section + :type text: str + :param activity_title: Activity title + :type activity_title: str + :param activity_subtitle: Activity subtitle + :type activity_subtitle: str + :param activity_text: Activity text + :type activity_text: str + :param activity_image: Activity image + :type activity_image: str + :param activity_image_type: Describes how Activity image is rendered. + Possible values include: 'avatar', 'article' + :type activity_image_type: str or + ~botframework.connector.teams.models.enum + :param markdown: Use markdown for all text contents. Default value is + true. + :type markdown: bool + :param facts: Set of facts for the current section + :type facts: + list[~botframework.connector.teams.models.O365ConnectorCardFact] + :param images: Set of images for the current section + :type images: + list[~botframework.connector.teams.models.O365ConnectorCardImage] + :param potential_action: Set of actions for the current section + :type potential_action: + list[~botframework.connector.teams.models.O365ConnectorCardActionBase] + """ + + _attribute_map = { + "title": {"key": "title", "type": "str"}, + "text": {"key": "text", "type": "str"}, + "activity_title": {"key": "activityTitle", "type": "str"}, + "activity_subtitle": {"key": "activitySubtitle", "type": "str"}, + "activity_text": {"key": "activityText", "type": "str"}, + "activity_image": {"key": "activityImage", "type": "str"}, + "activity_image_type": {"key": "activityImageType", "type": "str"}, + "markdown": {"key": "markdown", "type": "bool"}, + "facts": {"key": "facts", "type": "[O365ConnectorCardFact]"}, + "images": {"key": "images", "type": "[O365ConnectorCardImage]"}, + "potential_action": { + "key": "potentialAction", + "type": "[O365ConnectorCardActionBase]", + }, + } + + def __init__( + self, + *, + title: str = None, + text: str = None, + activity_title: str = None, + activity_subtitle: str = None, + activity_text: str = None, + activity_image: str = None, + activity_image_type=None, + markdown: bool = None, + facts=None, + images=None, + potential_action=None, + **kwargs + ) -> None: + super(O365ConnectorCardSection, self).__init__(**kwargs) + self.title = title + self.text = text + self.activity_title = activity_title + self.activity_subtitle = activity_subtitle + self.activity_text = activity_text + self.activity_image = activity_image + self.activity_image_type = activity_image_type + self.markdown = markdown + self.facts = facts + self.images = images + self.potential_action = potential_action + + +class O365ConnectorCardTextInput(O365ConnectorCardInputBase): + """O365 connector card text input. + + :param type: Input type name. Possible values include: 'textInput', + 'dateInput', 'multichoiceInput' + :type type: str or ~botframework.connector.teams.models.enum + :param id: Input Id. It must be unique per entire O365 connector card. + :type id: str + :param is_required: Define if this input is a required field. Default + value is false. + :type is_required: bool + :param title: Input title that will be shown as the placeholder + :type title: str + :param value: Default value for this input field + :type value: str + :param is_multiline: Define if text input is allowed for multiple lines. + Default value is false. + :type is_multiline: bool + :param max_length: Maximum length of text input. Default value is + unlimited. + :type max_length: float + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "id": {"key": "id", "type": "str"}, + "is_required": {"key": "isRequired", "type": "bool"}, + "title": {"key": "title", "type": "str"}, + "value": {"key": "value", "type": "str"}, + "is_multiline": {"key": "isMultiline", "type": "bool"}, + "max_length": {"key": "maxLength", "type": "float"}, + } + + def __init__( + self, + *, + type=None, + id: str = None, + is_required: bool = None, + title: str = None, + value: str = None, + is_multiline: bool = None, + max_length: float = None, + **kwargs + ) -> None: + super(O365ConnectorCardTextInput, self).__init__( + type=type, + id=id, + is_required=is_required, + title=title, + value=value, + **kwargs + ) + self.is_multiline = is_multiline + self.max_length = max_length + + +class O365ConnectorCardViewAction(O365ConnectorCardActionBase): + """O365 connector card ViewAction action. + + :param type: Type of the action. Possible values include: 'ViewAction', + 'OpenUri', 'HttpPOST', 'ActionCard' + :type type: str or ~botframework.connector.teams.models.enum + :param name: Name of the action that will be used as button title + :type name: str + :param id: Action Id + :type id: str + :param target: Target urls, only the first url effective for card button + :type target: list[str] + """ + + _attribute_map = { + "type": {"key": "@type", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "id": {"key": "@id", "type": "str"}, + "target": {"key": "target", "type": "[str]"}, + } + + def __init__( + self, *, type=None, name: str = None, id: str = None, target=None, **kwargs + ) -> None: + super(O365ConnectorCardViewAction, self).__init__( + type=type, name=name, id=id, **kwargs + ) + self.target = target + + +class SigninStateVerificationQuery(Model): + """Signin state (part of signin action auth flow) verification invoke query. + + :param state: The state string originally received when the signin web + flow is finished with a state posted back to client via tab SDK + microsoftTeams.authentication.notifySuccess(state) + :type state: str + """ + + _attribute_map = { + "state": {"key": "state", "type": "str"}, + } + + def __init__(self, *, state: str = None, **kwargs) -> None: + super(SigninStateVerificationQuery, self).__init__(**kwargs) + self.state = state + + +class TaskModuleResponseBase(Model): + """Base class for Task Module responses. + + :param type: Choice of action options when responding to the task/submit + message. Possible values include: 'message', 'continue' + :type type: str or ~botframework.connector.teams.models.enum + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + } + + def __init__(self, *, type=None, **kwargs) -> None: + super(TaskModuleResponseBase, self).__init__(**kwargs) + self.type = type + + +class TaskModuleContinueResponse(TaskModuleResponseBase): + """Task Module Response with continue action. + + :param type: Choice of action options when responding to the task/submit + message. Possible values include: 'message', 'continue' + :type type: str or ~botframework.connector.teams.models.enum + :param value: The JSON for the Adaptive card to appear in the task module. + :type value: ~botframework.connector.teams.models.TaskModuleTaskInfo + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + "value": {"key": "value", "type": "TaskModuleTaskInfo"}, + } + + def __init__(self, *, type=None, value=None, **kwargs) -> None: + super(TaskModuleContinueResponse, self).__init__(type=type, **kwargs) + self.value = value + + +class TaskModuleMessageResponse(TaskModuleResponseBase): + """Task Module response with message action. + + :param type: Choice of action options when responding to the task/submit + message. Possible values include: 'message', 'continue' + :type type: str or ~botframework.connector.teams.models.enum + :param value: Teams will display the value of value in a popup message + box. + :type value: str + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + "value": {"key": "value", "type": "str"}, + } + + def __init__(self, *, type=None, value: str = None, **kwargs) -> None: + super(TaskModuleMessageResponse, self).__init__(type=type, **kwargs) + self.value = value + + +class TaskModuleRequestContext(Model): + """Current user context, i.e., the current theme. + + :param theme: + :type theme: str + """ + + _attribute_map = { + "theme": {"key": "theme", "type": "str"}, + } + + def __init__(self, *, theme: str = None, **kwargs) -> None: + super(TaskModuleRequestContext, self).__init__(**kwargs) + self.theme = theme + + +class TaskModuleResponse(Model): + """Envelope for Task Module Response. + + :param task: The JSON for the Adaptive card to appear in the task module. + :type task: ~botframework.connector.teams.models.TaskModuleResponseBase + """ + + _attribute_map = { + "task": {"key": "task", "type": "TaskModuleResponseBase"}, + } + + def __init__(self, *, task=None, **kwargs) -> None: + super(TaskModuleResponse, self).__init__(**kwargs) + self.task = task + + +class TaskModuleTaskInfo(Model): + """Metadata for a Task Module. + + :param title: Appears below the app name and to the right of the app icon. + :type title: str + :param height: This can be a number, representing the task module's height + in pixels, or a string, one of: small, medium, large. + :type height: object + :param width: This can be a number, representing the task module's width + in pixels, or a string, one of: small, medium, large. + :type width: object + :param url: The URL of what is loaded as an iframe inside the task module. + One of url or card is required. + :type url: str + :param card: The JSON for the Adaptive card to appear in the task module. + :type card: ~botframework.connector.teams.models.Attachment + :param fallback_url: If a client does not support the task module feature, + this URL is opened in a browser tab. + :type fallback_url: str + :param completion_bot_id: If a client does not support the task module + feature, this URL is opened in a browser tab. + :type completion_bot_id: str + """ + + _attribute_map = { + "title": {"key": "title", "type": "str"}, + "height": {"key": "height", "type": "object"}, + "width": {"key": "width", "type": "object"}, + "url": {"key": "url", "type": "str"}, + "card": {"key": "card", "type": "Attachment"}, + "fallback_url": {"key": "fallbackUrl", "type": "str"}, + "completion_bot_id": {"key": "completionBotId", "type": "str"}, + } + + def __init__( + self, + *, + title: str = None, + height=None, + width=None, + url: str = None, + card=None, + fallback_url: str = None, + completion_bot_id: str = None, + **kwargs + ) -> None: + super(TaskModuleTaskInfo, self).__init__(**kwargs) + self.title = title + self.height = height + self.width = width + self.url = url + self.card = card + self.fallback_url = fallback_url + self.completion_bot_id = completion_bot_id + + +class TeamDetails(Model): + """Details related to a team. + + :param id: Unique identifier representing a team + :type id: str + :param name: Name of team. + :type name: str + :param aad_group_id: Azure Active Directory (AAD) Group Id for the team. + :type aad_group_id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "aad_group_id": {"key": "aadGroupId", "type": "str"}, + } + + def __init__( + self, *, id: str = None, name: str = None, aad_group_id: str = None, **kwargs + ) -> None: + super(TeamDetails, self).__init__(**kwargs) + self.id = id + self.name = name + self.aad_group_id = aad_group_id + + +class TeamInfo(Model): + """Describes a team. + + :param id: Unique identifier representing a team + :type id: str + :param name: Name of team. + :type name: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + } + + def __init__(self, *, id: str = None, name: str = None, **kwargs) -> None: + super(TeamInfo, self).__init__(**kwargs) + self.id = id + self.name = name + + +class TeamsChannelAccount(ChannelAccount): + """Teams channel account detailing user Azure Active Directory details. + + :param id: Channel id for the user or bot on this channel (Example: + joe@smith.com, or @joesmith or 123456) + :type id: str + :param name: Display friendly name + :type name: str + :param given_name: Given name part of the user name. + :type given_name: str + :param surname: Surname part of the user name. + :type surname: str + :param email: Email Id of the user. + :type email: str + :param user_principal_name: Unique user principal name + :type user_principal_name: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "given_name": {"key": "givenName", "type": "str"}, + "surname": {"key": "surname", "type": "str"}, + "email": {"key": "email", "type": "str"}, + "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + } + + def __init__( + self, + *, + id: str = None, + name: str = None, + given_name: str = None, + surname: str = None, + email: str = None, + user_principal_name: str = None, + **kwargs + ) -> None: + super(TeamsChannelAccount, self).__init__(id=id, name=name, **kwargs) + self.given_name = given_name + self.surname = surname + self.email = email + self.user_principal_name = user_principal_name + + +class TeamsChannelData(Model): + """Channel data specific to messages received in Microsoft Teams. + + :param channel: Information about the channel in which the message was + sent + :type channel: ~botframework.connector.teams.models.ChannelInfo + :param event_type: Type of event. + :type event_type: str + :param team: Information about the team in which the message was sent + :type team: ~botframework.connector.teams.models.TeamInfo + :param notification: Notification settings for the message + :type notification: ~botframework.connector.teams.models.NotificationInfo + :param tenant: Information about the tenant in which the message was sent + :type tenant: ~botframework.connector.teams.models.TenantInfo + """ + + _attribute_map = { + "channel": {"key": "channel", "type": "ChannelInfo"}, + "event_type": {"key": "eventType", "type": "str"}, + "team": {"key": "team", "type": "TeamInfo"}, + "notification": {"key": "notification", "type": "NotificationInfo"}, + "tenant": {"key": "tenant", "type": "TenantInfo"}, + } + + def __init__( + self, + *, + channel=None, + event_type: str = None, + team=None, + notification=None, + tenant=None, + **kwargs + ) -> None: + super(TeamsChannelData, self).__init__(**kwargs) + self.channel = channel + self.event_type = event_type + self.team = team + self.notification = notification + self.tenant = tenant + + +class TenantInfo(Model): + """Describes a tenant. + + :param id: Unique identifier representing a tenant + :type id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + } + + def __init__(self, *, id: str = None, **kwargs) -> None: + super(TenantInfo, self).__init__(**kwargs) + self.id = id diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py deleted file mode 100644 index 6125698c3..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. - - -class ChannelInfo(object): - def __init__(self, id="", name=""): - self.id = id - self.name = name diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py deleted file mode 100644 index dd55a69c7..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. - - -class NotificationInfo: - def __init__(self, alert: bool = False): - self.alert = alert diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py deleted file mode 100644 index 316ae89c2..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. - - -class TeamInfo: - def __init__(self, id="", name="", aadGroupId=""): - self.id = id - self.name = name - self.aad_group_id = aadGroupId diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py deleted file mode 100644 index a2354effd..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. - -from botbuilder.schema import ChannelAccount - - -class TeamsChannelAccount(ChannelAccount): - def __init__( - self, - id="", - name="", - aad_object_id="", - role="", - given_name="", - surname="", - email="", - userPrincipalName="", - ): - super().__init__( - **{"id": id, "name": name, "aad_object_id": aad_object_id, "role": role} - ) - self.given_name = given_name - self.surname = surname - self.email = email - # This isn't camel_cased because the JSON that makes this object isn't camel_case - self.user_principal_name = userPrincipalName diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py deleted file mode 100644 index 24001d00c..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. - -from botbuilder.schema.teams import ChannelInfo, TeamInfo, NotificationInfo, TenantInfo - - -class TeamsChannelData: - def __init__( - self, - channel: ChannelInfo = None, - eventType="", - team: TeamInfo = None, - notification: NotificationInfo = None, - tenant: TenantInfo = None, - ): - self.channel = ChannelInfo(**channel) if channel is not None else ChannelInfo() - # This is not camel case because the JSON that makes this object isn't - self.event_type = eventType - self.team = TeamInfo(**team) if team is not None else TeamInfo() - self.notification = ( - NotificationInfo(**notification) - if notification is not None - else NotificationInfo() - ) - self.tenant = TenantInfo(**tenant) if tenant is not None else TenantInfo() diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py deleted file mode 100644 index 2b47e81a0..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. - - -class TenantInfo: - def __init__(self, id=""): - self._id = id diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index a32625050..71776bc4f 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -2,8 +2,6 @@ from asyncio import Future from unittest.mock import Mock, DEFAULT import aiounittest -from ddt import data, ddt, unpack - from botframework.connector.auth import ( AuthenticationConstants, ClaimsIdentity, @@ -11,6 +9,7 @@ SkillValidation, ) +from ddt import data, ddt, unpack def future_builder(return_val: object) -> Future: result = Future() From bd62771881e30ef308c057086caec8a8636250fe Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 16:15:06 -0800 Subject: [PATCH 057/616] moving the import back --- .../botframework-connector/tests/test_skill_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index 71776bc4f..204d29765 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -2,6 +2,8 @@ from asyncio import Future from unittest.mock import Mock, DEFAULT import aiounittest +from ddt import data, ddt, unpack + from botframework.connector.auth import ( AuthenticationConstants, ClaimsIdentity, @@ -9,8 +11,6 @@ SkillValidation, ) -from ddt import data, ddt, unpack - def future_builder(return_val: object) -> Future: result = Future() result.set_result(return_val) From 9e47e4c1c5cc241b07f874589831338790deff8d Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 16:19:19 -0800 Subject: [PATCH 058/616] trying different import order --- .../botframework-connector/tests/test_skill_validation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index 204d29765..19ced8ae8 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -2,7 +2,6 @@ from asyncio import Future from unittest.mock import Mock, DEFAULT import aiounittest -from ddt import data, ddt, unpack from botframework.connector.auth import ( AuthenticationConstants, @@ -11,6 +10,8 @@ SkillValidation, ) +from ddt import data, ddt, unpack + def future_builder(return_val: object) -> Future: result = Future() result.set_result(return_val) From f9694d93acc683c234f5501680c0ae6cb6b74f0d Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 16:21:49 -0800 Subject: [PATCH 059/616] hard resetting test file --- .../botframework-connector/tests/test_skill_validation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index 19ced8ae8..204d29765 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -2,6 +2,7 @@ from asyncio import Future from unittest.mock import Mock, DEFAULT import aiounittest +from ddt import data, ddt, unpack from botframework.connector.auth import ( AuthenticationConstants, @@ -10,8 +11,6 @@ SkillValidation, ) -from ddt import data, ddt, unpack - def future_builder(return_val: object) -> Future: result = Future() result.set_result(return_val) From 1337b930f6ae86c1c1d0b386fd62e3a30e09c058 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 16:22:51 -0800 Subject: [PATCH 060/616] pulling file from master --- libraries/botframework-connector/tests/test_skill_validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index 204d29765..a32625050 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -11,6 +11,7 @@ SkillValidation, ) + def future_builder(return_val: object) -> Future: result = Future() result.set_result(return_val) From 55aa761f03d739f7a891efc4222754875e0ad4b5 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 16:37:06 -0800 Subject: [PATCH 061/616] adding connector --- .../botframework/connector/teams/__init__.py | 17 ++ .../connector/teams/operations/__init__.py | 16 ++ .../teams/operations/teams_operations.py | 147 ++++++++++++++++++ .../connector/teams/teams_connector_client.py | 82 ++++++++++ .../botframework/connector/teams/version.py | 12 ++ 5 files changed, 274 insertions(+) create mode 100644 libraries/botframework-connector/botframework/connector/teams/__init__.py create mode 100644 libraries/botframework-connector/botframework/connector/teams/operations/__init__.py create mode 100644 libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py create mode 100644 libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py create mode 100644 libraries/botframework-connector/botframework/connector/teams/version.py diff --git a/libraries/botframework-connector/botframework/connector/teams/__init__.py b/libraries/botframework-connector/botframework/connector/teams/__init__.py new file mode 100644 index 000000000..df0cf0a57 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/teams/__init__.py @@ -0,0 +1,17 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +from .teams_connector_client import TeamsConnectorClient +from .version import VERSION + +__all__ = ["TeamsConnectorClient"] + +__version__ = VERSION diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py b/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py new file mode 100644 index 000000000..3e46b2dc2 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py @@ -0,0 +1,16 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +from .teams_operations import TeamsOperations + +__all__ = [ + "TeamsOperations", +] diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py new file mode 100644 index 000000000..73d95e246 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -0,0 +1,147 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +from msrest.pipeline import ClientRawResponse +from msrest.exceptions import HttpOperationError + +from .. import models + + +class TeamsOperations(object): + """TeamsOperations operations. + + :param client: Client for service requests. + :param config: Configuration of service client. + :param serializer: An object model serializer. + :param deserializer: An object model deserializer. + """ + + models = models + + def __init__(self, client, config, serializer, deserializer): + + self._client = client + self._serialize = serializer + self._deserialize = deserializer + + self.config = config + + def fetch_channel_list( + self, team_id, custom_headers=None, raw=False, **operation_config + ): + """Fetches channel list for a given team. + + Fetch the channel list. + + :param team_id: Team Id + :type team_id: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: ConversationList or ClientRawResponse if raw=true + :rtype: ~botframework.connector.teams.models.ConversationList or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + # Construct URL + url = self.fetch_channel_list.metadata["url"] + path_format_arguments = { + "teamId": self._serialize.url("team_id", team_id, "str") + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + + if response.status_code == 200: + deserialized = self._deserialize("ConversationList", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + fetch_channel_list.metadata = {"url": "/v3/teams/{teamId}/conversations"} + + def fetch_team_details( + self, team_id, custom_headers=None, raw=False, **operation_config + ): + """Fetches details related to a team. + + Fetch details for a team. + + :param team_id: Team Id + :type team_id: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: TeamDetails or ClientRawResponse if raw=true + :rtype: ~botframework.connector.teams.models.TeamDetails or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + # Construct URL + url = self.fetch_team_details.metadata["url"] + path_format_arguments = { + "teamId": self._serialize.url("team_id", team_id, "str") + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + + if response.status_code == 200: + deserialized = self._deserialize("TeamDetails", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + fetch_team_details.metadata = {"url": "/v3/teams/{teamId}"} diff --git a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py new file mode 100644 index 000000000..61e7c979e --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py @@ -0,0 +1,82 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +from msrest.service_client import SDKClient +from msrest import Configuration, Serializer, Deserializer +from .version import VERSION +from msrest.exceptions import HttpOperationError +from .operations.teams_operations import TeamsOperations +from . import models + + +class TeamsConnectorClientConfiguration(Configuration): + """Configuration for TeamsConnectorClient + Note that all parameters used to create this instance are saved as instance + attributes. + + :param credentials: Subscription credentials which uniquely identify + client subscription. + :type credentials: None + :param str base_url: Service URL + """ + + def __init__(self, credentials, base_url=None): + + if credentials is None: + raise ValueError("Parameter 'credentials' must not be None.") + if not base_url: + base_url = "https://api.botframework.com" + + super(TeamsConnectorClientConfiguration, self).__init__(base_url) + + self.add_user_agent("botframework-connector/{}".format(VERSION)) + + self.credentials = credentials + + +class TeamsConnectorClient(SDKClient): + """The Bot Connector REST API extension for Microsoft Teams allows your bot to perform extended operations on to Microsoft Teams channel configured in the + [Bot Framework Developer Portal](https://dev.botframework.com). The Connector service uses industry-standard REST and JSON over HTTPS. + Client libraries for this REST API are available. See below for a list. + Authentication for both the Bot Connector and Bot State REST APIs is accomplished with JWT Bearer tokens, and is + described in detail in the [Connector Authentication](https://docs.botframework.com/en-us/restapi/authentication) document. + # Client Libraries for the Bot Connector REST API + * [Bot Builder for C#](https://docs.botframework.com/en-us/csharp/builder/sdkreference/) + * [Bot Builder for Node.js](https://docs.botframework.com/en-us/node/builder/overview/) + © 2016 Microsoft + + :ivar config: Configuration for client. + :vartype config: TeamsConnectorClientConfiguration + + :ivar teams: Teams operations + :vartype teams: botframework.connector.teams.operations.TeamsOperations + + :param credentials: Subscription credentials which uniquely identify + client subscription. + :type credentials: None + :param str base_url: Service URL + """ + + def __init__(self, credentials, base_url=None): + + self.config = TeamsConnectorClientConfiguration(credentials, base_url) + super(TeamsConnectorClient, self).__init__(self.config.credentials, self.config) + + client_models = { + k: v for k, v in models.__dict__.items() if isinstance(v, type) + } + self.api_version = "v3" + self._serialize = Serializer(client_models) + self._deserialize = Deserializer(client_models) + + self.teams = TeamsOperations( + self._client, self.config, self._serialize, self._deserialize + ) diff --git a/libraries/botframework-connector/botframework/connector/teams/version.py b/libraries/botframework-connector/botframework/connector/teams/version.py new file mode 100644 index 000000000..e36069e74 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/teams/version.py @@ -0,0 +1,12 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- + +VERSION = "v3" From 74bcc5ef086527fcfdb999f0c42ffd1542e9db10 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 27 Nov 2019 17:14:38 -0800 Subject: [PATCH 062/616] fixing linting --- .../connector/teams/teams_connector_client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py index 61e7c979e..9f75295b3 100644 --- a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py +++ b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py @@ -11,10 +11,9 @@ from msrest.service_client import SDKClient from msrest import Configuration, Serializer, Deserializer +from botbuilder.schema import models from .version import VERSION -from msrest.exceptions import HttpOperationError from .operations.teams_operations import TeamsOperations -from . import models class TeamsConnectorClientConfiguration(Configuration): @@ -43,11 +42,13 @@ def __init__(self, credentials, base_url=None): class TeamsConnectorClient(SDKClient): - """The Bot Connector REST API extension for Microsoft Teams allows your bot to perform extended operations on to Microsoft Teams channel configured in the - [Bot Framework Developer Portal](https://dev.botframework.com). The Connector service uses industry-standard REST and JSON over HTTPS. - Client libraries for this REST API are available. See below for a list. + """The Bot Connector REST API extension for Microsoft Teams allows your bot to perform extended + operations on to Microsoft Teams channel configured in the + [Bot Framework Developer Portal](https://dev.botframework.com). The Connector service uses + industry-standard REST and JSON over HTTPS. Client libraries for this REST API are available. See below for a list. Authentication for both the Bot Connector and Bot State REST APIs is accomplished with JWT Bearer tokens, and is - described in detail in the [Connector Authentication](https://docs.botframework.com/en-us/restapi/authentication) document. + described in detail in the [Connector Authentication](https://docs.botframework.com/en-us/restapi/authentication) + document. # Client Libraries for the Bot Connector REST API * [Bot Builder for C#](https://docs.botframework.com/en-us/csharp/builder/sdkreference/) * [Bot Builder for Node.js](https://docs.botframework.com/en-us/node/builder/overview/) From 059913cd7fa8c75869bd1d5c39ddfb53541a0d3b Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Dec 2019 11:37:06 -0600 Subject: [PATCH 063/616] Corrected serialize misspelling --- .../botbuilder/core/teams/teams_activity_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 04d7389aa..29f17d100 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -338,7 +338,7 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) """ for member in members_added: - new_account_json = member.seralize() + new_account_json = member.serialize() del new_account_json["additional_properties"] member = TeamsChannelAccount(**new_account_json) return await self.on_teams_members_added_activity(members_added, turn_context) @@ -357,7 +357,7 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- ): teams_members_removed = [] for member in members_removed: - new_account_json = member.seralize() + new_account_json = member.serialize() del new_account_json["additional_properties"] teams_members_removed.append(TeamsChannelAccount(**new_account_json)) From bb25f6f4143ce96347f5fb3b4498dc396cc216af Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Dec 2019 14:29:40 -0600 Subject: [PATCH 064/616] Corrected serialize misspelling (#463) --- .../botbuilder/core/teams/teams_activity_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 04d7389aa..29f17d100 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -338,7 +338,7 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) """ for member in members_added: - new_account_json = member.seralize() + new_account_json = member.serialize() del new_account_json["additional_properties"] member = TeamsChannelAccount(**new_account_json) return await self.on_teams_members_added_activity(members_added, turn_context) @@ -357,7 +357,7 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- ): teams_members_removed = [] for member in members_removed: - new_account_json = member.seralize() + new_account_json = member.serialize() del new_account_json["additional_properties"] teams_members_removed.append(TeamsChannelAccount(**new_account_json)) From 35ab4bfc7f8110b35d8f9df31fa6ad98f937e358 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 3 Dec 2019 08:07:23 -0600 Subject: [PATCH 065/616] Added TeamsFileBot scenario --- .../botbuilder/core/bot_framework_adapter.py | 2 +- .../core/teams/teams_activity_handler.py | 10 +- .../schema/teams/additional_properties.py | 18 ++ scenarios/file-upload/README.md | 119 +++++++++++ scenarios/file-upload/app.py | 91 +++++++++ scenarios/file-upload/bots/__init__.py | 6 + scenarios/file-upload/bots/teams_file_bot.py | 185 ++++++++++++++++++ scenarios/file-upload/config.py | 13 ++ scenarios/file-upload/files/teams-logo.png | Bin 0 -> 6412 bytes scenarios/file-upload/requirements.txt | 3 + .../file-upload/teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 38 ++++ .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes 13 files changed, 479 insertions(+), 6 deletions(-) create mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py create mode 100644 scenarios/file-upload/README.md create mode 100644 scenarios/file-upload/app.py create mode 100644 scenarios/file-upload/bots/__init__.py create mode 100644 scenarios/file-upload/bots/teams_file_bot.py create mode 100644 scenarios/file-upload/config.py create mode 100644 scenarios/file-upload/files/teams-logo.png create mode 100644 scenarios/file-upload/requirements.txt create mode 100644 scenarios/file-upload/teams_app_manifest/color.png create mode 100644 scenarios/file-upload/teams_app_manifest/manifest.json create mode 100644 scenarios/file-upload/teams_app_manifest/outline.png diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a7727956d..df392f338 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -378,7 +378,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-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 29f17d100..07bca2c0f 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -10,7 +10,7 @@ ChannelInfo, TeamsChannelData, TeamsChannelAccount, -) + FileConsentCardResponse) from botframework.connector import Channels @@ -55,7 +55,7 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "fileConsent/invoke": return await self.on_teams_file_consent_activity( - turn_context, turn_context.activity.value + turn_context, FileConsentCardResponse.deserialize(turn_context.activity.value) ) if turn_context.activity.name == "actionableMessage/executeAction": @@ -143,7 +143,7 @@ async def on_teams_signin_verify_state_activity(self, turn_context: TurnContext) raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent_activity( - self, turn_context: TurnContext, file_consent_card_response + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse ): if file_consent_card_response.action == "accept": await self.on_teams_file_consent_accept_activity( @@ -163,12 +163,12 @@ async def on_teams_file_consent_activity( ) async def on_teams_file_consent_accept_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) 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..83062d01c --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py @@ -0,0 +1,18 @@ +# 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..c6192bd2b --- /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_activity + # will be called, otherwise on_teams_file_consent_decline_activity. + 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_activity( + 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_activity( + 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 0000000000000000000000000000000000000000..78b0a0c308939206aee5f15e2c052def7a18a74e GIT binary patch literal 6412 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 F{{vnI6G{L8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.microsoft.teams.samples.fileUpload", + "developer": { + "name": "Microsoft Corp", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "name": { + "short": "V4 File Sample", + "full": "Microsoft Teams V4 File Sample Bot" + }, + "description": { + "short": "Sample bot using V4 SDK to demo bot file features", + "full": "Sample bot using V4 Bot Builder SDK and V4 Microsoft Teams Extension SDK to demo bot file features" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#abcdef", + "bots": [ + { + "botId": "<>", + "scopes": [ + "personal" + ], + "supportsFiles": true + } + ], + "validDomains": [ + "*.azurewebsites.net" + ] +} \ No newline at end of file diff --git a/scenarios/file-upload/teams_app_manifest/outline.png b/scenarios/file-upload/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Tue, 3 Dec 2019 11:38:50 -0800 Subject: [PATCH 066/616] saving unit tests so far --- .../core/teams/teams_activity_handler.py | 43 ++-- .../teams/test_teams_activity_handler.py | 217 ++++++++++++++---- .../botbuilder/schema/_models.py | 2 +- .../botbuilder/schema/_models_py3.py | 6 +- .../botbuilder/schema/teams/_models.py | 7 +- .../botbuilder/schema/teams/_models_py3.py | 14 +- 6 files changed, 217 insertions(+), 72 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 04d7389aa..218f6c714 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -12,7 +12,8 @@ TeamsChannelAccount, ) from botframework.connector import Channels - +import json +from typing import List class TeamsActivityHandler(ActivityHandler): async def on_turn(self, turn_context: TurnContext): @@ -261,9 +262,9 @@ async def on_teams_task_module_submit_activity( # pylint: disable=unused-argume raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_conversation_update_activity(self, turn_context: TurnContext): + if turn_context.activity.channel_id == Channels.ms_teams: channel_data = TeamsChannelData(**turn_context.activity.channel_data) - if turn_context.activity.members_added: return await self.on_teams_members_added_dispatch_activity( turn_context.activity.members_added, channel_data.team, turn_context @@ -277,22 +278,20 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): ) if channel_data: - if channel_data.event_type == "channelCreated": + if channel_data.eventType == "channelCreated": return await self.on_teams_channel_created_activity( - channel_data.channel, channel_data.team, turn_context + ChannelInfo(**channel_data.channel), channel_data.team, turn_context ) - if channel_data.event_type == "channelDeleted": + if channel_data.eventType == "channelDeleted": return await self.on_teams_channel_deleted_activity( channel_data.channel, channel_data.team, turn_context ) - if channel_data.event_type == "channelRenamed": + if channel_data.eventType == "channelRenamed": return await self.on_teams_channel_renamed_activity( channel_data.channel, channel_data.team, turn_context ) - if channel_data.event_type == "teamRenamed": - return await self.on_teams_team_renamed_activity( - channel_data.team, turn_context - ) + if channel_data.eventType == "teamRenamed": + return await self.on_teams_team_renamed_activity(channel_data.team, turn_context) return await super().on_conversation_update_activity(turn_context) return await super().on_conversation_update_activity(turn_context) @@ -300,7 +299,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_teams_channel_created_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - return + return async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext @@ -337,17 +336,20 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) """ + team_accounts_added = [] for member in members_added: - new_account_json = member.seralize() - del new_account_json["additional_properties"] + new_account_json = member.serialize() + if "additional_properties" in new_account_json: + del new_account_json["additional_properties"] member = TeamsChannelAccount(**new_account_json) - return await self.on_teams_members_added_activity(members_added, turn_context) + team_accounts_added.append(member) + return await self.on_teams_members_added_activity(team_accounts_added, turn_context) async def on_teams_members_added_activity( self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext ): - teams_members_added = [ChannelAccount(member) for member in teams_members_added] - return super().on_members_added_activity(teams_members_added, turn_context) + teams_members_added = [ ChannelAccount(**member.serialize()) for member in teams_members_added ] + return await super().on_members_added_activity(teams_members_added, turn_context) async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument self, @@ -357,8 +359,9 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- ): teams_members_removed = [] for member in members_removed: - new_account_json = member.seralize() - del new_account_json["additional_properties"] + new_account_json = member.serialize() + if "additional_properties" in new_account_json: + del new_account_json["additional_properties"] teams_members_removed.append(TeamsChannelAccount(**new_account_json)) return await self.on_teams_members_removed_activity( @@ -368,8 +371,8 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- async def on_teams_members_removed_activity( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): - members_removed = [ChannelAccount(member) for member in teams_members_removed] - return super().on_members_removed_activity(members_removed, turn_context) + members_removed = [ChannelAccount(**member.serialize()) for member in teams_members_removed] + return await super().on_members_removed_activity(members_removed, turn_context) async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 87b092e09..2b04c1f09 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -11,44 +11,36 @@ MessageReaction, ResourceResponse, ) - +from botbuilder.schema.teams import ( + ChannelInfo, + NotificationInfo, + TeamInfo, + TeamsChannelAccount, + TeamsChannelData, + TenantInfo, +) +from botframework.connector import Channels class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): self.record: List[str] = [] + async def on_conversation_update_activity(self, turn_context: TurnContext): + self.record.append("on_conversation_update_activity") + return await super().on_conversation_update_activity(turn_context) + + async def on_teams_members_added_activity(self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext): + self.record.append("on_teams_members_added_activity") + return await super().on_teams_members_added_activity(teams_members_added, turn_context) + + async def on_teams_members_removed_activity(self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext): + self.record.append("on_teams_members_removed_activity") + return await super().on_teams_members_removed_activity(teams_members_removed, turn_context) + async def on_message_activity(self, turn_context: TurnContext): self.record.append("on_message_activity") return await super().on_message_activity(turn_context) - async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_added_activity") - return await super().on_members_added_activity(members_added, turn_context) - - async def on_members_removed_activity( - self, members_removed: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_removed_activity") - return await super().on_members_removed_activity(members_removed, turn_context) - - async def on_message_reaction_activity(self, turn_context: TurnContext): - self.record.append("on_message_reaction_activity") - return await super().on_message_reaction_activity(turn_context) - - async def on_reactions_added( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_added") - return await super().on_reactions_added(message_reactions, turn_context) - - async def on_reactions_removed( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_removed") - return await super().on_reactions_removed(message_reactions, turn_context) - async def on_token_response_event(self, turn_context: TurnContext): self.record.append("on_token_response_event") return await super().on_token_response_event(turn_context) @@ -60,7 +52,32 @@ async def on_event(self, turn_context: TurnContext): async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) + + async def on_teams_channel_created_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_created_activity") + return await super().on_teams_channel_created_activity(channel_info, team_info, turn_context) + + async def on_teams_channel_renamed_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_renamed_activity") + return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) + + async def on_teams_channel_deleted_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_deleted_activity") + return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) + + async def on_teams_team_renamed_activity(self, team_info: TeamInfo, turn_context: TurnContext): + self.record.append("on_teams_team_renamed_activity") + return await super().on_teams_team_renamed_activity(team_info, turn_context) + async def on_invoke_activity(self, turn_context: TurnContext): + self.record.append("on_invoke_activity") + return await super().on_invoke_activity(turn_context) class NotImplementedAdapter(BotAdapter): async def delete_activity( @@ -76,18 +93,140 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): raise NotImplementedError() - class TestTeamsActivityHandler(aiounittest.AsyncTestCase): - async def test_message_reaction(self): - # Note the code supports multiple adds and removes in the same activity though - # a channel may decide to send separate activities for each. For example, Teams - # sends separate activities each with a single add and a single remove. + async def test_on_teams_channel_created_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelCreated", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_created_activity" + + async def test_on_teams_channel_renamed_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelRenamed", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) - # Arrange + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_renamed_activity" + + async def test_on_teams_channel_deleted_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelDeleted", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_deleted_activity" + + async def test_on_teams_team_renamed_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamRenamed", + "team": { + "id": "team_id_1", + "name" : "new_team_name" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_renamed_activity" + + async def test_on_teams_members_added_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamMemberAdded" + }, + members_added = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_added_activity" + + async def test_on_teams_members_removed_activity(self): + #arrange activity = Activity( - type=ActivityTypes.message_reaction, - reactions_added=[MessageReaction(type="sad")], + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamMemberRemoved" + }, + members_removed = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], + channel_id = Channels.ms_teams ) + turn_context = TurnContext(NotImplementedAdapter(), activity) # Act @@ -96,5 +235,5 @@ async def test_message_reaction(self): # Assert assert len(bot.record) == 2 - assert bot.record[0] == "on_message_reaction_activity" - assert bot.record[1] == "on_reactions_added" + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_removed_activity" \ No newline at end of file diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 736ddcf81..dc40b3fee 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -611,7 +611,7 @@ def __init__(self, **kwargs): super(ChannelAccount, self).__init__(**kwargs) self.id = kwargs.get("id", None) self.name = kwargs.get("name", None) - self.aad_object_id = kwargs.get("aad_object_id", None) + self.aadObjectId = kwargs.get("aadObjectId", None) self.role = kwargs.get("role", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 58caa1567..c6cd5fdba 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -721,7 +721,7 @@ class ChannelAccount(Model): _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, - "aad_object_id": {"key": "aadObjectId", "type": "str"}, + "aadObjectId": {"key": "aadObjectId", "type": "str"}, "role": {"key": "role", "type": "str"}, } @@ -730,14 +730,14 @@ def __init__( *, id: str = None, name: str = None, - aad_object_id: str = None, + aadObjectId: str = None, role=None, **kwargs ) -> None: super(ChannelAccount, self).__init__(**kwargs) self.id = id self.name = name - self.aad_object_id = aad_object_id + self.aadObjectId = aadObjectId self.role = role diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 5e41c6fd4..fe0e5d741 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1535,7 +1535,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, } def __init__(self, **kwargs): @@ -1543,7 +1543,7 @@ def __init__(self, **kwargs): self.given_name = kwargs.get("given_name", None) self.surname = kwargs.get("surname", None) self.email = kwargs.get("email", None) - self.user_principal_name = kwargs.get("user_principal_name", None) + self.userPrincipalName = kwargs.get("userPrincipalName", None) class TeamsChannelData(Model): @@ -1573,7 +1573,8 @@ class TeamsChannelData(Model): def __init__(self, **kwargs): super(TeamsChannelData, self).__init__(**kwargs) self.channel = kwargs.get("channel", None) - self.event_type = kwargs.get("event_type", None) + # doing camel case here since that's how the data comes in + self.eventType = kwargs.get("eventType", None) self.team = kwargs.get("team", None) self.notification = kwargs.get("notification", None) self.tenant = kwargs.get("tenant", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 80249f277..3cd336941 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1796,7 +1796,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, } def __init__( @@ -1807,14 +1807,15 @@ def __init__( given_name: str = None, surname: str = None, email: str = None, - user_principal_name: str = None, + userPrincipalName: str = None, **kwargs ) -> None: super(TeamsChannelAccount, self).__init__(id=id, name=name, **kwargs) self.given_name = given_name self.surname = surname self.email = email - self.user_principal_name = user_principal_name + # changing to camel case due to how data comes in off the wire + self.userPrincipalName = userPrincipalName class TeamsChannelData(Model): @@ -1835,7 +1836,7 @@ class TeamsChannelData(Model): _attribute_map = { "channel": {"key": "channel", "type": "ChannelInfo"}, - "event_type": {"key": "eventType", "type": "str"}, + "eventType": {"key": "eventType", "type": "str"}, "team": {"key": "team", "type": "TeamInfo"}, "notification": {"key": "notification", "type": "NotificationInfo"}, "tenant": {"key": "tenant", "type": "TenantInfo"}, @@ -1845,7 +1846,7 @@ def __init__( self, *, channel=None, - event_type: str = None, + eventType: str = None, team=None, notification=None, tenant=None, @@ -1853,7 +1854,8 @@ def __init__( ) -> None: super(TeamsChannelData, self).__init__(**kwargs) self.channel = channel - self.event_type = event_type + # doing camel case here since that's how the data comes in + self.eventType = eventType self.team = team self.notification = notification self.tenant = tenant From a902fb9baed7dab568a42e910f3748a2409345bf Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 3 Dec 2019 11:44:54 -0800 Subject: [PATCH 067/616] removing activity from method names --- .../core/teams/teams_activity_handler.py | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 29f17d100..7ff3cf4ec 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -50,84 +50,84 @@ async def on_invoke_activity(self, turn_context: TurnContext): return await self.on_teams_card_action_invoke_activity(turn_context) if turn_context.activity.name == "signin/verifyState": - await self.on_teams_signin_verify_state_activity(turn_context) + await self.on_teams_signin_verify_state(turn_context) return self._create_invoke_response() if turn_context.activity.name == "fileConsent/invoke": - return await self.on_teams_file_consent_activity( + return await self.on_teams_file_consent( turn_context, turn_context.activity.value ) if turn_context.activity.name == "actionableMessage/executeAction": - await self.on_teams_o365_connector_card_action_activity( + await self.on_teams_o365_connector_card_action( turn_context, turn_context.activity.value ) return self._create_invoke_response() if turn_context.activity.name == "composeExtension/queryLink": return self._create_invoke_response( - await self.on_teams_app_based_link_query_activity( + await self.on_teams_app_based_link_query( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "composeExtension/query": return self._create_invoke_response( - await self.on_teams_messaging_extension_query_activity( + await self.on_teams_messaging_extension_query( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "composeExtension/selectItem": return self._create_invoke_response( - await self.on_teams_messaging_extension_select_item_activity( + await self.on_teams_messaging_extension_select_item( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "composeExtension/submitAction": return self._create_invoke_response( - await self.on_teams_messaging_extension_submit_action_dispatch_activity( + await self.on_teams_messaging_extension_submit_action_dispatch( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "composeExtension/fetchTask": return self._create_invoke_response( - await self.on_teams_messaging_extension_fetch_task_activity( + await self.on_teams_messaging_extension_fetch_task( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "composeExtension/querySettingUrl": return self._create_invoke_response( - await self.on_teams_messaging_extension_configuration_query_settings_url_activity( + await self.on_teams_messaging_extension_configuration_query_settings_url( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "composeExtension/setting": - await self.on_teams_messaging_extension_configuration_setting_activity( + await self.on_teams_messaging_extension_configuration_setting( turn_context, turn_context.activity.value ) return self._create_invoke_response() if turn_context.activity.name == "composeExtension/onCardButtonClicked": - await self.on_teams_messaging_extension_card_button_clicked_activity( + await self.on_teams_messaging_extension_card_button_clicked( turn_context, turn_context.activity.value ) return self._create_invoke_response() if turn_context.activity.name == "task/fetch": return self._create_invoke_response( - await self.on_teams_task_module_fetch_activity( + await self.on_teams_task_module_fetch( turn_context, turn_context.activity.value ) ) if turn_context.activity.name == "task/submit": return self._create_invoke_response( - await self.on_teams_task_module_submit_activity( + await self.on_teams_task_module_submit( turn_context, turn_context.activity.value ) ) @@ -139,10 +139,10 @@ async def on_invoke_activity(self, turn_context: TurnContext): async def on_teams_card_action_invoke_activity(self, turn_context: TurnContext): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_signin_verify_state_activity(self, turn_context: TurnContext): + async def on_teams_signin_verify_state(self, turn_context: TurnContext): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_file_consent_activity( + async def on_teams_file_consent( self, turn_context: TurnContext, file_consent_card_response ): if file_consent_card_response.action == "accept": @@ -172,27 +172,27 @@ async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argu ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_o365_connector_card_action_activity( # pylint: disable=unused-argument + async def on_teams_o365_connector_card_action( # pylint: disable=unused-argument self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_app_based_link_query_activity( # pylint: disable=unused-argument + async def on_teams_app_based_link_query( # pylint: disable=unused-argument self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_query_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_query( # pylint: disable=unused-argument self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_select_item_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_select_item( # pylint: disable=unused-argument self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_submit_action_dispatch_activity( + async def on_teams_messaging_extension_submit_action_dispatch( self, turn_context: TurnContext, action ): if not action: @@ -230,32 +230,32 @@ async def on_teams_messaging_extension_submit_action_activity( # pylint: disabl ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_fetch_task_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_fetch_task( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_configuration_query_settings_url_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_configuration_query_settings_url( # pylint: disable=unused-argument self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_configuration_setting_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_configuration_setting( # pylint: disable=unused-argument self, turn_context: TurnContext, settings ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_card_button_clicked_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_card_button_clicked( # pylint: disable=unused-argument self, turn_context: TurnContext, card_data ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_task_module_fetch_activity( # pylint: disable=unused-argument + async def on_teams_task_module_fetch( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_task_module_submit_activity( # pylint: disable=unused-argument + async def on_teams_task_module_submit( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) From 4fafb1527b052123c0ead2b607beb713c3678fb5 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 3 Dec 2019 14:06:03 -0600 Subject: [PATCH 068/616] Corrected black errors --- .../core/teams/teams_activity_handler.py | 18 +++++++++++++----- .../schema/teams/additional_properties.py | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index b687dc250..45834c88f 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -10,7 +10,8 @@ ChannelInfo, TeamsChannelData, TeamsChannelAccount, - FileConsentCardResponse) + FileConsentCardResponse, +) from botframework.connector import Channels @@ -55,7 +56,8 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "fileConsent/invoke": return await self.on_teams_file_consent( - turn_context, FileConsentCardResponse.deserialize(turn_context.activity.value) + turn_context, + FileConsentCardResponse.deserialize(turn_context.activity.value), ) if turn_context.activity.name == "actionableMessage/executeAction": @@ -143,7 +145,9 @@ async def on_teams_signin_verify_state(self, turn_context: TurnContext): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent( - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): if file_consent_card_response.action == "accept": await self.on_teams_file_consent_accept( @@ -163,12 +167,16 @@ async def on_teams_file_consent( ) async def on_teams_file_consent_accept( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent_decline( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py index 83062d01c..e9c7544d7 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py @@ -8,6 +8,7 @@ class ContentType: 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" From aca5c976e93edc601e1219edb222c9105142b670 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 3 Dec 2019 17:13:49 -0800 Subject: [PATCH 069/616] adding unit tests for activity handler, cleaning up types on activity handler --- .../core/teams/teams_activity_handler.py | 84 ++- libraries/botbuilder-core/tests/__init__.py | 3 + .../botbuilder-core/tests/teams/__init__.py | 0 .../teams/test_teams_activity_handler.py | 693 +++++++++++++++++- .../botbuilder/schema/_models.py | 2 +- .../botbuilder/schema/_models_py3.py | 4 +- .../botbuilder/schema/teams/_models.py | 12 +- .../botbuilder/schema/teams/_models_py3.py | 21 +- 8 files changed, 726 insertions(+), 93 deletions(-) create mode 100644 libraries/botbuilder-core/tests/__init__.py create mode 100644 libraries/botbuilder-core/tests/teams/__init__.py diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 7ff3cf4ec..8e647b76f 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -6,13 +6,21 @@ from botbuilder.core.turn_context import TurnContext from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.schema.teams import ( + AppBasedLinkQuery, TeamInfo, ChannelInfo, + FileConsentCardResponse, TeamsChannelData, TeamsChannelAccount, + MessageActionsPayload, + MessagingExtensionAction, + MessagingExtensionQuery, + O365ConnectorCardActionQuery, + TaskModuleRequest ) from botframework.connector import Channels - +import json +from typing import List class TeamsActivityHandler(ActivityHandler): async def on_turn(self, turn_context: TurnContext): @@ -55,26 +63,26 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "fileConsent/invoke": return await self.on_teams_file_consent( - turn_context, turn_context.activity.value + turn_context, FileConsentCardResponse(**turn_context.activity.value) ) if turn_context.activity.name == "actionableMessage/executeAction": await self.on_teams_o365_connector_card_action( - turn_context, turn_context.activity.value + turn_context, O365ConnectorCardActionQuery(**turn_context.activity.value) ) return self._create_invoke_response() if turn_context.activity.name == "composeExtension/queryLink": return self._create_invoke_response( await self.on_teams_app_based_link_query( - turn_context, turn_context.activity.value + turn_context, AppBasedLinkQuery(**turn_context.activity.value) ) ) if turn_context.activity.name == "composeExtension/query": return self._create_invoke_response( await self.on_teams_messaging_extension_query( - turn_context, turn_context.activity.value + turn_context, MessagingExtensionQuery(**turn_context.activity.value) ) ) @@ -88,21 +96,21 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "composeExtension/submitAction": return self._create_invoke_response( await self.on_teams_messaging_extension_submit_action_dispatch( - turn_context, turn_context.activity.value + turn_context, MessagingExtensionAction(**turn_context.activity.value) ) ) if turn_context.activity.name == "composeExtension/fetchTask": return self._create_invoke_response( await self.on_teams_messaging_extension_fetch_task( - turn_context, turn_context.activity.value + turn_context, MessagingExtensionAction(**turn_context.activity.value) ) ) if turn_context.activity.name == "composeExtension/querySettingUrl": return self._create_invoke_response( await self.on_teams_messaging_extension_configuration_query_settings_url( - turn_context, turn_context.activity.value + turn_context, MessagingExtensionQuery(**turn_context.activity.value) ) ) @@ -121,14 +129,14 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "task/fetch": return self._create_invoke_response( await self.on_teams_task_module_fetch( - turn_context, turn_context.activity.value + turn_context, TaskModuleRequest(**turn_context.activity.value) ) ) if turn_context.activity.name == "task/submit": return self._create_invoke_response( await self.on_teams_task_module_submit( - turn_context, turn_context.activity.value + turn_context, TaskModuleRequest(**turn_context.activity.value) ) ) @@ -143,7 +151,7 @@ async def on_teams_signin_verify_state(self, turn_context: TurnContext): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent( - self, turn_context: TurnContext, file_consent_card_response + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse ): if file_consent_card_response.action == "accept": await self.on_teams_file_consent_accept_activity( @@ -163,39 +171,39 @@ async def on_teams_file_consent( ) async def on_teams_file_consent_accept_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_o365_connector_card_action( # pylint: disable=unused-argument - self, turn_context: TurnContext, query + self, turn_context: TurnContext, query: O365ConnectorCardActionQuery ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_app_based_link_query( # pylint: disable=unused-argument - self, turn_context: TurnContext, query + self, turn_context: TurnContext, query: AppBasedLinkQuery ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_query( # pylint: disable=unused-argument - self, turn_context: TurnContext, query + self, turn_context: TurnContext, query: MessagingExtensionQuery ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_select_item( # pylint: disable=unused-argument - self, turn_context: TurnContext, query + self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_submit_action_dispatch( - self, turn_context: TurnContext, action + self, turn_context: TurnContext, action: MessagingExtensionAction ): - if not action: + if not action.bot_message_preview_action: return await self.on_teams_messaging_extension_submit_action_activity( turn_context, action ) @@ -226,17 +234,17 @@ async def on_teams_messaging_extension_bot_message_send_activity( # pylint: dis raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_submit_action_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, action + self, turn_context: TurnContext, action: MessagingExtensionAction ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_fetch_task( # pylint: disable=unused-argument - self, turn_context: TurnContext, task_module_request + self, turn_context: TurnContext, action: MessagingExtensionAction ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_configuration_query_settings_url( # pylint: disable=unused-argument - self, turn_context: TurnContext, query + self, turn_context: TurnContext, query: MessagingExtensionQuery ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) @@ -251,19 +259,19 @@ async def on_teams_messaging_extension_card_button_clicked( # pylint: disable=u raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_task_module_fetch( # pylint: disable=unused-argument - self, turn_context: TurnContext, task_module_request + self, turn_context: TurnContext, task_module_request: TaskModuleRequest ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_task_module_submit( # pylint: disable=unused-argument - self, turn_context: TurnContext, task_module_request + self, turn_context: TurnContext, task_module_request: TaskModuleRequest ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_conversation_update_activity(self, turn_context: TurnContext): + if turn_context.activity.channel_id == Channels.ms_teams: channel_data = TeamsChannelData(**turn_context.activity.channel_data) - if turn_context.activity.members_added: return await self.on_teams_members_added_dispatch_activity( turn_context.activity.members_added, channel_data.team, turn_context @@ -279,7 +287,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): if channel_data: if channel_data.event_type == "channelCreated": return await self.on_teams_channel_created_activity( - channel_data.channel, channel_data.team, turn_context + ChannelInfo(**channel_data.channel), channel_data.team, turn_context ) if channel_data.event_type == "channelDeleted": return await self.on_teams_channel_deleted_activity( @@ -290,9 +298,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): channel_data.channel, channel_data.team, turn_context ) if channel_data.event_type == "teamRenamed": - return await self.on_teams_team_renamed_activity( - channel_data.team, turn_context - ) + return await self.on_teams_team_renamed_activity(channel_data.team, turn_context) return await super().on_conversation_update_activity(turn_context) return await super().on_conversation_update_activity(turn_context) @@ -300,7 +306,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_teams_channel_created_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - return + return async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext @@ -337,17 +343,20 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) """ + team_accounts_added = [] for member in members_added: new_account_json = member.serialize() - del new_account_json["additional_properties"] + if "additional_properties" in new_account_json: + del new_account_json["additional_properties"] member = TeamsChannelAccount(**new_account_json) - return await self.on_teams_members_added_activity(members_added, turn_context) + team_accounts_added.append(member) + return await self.on_teams_members_added_activity(team_accounts_added, turn_context) async def on_teams_members_added_activity( self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext ): - teams_members_added = [ChannelAccount(member) for member in teams_members_added] - return super().on_members_added_activity(teams_members_added, turn_context) + teams_members_added = [ ChannelAccount(**member.serialize()) for member in teams_members_added ] + return await super().on_members_added_activity(teams_members_added, turn_context) async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument self, @@ -358,7 +367,8 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- teams_members_removed = [] for member in members_removed: new_account_json = member.serialize() - del new_account_json["additional_properties"] + if "additional_properties" in new_account_json: + del new_account_json["additional_properties"] teams_members_removed.append(TeamsChannelAccount(**new_account_json)) return await self.on_teams_members_removed_activity( @@ -368,8 +378,8 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- async def on_teams_members_removed_activity( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): - members_removed = [ChannelAccount(member) for member in teams_members_removed] - return super().on_members_removed_activity(members_removed, turn_context) + members_removed = [ChannelAccount(**member.serialize()) for member in teams_members_removed] + return await super().on_members_removed_activity(members_removed, turn_context) async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext diff --git a/libraries/botbuilder-core/tests/__init__.py b/libraries/botbuilder-core/tests/__init__.py new file mode 100644 index 000000000..bc1cb1d65 --- /dev/null +++ b/libraries/botbuilder-core/tests/__init__.py @@ -0,0 +1,3 @@ +from .simple_adapter import SimpleAdapter + +__all__ = ["SimpleAdapter"] \ No newline at end of file diff --git a/libraries/botbuilder-core/tests/teams/__init__.py b/libraries/botbuilder-core/tests/teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 87b092e09..1d39fd823 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -3,6 +3,7 @@ import aiounittest from botbuilder.core import BotAdapter, TurnContext from botbuilder.core.teams import TeamsActivityHandler +from .. import SimpleAdapter from botbuilder.schema import ( Activity, ActivityTypes, @@ -11,44 +12,44 @@ MessageReaction, ResourceResponse, ) - +from botbuilder.schema.teams import ( + AppBasedLinkQuery, + ChannelInfo, + FileConsentCardResponse, + MessageActionsPayload, + MessagingExtensionAction, + MessagingExtensionQuery, + NotificationInfo, + O365ConnectorCardActionQuery, + TaskModuleRequest, + TaskModuleRequestContext, + TeamInfo, + TeamsChannelAccount, + TeamsChannelData, + TenantInfo, +) +from botframework.connector import Channels class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): self.record: List[str] = [] + async def on_conversation_update_activity(self, turn_context: TurnContext): + self.record.append("on_conversation_update_activity") + return await super().on_conversation_update_activity(turn_context) + + async def on_teams_members_added_activity(self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext): + self.record.append("on_teams_members_added_activity") + return await super().on_teams_members_added_activity(teams_members_added, turn_context) + + async def on_teams_members_removed_activity(self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext): + self.record.append("on_teams_members_removed_activity") + return await super().on_teams_members_removed_activity(teams_members_removed, turn_context) + async def on_message_activity(self, turn_context: TurnContext): self.record.append("on_message_activity") return await super().on_message_activity(turn_context) - async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_added_activity") - return await super().on_members_added_activity(members_added, turn_context) - - async def on_members_removed_activity( - self, members_removed: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_removed_activity") - return await super().on_members_removed_activity(members_removed, turn_context) - - async def on_message_reaction_activity(self, turn_context: TurnContext): - self.record.append("on_message_reaction_activity") - return await super().on_message_reaction_activity(turn_context) - - async def on_reactions_added( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_added") - return await super().on_reactions_added(message_reactions, turn_context) - - async def on_reactions_removed( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_removed") - return await super().on_reactions_removed(message_reactions, turn_context) - async def on_token_response_event(self, turn_context: TurnContext): self.record.append("on_token_response_event") return await super().on_token_response_event(turn_context) @@ -60,7 +61,130 @@ async def on_event(self, turn_context: TurnContext): async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) + + async def on_teams_channel_created_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_created_activity") + return await super().on_teams_channel_created_activity(channel_info, team_info, turn_context) + + async def on_teams_channel_renamed_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_renamed_activity") + return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) + + async def on_teams_channel_deleted_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_deleted_activity") + return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) + + async def on_teams_team_renamed_activity(self, team_info: TeamInfo, turn_context: TurnContext): + self.record.append("on_teams_team_renamed_activity") + return await super().on_teams_team_renamed_activity(team_info, turn_context) + + async def on_invoke_activity(self, turn_context: TurnContext): + self.record.append("on_invoke_activity") + return await super().on_invoke_activity(turn_context) + + async def on_teams_signin_verify_state(self, turn_context: TurnContext): + self.record.append("on_teams_signin_verify_state") + return await super().on_teams_signin_verify_state(turn_context) + + async def on_teams_file_consent(self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse): + self.record.append("on_teams_file_consent") + return await super().on_teams_file_consent(turn_context, file_consent_card_response) + + async def on_teams_file_consent_accept_activity( + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + ): + self.record.append("on_teams_file_consent_accept_activity") + return await super().on_teams_file_consent_accept_activity(turn_context, file_consent_card_response) + + async def on_teams_file_consent_decline_activity( + self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + ): + self.record.append("on_teams_file_consent_decline_activity") + return await super().on_teams_file_consent_decline_activity(turn_context, file_consent_card_response) + + async def on_teams_o365_connector_card_action( + self, turn_context: TurnContext, query: O365ConnectorCardActionQuery + ): + self.record.append("on_teams_o365_connector_card_action") + return await super().on_teams_o365_connector_card_action(turn_context, query) + + async def on_teams_app_based_link_query( + self, turn_context: TurnContext, query: AppBasedLinkQuery + ): + self.record.append("on_teams_app_based_link_query") + return await super().on_teams_app_based_link_query(turn_context, query) + + async def on_teams_messaging_extension_query( + self, turn_context: TurnContext, query: MessagingExtensionQuery + ): + self.record.append("on_teams_messaging_extension_query") + return await super().on_teams_messaging_extension_query(turn_context, query) + + async def on_teams_messaging_extension_submit_action_dispatch( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_submit_action_dispatch") + return await super().on_teams_messaging_extension_submit_action_dispatch(turn_context, action) + + async def on_teams_messaging_extension_submit_action_activity( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_submit_action_activity") + return await super().on_teams_messaging_extension_submit_action_activity(turn_context, action) + + async def on_teams_messaging_extension_bot_message_preview_edit_activity( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_bot_message_preview_edit_activity") + return await super().on_teams_messaging_extension_bot_message_preview_edit_activity(turn_context, action) + + async def on_teams_messaging_extension_bot_message_send_activity( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_bot_message_send_activity") + return await super().on_teams_messaging_extension_bot_message_send_activity(turn_context, action) + + async def on_teams_messaging_extension_fetch_task( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_fetch_task") + return await super().on_teams_messaging_extension_fetch_task(turn_context, action) + + async def on_teams_messaging_extension_configuration_query_settings_url( + self, turn_context: TurnContext, query: MessagingExtensionQuery + ): + self.record.append("on_teams_messaging_extension_configuration_query_settings_url") + return await super().on_teams_messaging_extension_configuration_query_settings_url(turn_context, query) + + async def on_teams_messaging_extension_configuration_setting( + self, turn_context: TurnContext, settings + ): + self.record.append("on_teams_messaging_extension_configuration_setting") + return await super().on_teams_messaging_extension_configuration_setting(turn_context, settings) + + async def on_teams_messaging_extension_card_button_clicked( + self, turn_context: TurnContext, card_data + ): + self.record.append("on_teams_messaging_extension_card_button_clicked") + return await super().on_teams_messaging_extension_card_button_clicked(turn_context, card_data) + + async def on_teams_task_module_fetch( + self, turn_context: TurnContext, task_module_request + ): + self.record.append("on_teams_task_module_fetch") + return await super().on_teams_task_module_fetch(turn_context, task_module_request) + async def on_teams_task_module_submit( # pylint: disable=unused-argument + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ): + self.record.append("on_teams_task_module_submit") + return await super().on_teams_task_module_submit(turn_context, task_module_request) class NotImplementedAdapter(BotAdapter): async def delete_activity( @@ -76,18 +200,46 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): raise NotImplementedError() - class TestTeamsActivityHandler(aiounittest.AsyncTestCase): - async def test_message_reaction(self): - # Note the code supports multiple adds and removes in the same activity though - # a channel may decide to send separate activities for each. For example, Teams - # sends separate activities each with a single add and a single remove. + async def test_on_teams_channel_created_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelCreated", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) - # Arrange + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_created_activity" + + async def test_on_teams_channel_renamed_activity(self): + #arrange activity = Activity( - type=ActivityTypes.message_reaction, - reactions_added=[MessageReaction(type="sad")], + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelRenamed", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams ) + turn_context = TurnContext(NotImplementedAdapter(), activity) # Act @@ -96,5 +248,468 @@ async def test_message_reaction(self): # Assert assert len(bot.record) == 2 - assert bot.record[0] == "on_message_reaction_activity" - assert bot.record[1] == "on_reactions_added" + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_renamed_activity" + + async def test_on_teams_channel_deleted_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelDeleted", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_deleted_activity" + + async def test_on_teams_team_renamed_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamRenamed", + "team": { + "id": "team_id_1", + "name" : "new_team_name" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_renamed_activity" + + async def test_on_teams_members_added_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamMemberAdded" + }, + members_added = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_added_activity" + + async def test_on_teams_members_removed_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamMemberRemoved" + }, + members_removed = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_removed_activity" + + async def test_on_signin_verify_state(self): + #arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "signin/verifyState" + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_signin_verify_state" + + async def test_on_file_consent_accept_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "fileConsent/invoke", + value = {"action" : "accept"} + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_file_consent" + assert bot.record[2] == "on_teams_file_consent_accept_activity" + + async def test_on_file_consent_decline_activity(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "fileConsent/invoke", + value = {"action" : "decline"} + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_file_consent" + assert bot.record[2] == "on_teams_file_consent_decline_activity" + + async def test_on_file_consent_bad_action_activity(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "fileConsent/invoke", + value = {"action" : "bad_action"} + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_file_consent" + + async def test_on_teams_o365_connector_card_action(self): + #arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "actionableMessage/executeAction", + value = { + "body": "body_here", + "actionId": "action_id_here" + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_o365_connector_card_action" + + async def test_on_app_based_link_query(self): + #arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/query", + value = { + "url": "http://www.test.com" + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_query" + + async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/submitAction", + value = { + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "edit", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_edit_activity" + + async def test_on_teams_messaging_extension_bot_message_send_activity(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/submitAction", + value = { + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "send", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_bot_message_send_activity" + + async def test_on_teams_messaging_extension_bot_message_send_activity_with_none(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/submitAction", + value = { + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": None, + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_submit_action_activity" + + async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty_string(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/submitAction", + value = { + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_submit_action_activity" + + async def test_on_teams_messaging_extension_fetch_task(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/fetchTask", + value = { + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "message_action", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_fetch_task" + + async def test_on_teams_messaging_extension_configuration_query_settings_url(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/querySettingUrl", + value = { + "comamndId": "test_command", + "parameters": [], + "messagingExtensionQueryOptions": {"skip": 1, "count": 1}, + "state": "state_string", + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_configuration_query_settings_url" + + async def test_on_teams_messaging_extension_configuration_setting(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/setting", + value = { + "key": "value" + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_configuration_setting" + + async def test_on_teams_messaging_extension_card_button_clicked(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "composeExtension/onCardButtonClicked", + value = { + "key": "value" + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_card_button_clicked" + + async def test_on_teams_task_module_fetch(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "task/fetch", + value = { + "data": {"key": "value"}, + "context": TaskModuleRequestContext().serialize() + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_task_module_fetch" + + async def test_on_teams_task_module_submit(self): + # Arrange + activity = Activity( + type = ActivityTypes.invoke, + name = "task/submit", + value = { + "data": {"key": "value"}, + "context": TaskModuleRequestContext().serialize() + } + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_task_module_submit" \ No newline at end of file diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 736ddcf81..9574df14a 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -611,7 +611,7 @@ def __init__(self, **kwargs): super(ChannelAccount, self).__init__(**kwargs) self.id = kwargs.get("id", None) self.name = kwargs.get("name", None) - self.aad_object_id = kwargs.get("aad_object_id", None) + self.aad_object_id = kwargs.get("aadObjectId", None) self.role = kwargs.get("role", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 58caa1567..b6b9f1aac 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -730,14 +730,14 @@ def __init__( *, id: str = None, name: str = None, - aad_object_id: str = None, + aadObjectId: str = None, role=None, **kwargs ) -> None: super(ChannelAccount, self).__init__(**kwargs) self.id = id self.name = name - self.aad_object_id = aad_object_id + self.aad_object_id = aadObjectId self.role = role diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 5e41c6fd4..f348245af 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -559,7 +559,7 @@ def __init__(self, **kwargs): super(MessagingExtensionAction, self).__init__(**kwargs) self.command_id = kwargs.get("command_id", None) self.command_context = kwargs.get("command_context", None) - self.bot_message_preview_action = kwargs.get("bot_message_preview_action", None) + self.bot_message_preview_action = kwargs.get("botMessagePreviewAction", None) self.bot_activity_preview = kwargs.get("bot_activity_preview", None) self.message_payload = kwargs.get("message_payload", None) @@ -910,7 +910,8 @@ class O365ConnectorCardActionQuery(Model): def __init__(self, **kwargs): super(O365ConnectorCardActionQuery, self).__init__(**kwargs) self.body = kwargs.get("body", None) - self.action_id = kwargs.get("action_id", None) + # This is how it comes in from Teams + self.action_id = kwargs.get("actionId", None) class O365ConnectorCardDateInput(O365ConnectorCardInputBase): @@ -1535,7 +1536,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, } def __init__(self, **kwargs): @@ -1543,7 +1544,7 @@ def __init__(self, **kwargs): self.given_name = kwargs.get("given_name", None) self.surname = kwargs.get("surname", None) self.email = kwargs.get("email", None) - self.user_principal_name = kwargs.get("user_principal_name", None) + self.userPrincipalName = kwargs.get("userPrincipalName", None) class TeamsChannelData(Model): @@ -1573,7 +1574,8 @@ class TeamsChannelData(Model): def __init__(self, **kwargs): super(TeamsChannelData, self).__init__(**kwargs) self.channel = kwargs.get("channel", None) - self.event_type = kwargs.get("event_type", None) + # doing camel case here since that's how the data comes in + self.event_type = kwargs.get("eventType", None) self.team = kwargs.get("team", None) self.notification = kwargs.get("notification", None) self.tenant = kwargs.get("tenant", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 80249f277..612f59cde 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -670,7 +670,7 @@ def __init__( context=None, command_id: str = None, command_context=None, - bot_message_preview_action=None, + botMessagePreviewAction=None, bot_activity_preview=None, message_payload=None, **kwargs @@ -680,7 +680,7 @@ def __init__( ) self.command_id = command_id self.command_context = command_context - self.bot_message_preview_action = bot_message_preview_action + self.bot_message_preview_action = botMessagePreviewAction self.bot_activity_preview = bot_activity_preview self.message_payload = message_payload @@ -1129,10 +1129,11 @@ class O365ConnectorCardActionQuery(Model): "action_id": {"key": "actionId", "type": "str"}, } - def __init__(self, *, body: str = None, action_id: str = None, **kwargs) -> None: + def __init__(self, *, body: str = None, actionId: str = None, **kwargs) -> None: super(O365ConnectorCardActionQuery, self).__init__(**kwargs) self.body = body - self.action_id = action_id + # This is how it comes in from Teams + self.action_id = actionId class O365ConnectorCardDateInput(O365ConnectorCardInputBase): @@ -1796,7 +1797,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, } def __init__( @@ -1807,14 +1808,15 @@ def __init__( given_name: str = None, surname: str = None, email: str = None, - user_principal_name: str = None, + userPrincipalName: str = None, **kwargs ) -> None: super(TeamsChannelAccount, self).__init__(id=id, name=name, **kwargs) self.given_name = given_name self.surname = surname self.email = email - self.user_principal_name = user_principal_name + # changing to camel case due to how data comes in off the wire + self.userPrincipalName = userPrincipalName class TeamsChannelData(Model): @@ -1845,7 +1847,7 @@ def __init__( self, *, channel=None, - event_type: str = None, + eventType: str = None, team=None, notification=None, tenant=None, @@ -1853,7 +1855,8 @@ def __init__( ) -> None: super(TeamsChannelData, self).__init__(**kwargs) self.channel = channel - self.event_type = event_type + # doing camel case here since that's how the data comes in + self.event_type = eventType self.team = team self.notification = notification self.tenant = tenant From fa499557de19105345b76a35b5f30b63ce938b95 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 3 Dec 2019 17:15:26 -0800 Subject: [PATCH 070/616] resolving merge conflict --- .../core/teams/teams_activity_handler.py | 43 ++-- .../teams/test_teams_activity_handler.py | 217 ++++++++++++++---- .../botbuilder/schema/_models.py | 2 +- .../botbuilder/schema/_models_py3.py | 6 +- .../botbuilder/schema/teams/_models.py | 7 +- .../botbuilder/schema/teams/_models_py3.py | 14 +- 6 files changed, 221 insertions(+), 68 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 7ff3cf4ec..74468ac78 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -12,7 +12,8 @@ TeamsChannelAccount, ) from botframework.connector import Channels - +import json +from typing import List class TeamsActivityHandler(ActivityHandler): async def on_turn(self, turn_context: TurnContext): @@ -261,9 +262,9 @@ async def on_teams_task_module_submit( # pylint: disable=unused-argument raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_conversation_update_activity(self, turn_context: TurnContext): + if turn_context.activity.channel_id == Channels.ms_teams: channel_data = TeamsChannelData(**turn_context.activity.channel_data) - if turn_context.activity.members_added: return await self.on_teams_members_added_dispatch_activity( turn_context.activity.members_added, channel_data.team, turn_context @@ -277,22 +278,20 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): ) if channel_data: - if channel_data.event_type == "channelCreated": + if channel_data.eventType == "channelCreated": return await self.on_teams_channel_created_activity( - channel_data.channel, channel_data.team, turn_context + ChannelInfo(**channel_data.channel), channel_data.team, turn_context ) - if channel_data.event_type == "channelDeleted": + if channel_data.eventType == "channelDeleted": return await self.on_teams_channel_deleted_activity( channel_data.channel, channel_data.team, turn_context ) - if channel_data.event_type == "channelRenamed": + if channel_data.eventType == "channelRenamed": return await self.on_teams_channel_renamed_activity( channel_data.channel, channel_data.team, turn_context ) - if channel_data.event_type == "teamRenamed": - return await self.on_teams_team_renamed_activity( - channel_data.team, turn_context - ) + if channel_data.eventType == "teamRenamed": + return await self.on_teams_team_renamed_activity(channel_data.team, turn_context) return await super().on_conversation_update_activity(turn_context) return await super().on_conversation_update_activity(turn_context) @@ -300,7 +299,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_teams_channel_created_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - return + return async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext @@ -337,17 +336,24 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) """ + team_accounts_added = [] for member in members_added: new_account_json = member.serialize() +<<<<<<< HEAD del new_account_json["additional_properties"] +======= + if "additional_properties" in new_account_json: + del new_account_json["additional_properties"] +>>>>>>> e682f38... saving unit tests so far member = TeamsChannelAccount(**new_account_json) - return await self.on_teams_members_added_activity(members_added, turn_context) + team_accounts_added.append(member) + return await self.on_teams_members_added_activity(team_accounts_added, turn_context) async def on_teams_members_added_activity( self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext ): - teams_members_added = [ChannelAccount(member) for member in teams_members_added] - return super().on_members_added_activity(teams_members_added, turn_context) + teams_members_added = [ ChannelAccount(**member.serialize()) for member in teams_members_added ] + return await super().on_members_added_activity(teams_members_added, turn_context) async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument self, @@ -358,7 +364,12 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- teams_members_removed = [] for member in members_removed: new_account_json = member.serialize() +<<<<<<< HEAD del new_account_json["additional_properties"] +======= + if "additional_properties" in new_account_json: + del new_account_json["additional_properties"] +>>>>>>> e682f38... saving unit tests so far teams_members_removed.append(TeamsChannelAccount(**new_account_json)) return await self.on_teams_members_removed_activity( @@ -368,8 +379,8 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- async def on_teams_members_removed_activity( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): - members_removed = [ChannelAccount(member) for member in teams_members_removed] - return super().on_members_removed_activity(members_removed, turn_context) + members_removed = [ChannelAccount(**member.serialize()) for member in teams_members_removed] + return await super().on_members_removed_activity(members_removed, turn_context) async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 87b092e09..2b04c1f09 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -11,44 +11,36 @@ MessageReaction, ResourceResponse, ) - +from botbuilder.schema.teams import ( + ChannelInfo, + NotificationInfo, + TeamInfo, + TeamsChannelAccount, + TeamsChannelData, + TenantInfo, +) +from botframework.connector import Channels class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): self.record: List[str] = [] + async def on_conversation_update_activity(self, turn_context: TurnContext): + self.record.append("on_conversation_update_activity") + return await super().on_conversation_update_activity(turn_context) + + async def on_teams_members_added_activity(self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext): + self.record.append("on_teams_members_added_activity") + return await super().on_teams_members_added_activity(teams_members_added, turn_context) + + async def on_teams_members_removed_activity(self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext): + self.record.append("on_teams_members_removed_activity") + return await super().on_teams_members_removed_activity(teams_members_removed, turn_context) + async def on_message_activity(self, turn_context: TurnContext): self.record.append("on_message_activity") return await super().on_message_activity(turn_context) - async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_added_activity") - return await super().on_members_added_activity(members_added, turn_context) - - async def on_members_removed_activity( - self, members_removed: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_removed_activity") - return await super().on_members_removed_activity(members_removed, turn_context) - - async def on_message_reaction_activity(self, turn_context: TurnContext): - self.record.append("on_message_reaction_activity") - return await super().on_message_reaction_activity(turn_context) - - async def on_reactions_added( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_added") - return await super().on_reactions_added(message_reactions, turn_context) - - async def on_reactions_removed( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_removed") - return await super().on_reactions_removed(message_reactions, turn_context) - async def on_token_response_event(self, turn_context: TurnContext): self.record.append("on_token_response_event") return await super().on_token_response_event(turn_context) @@ -60,7 +52,32 @@ async def on_event(self, turn_context: TurnContext): async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) + + async def on_teams_channel_created_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_created_activity") + return await super().on_teams_channel_created_activity(channel_info, team_info, turn_context) + + async def on_teams_channel_renamed_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_renamed_activity") + return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) + + async def on_teams_channel_deleted_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_deleted_activity") + return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) + + async def on_teams_team_renamed_activity(self, team_info: TeamInfo, turn_context: TurnContext): + self.record.append("on_teams_team_renamed_activity") + return await super().on_teams_team_renamed_activity(team_info, turn_context) + async def on_invoke_activity(self, turn_context: TurnContext): + self.record.append("on_invoke_activity") + return await super().on_invoke_activity(turn_context) class NotImplementedAdapter(BotAdapter): async def delete_activity( @@ -76,18 +93,140 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): raise NotImplementedError() - class TestTeamsActivityHandler(aiounittest.AsyncTestCase): - async def test_message_reaction(self): - # Note the code supports multiple adds and removes in the same activity though - # a channel may decide to send separate activities for each. For example, Teams - # sends separate activities each with a single add and a single remove. + async def test_on_teams_channel_created_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelCreated", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_created_activity" + + async def test_on_teams_channel_renamed_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelRenamed", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) - # Arrange + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_renamed_activity" + + async def test_on_teams_channel_deleted_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "channelDeleted", + "channel": { + "id": "asdfqwerty", + "name" : "new_channel" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_deleted_activity" + + async def test_on_teams_team_renamed_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamRenamed", + "team": { + "id": "team_id_1", + "name" : "new_team_name" + } + }, + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_renamed_activity" + + async def test_on_teams_members_added_activity(self): + #arrange + activity = Activity( + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamMemberAdded" + }, + members_added = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], + channel_id = Channels.ms_teams + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_added_activity" + + async def test_on_teams_members_removed_activity(self): + #arrange activity = Activity( - type=ActivityTypes.message_reaction, - reactions_added=[MessageReaction(type="sad")], + type = ActivityTypes.conversation_update, + channel_data = { + "eventType": "teamMemberRemoved" + }, + members_removed = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], + channel_id = Channels.ms_teams ) + turn_context = TurnContext(NotImplementedAdapter(), activity) # Act @@ -96,5 +235,5 @@ async def test_message_reaction(self): # Assert assert len(bot.record) == 2 - assert bot.record[0] == "on_message_reaction_activity" - assert bot.record[1] == "on_reactions_added" + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_removed_activity" \ No newline at end of file diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 736ddcf81..dc40b3fee 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -611,7 +611,7 @@ def __init__(self, **kwargs): super(ChannelAccount, self).__init__(**kwargs) self.id = kwargs.get("id", None) self.name = kwargs.get("name", None) - self.aad_object_id = kwargs.get("aad_object_id", None) + self.aadObjectId = kwargs.get("aadObjectId", None) self.role = kwargs.get("role", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 58caa1567..c6cd5fdba 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -721,7 +721,7 @@ class ChannelAccount(Model): _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, - "aad_object_id": {"key": "aadObjectId", "type": "str"}, + "aadObjectId": {"key": "aadObjectId", "type": "str"}, "role": {"key": "role", "type": "str"}, } @@ -730,14 +730,14 @@ def __init__( *, id: str = None, name: str = None, - aad_object_id: str = None, + aadObjectId: str = None, role=None, **kwargs ) -> None: super(ChannelAccount, self).__init__(**kwargs) self.id = id self.name = name - self.aad_object_id = aad_object_id + self.aadObjectId = aadObjectId self.role = role diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 5e41c6fd4..fe0e5d741 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1535,7 +1535,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, } def __init__(self, **kwargs): @@ -1543,7 +1543,7 @@ def __init__(self, **kwargs): self.given_name = kwargs.get("given_name", None) self.surname = kwargs.get("surname", None) self.email = kwargs.get("email", None) - self.user_principal_name = kwargs.get("user_principal_name", None) + self.userPrincipalName = kwargs.get("userPrincipalName", None) class TeamsChannelData(Model): @@ -1573,7 +1573,8 @@ class TeamsChannelData(Model): def __init__(self, **kwargs): super(TeamsChannelData, self).__init__(**kwargs) self.channel = kwargs.get("channel", None) - self.event_type = kwargs.get("event_type", None) + # doing camel case here since that's how the data comes in + self.eventType = kwargs.get("eventType", None) self.team = kwargs.get("team", None) self.notification = kwargs.get("notification", None) self.tenant = kwargs.get("tenant", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 80249f277..3cd336941 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1796,7 +1796,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, } def __init__( @@ -1807,14 +1807,15 @@ def __init__( given_name: str = None, surname: str = None, email: str = None, - user_principal_name: str = None, + userPrincipalName: str = None, **kwargs ) -> None: super(TeamsChannelAccount, self).__init__(id=id, name=name, **kwargs) self.given_name = given_name self.surname = surname self.email = email - self.user_principal_name = user_principal_name + # changing to camel case due to how data comes in off the wire + self.userPrincipalName = userPrincipalName class TeamsChannelData(Model): @@ -1835,7 +1836,7 @@ class TeamsChannelData(Model): _attribute_map = { "channel": {"key": "channel", "type": "ChannelInfo"}, - "event_type": {"key": "eventType", "type": "str"}, + "eventType": {"key": "eventType", "type": "str"}, "team": {"key": "team", "type": "TeamInfo"}, "notification": {"key": "notification", "type": "NotificationInfo"}, "tenant": {"key": "tenant", "type": "TenantInfo"}, @@ -1845,7 +1846,7 @@ def __init__( self, *, channel=None, - event_type: str = None, + eventType: str = None, team=None, notification=None, tenant=None, @@ -1853,7 +1854,8 @@ def __init__( ) -> None: super(TeamsChannelData, self).__init__(**kwargs) self.channel = channel - self.event_type = event_type + # doing camel case here since that's how the data comes in + self.eventType = eventType self.team = team self.notification = notification self.tenant = tenant From 5fb0b92be971fde1136dbc97b884dbc660225d59 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 3 Dec 2019 21:24:39 -0800 Subject: [PATCH 071/616] adding init files for adapter import --- libraries/botbuilder-core/tests/__init__.py | 3 +++ libraries/botbuilder-core/tests/teams/__init__.py | 0 .../tests/teams/test_teams_activity_handler.py | 1 + .../botbuilder-schema/botbuilder/schema/_models_py3.py | 6 +++--- .../botbuilder/schema/teams/_models_py3.py | 6 +++--- 5 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 libraries/botbuilder-core/tests/__init__.py create mode 100644 libraries/botbuilder-core/tests/teams/__init__.py diff --git a/libraries/botbuilder-core/tests/__init__.py b/libraries/botbuilder-core/tests/__init__.py new file mode 100644 index 000000000..bc1cb1d65 --- /dev/null +++ b/libraries/botbuilder-core/tests/__init__.py @@ -0,0 +1,3 @@ +from .simple_adapter import SimpleAdapter + +__all__ = ["SimpleAdapter"] \ No newline at end of file diff --git a/libraries/botbuilder-core/tests/teams/__init__.py b/libraries/botbuilder-core/tests/teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 2b04c1f09..9184ee789 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -20,6 +20,7 @@ TenantInfo, ) from botframework.connector import Channels +from .. import SimpleAdapter class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index c6cd5fdba..58caa1567 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -721,7 +721,7 @@ class ChannelAccount(Model): _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, - "aadObjectId": {"key": "aadObjectId", "type": "str"}, + "aad_object_id": {"key": "aadObjectId", "type": "str"}, "role": {"key": "role", "type": "str"}, } @@ -730,14 +730,14 @@ def __init__( *, id: str = None, name: str = None, - aadObjectId: str = None, + aad_object_id: str = None, role=None, **kwargs ) -> None: super(ChannelAccount, self).__init__(**kwargs) self.id = id self.name = name - self.aadObjectId = aadObjectId + self.aad_object_id = aad_object_id self.role = role diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 3cd336941..e9547f62a 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1796,7 +1796,7 @@ class TeamsChannelAccount(ChannelAccount): "given_name": {"key": "givenName", "type": "str"}, "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, - "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, + "user_principal_name": {"key": "userPrincipalName", "type": "str"}, } def __init__( @@ -1807,7 +1807,7 @@ def __init__( given_name: str = None, surname: str = None, email: str = None, - userPrincipalName: str = None, + user_principal_name: str = None, **kwargs ) -> None: super(TeamsChannelAccount, self).__init__(id=id, name=name, **kwargs) @@ -1815,7 +1815,7 @@ def __init__( self.surname = surname self.email = email # changing to camel case due to how data comes in off the wire - self.userPrincipalName = userPrincipalName + self.user_principal_name = user_principal_name class TeamsChannelData(Model): From 9ed8110c6376f559d2cfefbd8580a20a7317c1a9 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 3 Dec 2019 21:51:37 -0800 Subject: [PATCH 072/616] cleaning up activity handler and models --- .../botbuilder/core/teams/teams_activity_handler.py | 6 +++--- .../botbuilder-schema/botbuilder/schema/_models_py3.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index d6fe66199..8e647b76f 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -285,15 +285,15 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): ) if channel_data: - if channel_data.eventType == "channelCreated": + if channel_data.event_type == "channelCreated": return await self.on_teams_channel_created_activity( ChannelInfo(**channel_data.channel), channel_data.team, turn_context ) - if channel_data.eventType == "channelDeleted": + if channel_data.event_type == "channelDeleted": return await self.on_teams_channel_deleted_activity( channel_data.channel, channel_data.team, turn_context ) - if channel_data.eventType == "channelRenamed": + if channel_data.event_type == "channelRenamed": return await self.on_teams_channel_renamed_activity( channel_data.channel, channel_data.team, turn_context ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index b6b9f1aac..58caa1567 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -730,14 +730,14 @@ def __init__( *, id: str = None, name: str = None, - aadObjectId: str = None, + aad_object_id: str = None, role=None, **kwargs ) -> None: super(ChannelAccount, self).__init__(**kwargs) self.id = id self.name = name - self.aad_object_id = aadObjectId + self.aad_object_id = aad_object_id self.role = role From e29840ed468cdcd07c030f75a98bea469b1dcc37 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 3 Dec 2019 22:39:54 -0800 Subject: [PATCH 073/616] fixing linting and black --- .../core/teams/teams_activity_handler.py | 63 +- libraries/botbuilder-core/tests/__init__.py | 2 +- .../teams/test_teams_activity_handler.py | 538 ++++++++++-------- .../botbuilder/schema/teams/_models.py | 2 +- .../botbuilder/schema/teams/_models_py3.py | 2 +- 5 files changed, 338 insertions(+), 269 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 8e647b76f..6ab5f3830 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -12,15 +12,13 @@ FileConsentCardResponse, TeamsChannelData, TeamsChannelAccount, - MessageActionsPayload, MessagingExtensionAction, MessagingExtensionQuery, O365ConnectorCardActionQuery, - TaskModuleRequest + TaskModuleRequest, ) from botframework.connector import Channels -import json -from typing import List + class TeamsActivityHandler(ActivityHandler): async def on_turn(self, turn_context: TurnContext): @@ -68,7 +66,8 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "actionableMessage/executeAction": await self.on_teams_o365_connector_card_action( - turn_context, O365ConnectorCardActionQuery(**turn_context.activity.value) + turn_context, + O365ConnectorCardActionQuery(**turn_context.activity.value), ) return self._create_invoke_response() @@ -82,7 +81,8 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "composeExtension/query": return self._create_invoke_response( await self.on_teams_messaging_extension_query( - turn_context, MessagingExtensionQuery(**turn_context.activity.value) + turn_context, + MessagingExtensionQuery(**turn_context.activity.value), ) ) @@ -96,21 +96,24 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "composeExtension/submitAction": return self._create_invoke_response( await self.on_teams_messaging_extension_submit_action_dispatch( - turn_context, MessagingExtensionAction(**turn_context.activity.value) + turn_context, + MessagingExtensionAction(**turn_context.activity.value), ) ) if turn_context.activity.name == "composeExtension/fetchTask": return self._create_invoke_response( await self.on_teams_messaging_extension_fetch_task( - turn_context, MessagingExtensionAction(**turn_context.activity.value) + turn_context, + MessagingExtensionAction(**turn_context.activity.value), ) ) if turn_context.activity.name == "composeExtension/querySettingUrl": return self._create_invoke_response( await self.on_teams_messaging_extension_configuration_query_settings_url( - turn_context, MessagingExtensionQuery(**turn_context.activity.value) + turn_context, + MessagingExtensionQuery(**turn_context.activity.value), ) ) @@ -151,7 +154,9 @@ async def on_teams_signin_verify_state(self, turn_context: TurnContext): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent( - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): if file_consent_card_response.action == "accept": await self.on_teams_file_consent_accept_activity( @@ -171,12 +176,16 @@ async def on_teams_file_consent( ) async def on_teams_file_consent_accept_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) @@ -196,7 +205,7 @@ async def on_teams_messaging_extension_query( # pylint: disable=unused-argument raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_select_item( # pylint: disable=unused-argument - self, turn_context: TurnContext, query + self, turn_context: TurnContext, query ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) @@ -269,7 +278,7 @@ async def on_teams_task_module_submit( # pylint: disable=unused-argument raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_conversation_update_activity(self, turn_context: TurnContext): - + if turn_context.activity.channel_id == Channels.ms_teams: channel_data = TeamsChannelData(**turn_context.activity.channel_data) if turn_context.activity.members_added: @@ -287,7 +296,9 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): if channel_data: if channel_data.event_type == "channelCreated": return await self.on_teams_channel_created_activity( - ChannelInfo(**channel_data.channel), channel_data.team, turn_context + ChannelInfo(**channel_data.channel), + channel_data.team, + turn_context, ) if channel_data.event_type == "channelDeleted": return await self.on_teams_channel_deleted_activity( @@ -298,7 +309,9 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): channel_data.channel, channel_data.team, turn_context ) if channel_data.event_type == "teamRenamed": - return await self.on_teams_team_renamed_activity(channel_data.team, turn_context) + return await self.on_teams_team_renamed_activity( + channel_data.team, turn_context + ) return await super().on_conversation_update_activity(turn_context) return await super().on_conversation_update_activity(turn_context) @@ -306,7 +319,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_teams_channel_created_activity( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - return + return async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext @@ -350,13 +363,19 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar del new_account_json["additional_properties"] member = TeamsChannelAccount(**new_account_json) team_accounts_added.append(member) - return await self.on_teams_members_added_activity(team_accounts_added, turn_context) + return await self.on_teams_members_added_activity( + team_accounts_added, turn_context + ) async def on_teams_members_added_activity( self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext ): - teams_members_added = [ ChannelAccount(**member.serialize()) for member in teams_members_added ] - return await super().on_members_added_activity(teams_members_added, turn_context) + teams_members_added = [ + ChannelAccount(**member.serialize()) for member in teams_members_added + ] + return await super().on_members_added_activity( + teams_members_added, turn_context + ) async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument self, @@ -378,7 +397,9 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- async def on_teams_members_removed_activity( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): - members_removed = [ChannelAccount(**member.serialize()) for member in teams_members_removed] + members_removed = [ + ChannelAccount(**member.serialize()) for member in teams_members_removed + ] return await super().on_members_removed_activity(members_removed, turn_context) async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument diff --git a/libraries/botbuilder-core/tests/__init__.py b/libraries/botbuilder-core/tests/__init__.py index bc1cb1d65..6fff60cf5 100644 --- a/libraries/botbuilder-core/tests/__init__.py +++ b/libraries/botbuilder-core/tests/__init__.py @@ -1,3 +1,3 @@ from .simple_adapter import SimpleAdapter -__all__ = ["SimpleAdapter"] \ No newline at end of file +__all__ = ["SimpleAdapter"] diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 2b760fd15..06152f21b 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -3,13 +3,11 @@ import aiounittest from botbuilder.core import BotAdapter, TurnContext from botbuilder.core.teams import TeamsActivityHandler -from .. import SimpleAdapter from botbuilder.schema import ( Activity, ActivityTypes, ChannelAccount, ConversationReference, - MessageReaction, ResourceResponse, ) from botbuilder.schema.teams import ( @@ -19,16 +17,14 @@ MessageActionsPayload, MessagingExtensionAction, MessagingExtensionQuery, - NotificationInfo, O365ConnectorCardActionQuery, TaskModuleRequest, TaskModuleRequestContext, TeamInfo, TeamsChannelAccount, - TeamsChannelData, - TenantInfo, ) from botframework.connector import Channels +from .. import SimpleAdapter class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): @@ -38,13 +34,21 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): self.record.append("on_conversation_update_activity") return await super().on_conversation_update_activity(turn_context) - async def on_teams_members_added_activity(self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext): + async def on_teams_members_added_activity( + self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext + ): self.record.append("on_teams_members_added_activity") - return await super().on_teams_members_added_activity(teams_members_added, turn_context) - - async def on_teams_members_removed_activity(self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext): + return await super().on_teams_members_added_activity( + teams_members_added, turn_context + ) + + async def on_teams_members_removed_activity( + self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + ): self.record.append("on_teams_members_removed_activity") - return await super().on_teams_members_removed_activity(teams_members_removed, turn_context) + return await super().on_teams_members_removed_activity( + teams_members_removed, turn_context + ) async def on_message_activity(self, turn_context: TurnContext): self.record.append("on_message_activity") @@ -61,65 +65,87 @@ async def on_event(self, turn_context: TurnContext): async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) - + async def on_teams_channel_created_activity( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): self.record.append("on_teams_channel_created_activity") - return await super().on_teams_channel_created_activity(channel_info, team_info, turn_context) - + return await super().on_teams_channel_created_activity( + channel_info, team_info, turn_context + ) + async def on_teams_channel_renamed_activity( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): self.record.append("on_teams_channel_renamed_activity") - return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) - + return await super().on_teams_channel_renamed_activity( + channel_info, team_info, turn_context + ) + async def on_teams_channel_deleted_activity( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): self.record.append("on_teams_channel_deleted_activity") - return await super().on_teams_channel_renamed_activity(channel_info, team_info, turn_context) - - async def on_teams_team_renamed_activity(self, team_info: TeamInfo, turn_context: TurnContext): + return await super().on_teams_channel_renamed_activity( + channel_info, team_info, turn_context + ) + + async def on_teams_team_renamed_activity( + self, team_info: TeamInfo, turn_context: TurnContext + ): self.record.append("on_teams_team_renamed_activity") return await super().on_teams_team_renamed_activity(team_info, turn_context) async def on_invoke_activity(self, turn_context: TurnContext): self.record.append("on_invoke_activity") return await super().on_invoke_activity(turn_context) - + async def on_teams_signin_verify_state(self, turn_context: TurnContext): self.record.append("on_teams_signin_verify_state") return await super().on_teams_signin_verify_state(turn_context) - - async def on_teams_file_consent(self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse): + + async def on_teams_file_consent( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ): self.record.append("on_teams_file_consent") - return await super().on_teams_file_consent(turn_context, file_consent_card_response) - + return await super().on_teams_file_consent( + turn_context, file_consent_card_response + ) + async def on_teams_file_consent_accept_activity( - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): self.record.append("on_teams_file_consent_accept_activity") - return await super().on_teams_file_consent_accept_activity(turn_context, file_consent_card_response) + return await super().on_teams_file_consent_accept_activity( + turn_context, file_consent_card_response + ) async def on_teams_file_consent_decline_activity( - self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, ): self.record.append("on_teams_file_consent_decline_activity") - return await super().on_teams_file_consent_decline_activity(turn_context, file_consent_card_response) + return await super().on_teams_file_consent_decline_activity( + turn_context, file_consent_card_response + ) async def on_teams_o365_connector_card_action( self, turn_context: TurnContext, query: O365ConnectorCardActionQuery ): self.record.append("on_teams_o365_connector_card_action") return await super().on_teams_o365_connector_card_action(turn_context, query) - + async def on_teams_app_based_link_query( self, turn_context: TurnContext, query: AppBasedLinkQuery ): self.record.append("on_teams_app_based_link_query") return await super().on_teams_app_based_link_query(turn_context, query) - + async def on_teams_messaging_extension_query( self, turn_context: TurnContext, query: MessagingExtensionQuery ): @@ -130,61 +156,86 @@ async def on_teams_messaging_extension_submit_action_dispatch( self, turn_context: TurnContext, action: MessagingExtensionAction ): self.record.append("on_teams_messaging_extension_submit_action_dispatch") - return await super().on_teams_messaging_extension_submit_action_dispatch(turn_context, action) + return await super().on_teams_messaging_extension_submit_action_dispatch( + turn_context, action + ) async def on_teams_messaging_extension_submit_action_activity( self, turn_context: TurnContext, action: MessagingExtensionAction ): self.record.append("on_teams_messaging_extension_submit_action_activity") - return await super().on_teams_messaging_extension_submit_action_activity(turn_context, action) + return await super().on_teams_messaging_extension_submit_action_activity( + turn_context, action + ) async def on_teams_messaging_extension_bot_message_preview_edit_activity( self, turn_context: TurnContext, action: MessagingExtensionAction ): - self.record.append("on_teams_messaging_extension_bot_message_preview_edit_activity") - return await super().on_teams_messaging_extension_bot_message_preview_edit_activity(turn_context, action) - + self.record.append( + "on_teams_messaging_extension_bot_message_preview_edit_activity" + ) + return await super().on_teams_messaging_extension_bot_message_preview_edit_activity( + turn_context, action + ) + async def on_teams_messaging_extension_bot_message_send_activity( self, turn_context: TurnContext, action: MessagingExtensionAction ): self.record.append("on_teams_messaging_extension_bot_message_send_activity") - return await super().on_teams_messaging_extension_bot_message_send_activity(turn_context, action) + return await super().on_teams_messaging_extension_bot_message_send_activity( + turn_context, action + ) async def on_teams_messaging_extension_fetch_task( self, turn_context: TurnContext, action: MessagingExtensionAction ): self.record.append("on_teams_messaging_extension_fetch_task") - return await super().on_teams_messaging_extension_fetch_task(turn_context, action) + return await super().on_teams_messaging_extension_fetch_task( + turn_context, action + ) async def on_teams_messaging_extension_configuration_query_settings_url( self, turn_context: TurnContext, query: MessagingExtensionQuery ): - self.record.append("on_teams_messaging_extension_configuration_query_settings_url") - return await super().on_teams_messaging_extension_configuration_query_settings_url(turn_context, query) + self.record.append( + "on_teams_messaging_extension_configuration_query_settings_url" + ) + return await super().on_teams_messaging_extension_configuration_query_settings_url( + turn_context, query + ) async def on_teams_messaging_extension_configuration_setting( self, turn_context: TurnContext, settings ): self.record.append("on_teams_messaging_extension_configuration_setting") - return await super().on_teams_messaging_extension_configuration_setting(turn_context, settings) + return await super().on_teams_messaging_extension_configuration_setting( + turn_context, settings + ) async def on_teams_messaging_extension_card_button_clicked( self, turn_context: TurnContext, card_data ): self.record.append("on_teams_messaging_extension_card_button_clicked") - return await super().on_teams_messaging_extension_card_button_clicked(turn_context, card_data) + return await super().on_teams_messaging_extension_card_button_clicked( + turn_context, card_data + ) async def on_teams_task_module_fetch( self, turn_context: TurnContext, task_module_request ): self.record.append("on_teams_task_module_fetch") - return await super().on_teams_task_module_fetch(turn_context, task_module_request) + return await super().on_teams_task_module_fetch( + turn_context, task_module_request + ) async def on_teams_task_module_submit( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request: TaskModuleRequest ): self.record.append("on_teams_task_module_submit") - return await super().on_teams_task_module_submit(turn_context, task_module_request) + return await super().on_teams_task_module_submit( + turn_context, task_module_request + ) + class NotImplementedAdapter(BotAdapter): async def delete_activity( @@ -200,19 +251,17 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): raise NotImplementedError() + class TestTeamsActivityHandler(aiounittest.AsyncTestCase): async def test_on_teams_channel_created_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.conversation_update, - channel_data = { - "eventType": "channelCreated", - "channel": { - "id": "asdfqwerty", - "name" : "new_channel" - } - }, - channel_id = Channels.ms_teams + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelCreated", + "channel": {"id": "asdfqwerty", "name": "new_channel"}, + }, + channel_id=Channels.ms_teams, ) turn_context = TurnContext(NotImplementedAdapter(), activity) @@ -225,19 +274,16 @@ async def test_on_teams_channel_created_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_channel_created_activity" - + async def test_on_teams_channel_renamed_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.conversation_update, - channel_data = { - "eventType": "channelRenamed", - "channel": { - "id": "asdfqwerty", - "name" : "new_channel" - } - }, - channel_id = Channels.ms_teams + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelRenamed", + "channel": {"id": "asdfqwerty", "name": "new_channel"}, + }, + channel_id=Channels.ms_teams, ) turn_context = TurnContext(NotImplementedAdapter(), activity) @@ -250,19 +296,16 @@ async def test_on_teams_channel_renamed_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_channel_renamed_activity" - + async def test_on_teams_channel_deleted_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.conversation_update, - channel_data = { - "eventType": "channelDeleted", - "channel": { - "id": "asdfqwerty", - "name" : "new_channel" - } - }, - channel_id = Channels.ms_teams + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelDeleted", + "channel": {"id": "asdfqwerty", "name": "new_channel"}, + }, + channel_id=Channels.ms_teams, ) turn_context = TurnContext(NotImplementedAdapter(), activity) @@ -275,19 +318,16 @@ async def test_on_teams_channel_deleted_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_channel_deleted_activity" - + async def test_on_teams_team_renamed_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.conversation_update, - channel_data = { - "eventType": "teamRenamed", - "team": { - "id": "team_id_1", - "name" : "new_team_name" - } - }, - channel_id = Channels.ms_teams + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamRenamed", + "team": {"id": "team_id_1", "name": "new_team_name"}, + }, + channel_id=Channels.ms_teams, ) turn_context = TurnContext(NotImplementedAdapter(), activity) @@ -300,16 +340,21 @@ async def test_on_teams_team_renamed_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_team_renamed_activity" - + async def test_on_teams_members_added_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.conversation_update, - channel_data = { - "eventType": "teamMemberAdded" - }, - members_added = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], - channel_id = Channels.ms_teams + type=ActivityTypes.conversation_update, + channel_data={"eventType": "teamMemberAdded"}, + members_added=[ + ChannelAccount( + id="123", + name="test_user", + aad_object_id="asdfqwerty", + role="tester", + ) + ], + channel_id=Channels.ms_teams, ) turn_context = TurnContext(NotImplementedAdapter(), activity) @@ -322,16 +367,21 @@ async def test_on_teams_members_added_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_members_added_activity" - + async def test_on_teams_members_removed_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.conversation_update, - channel_data = { - "eventType": "teamMemberRemoved" - }, - members_removed = [ChannelAccount(id="123", name="test_user", aad_object_id="asdfqwerty", role="tester")], - channel_id = Channels.ms_teams + type=ActivityTypes.conversation_update, + channel_data={"eventType": "teamMemberRemoved"}, + members_removed=[ + ChannelAccount( + id="123", + name="test_user", + aad_object_id="asdfqwerty", + role="tester", + ) + ], + channel_id=Channels.ms_teams, ) turn_context = TurnContext(NotImplementedAdapter(), activity) @@ -344,13 +394,10 @@ async def test_on_teams_members_removed_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_members_removed_activity" - + async def test_on_signin_verify_state(self): - #arrange - activity = Activity( - type = ActivityTypes.invoke, - name = "signin/verifyState" - ) + # arrange + activity = Activity(type=ActivityTypes.invoke, name="signin/verifyState") turn_context = TurnContext(SimpleAdapter(), activity) @@ -362,13 +409,13 @@ async def test_on_signin_verify_state(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_signin_verify_state" - + async def test_on_file_consent_accept_activity(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.invoke, - name = "fileConsent/invoke", - value = {"action" : "accept"} + type=ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -382,13 +429,13 @@ async def test_on_file_consent_accept_activity(self): assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_file_consent" assert bot.record[2] == "on_teams_file_consent_accept_activity" - + async def test_on_file_consent_decline_activity(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "fileConsent/invoke", - value = {"action" : "decline"} + type=ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -402,13 +449,13 @@ async def test_on_file_consent_decline_activity(self): assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_file_consent" assert bot.record[2] == "on_teams_file_consent_decline_activity" - + async def test_on_file_consent_bad_action_activity(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "fileConsent/invoke", - value = {"action" : "bad_action"} + type=ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "bad_action"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -421,16 +468,13 @@ async def test_on_file_consent_bad_action_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_file_consent" - + async def test_on_teams_o365_connector_card_action(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.invoke, - name = "actionableMessage/executeAction", - value = { - "body": "body_here", - "actionId": "action_id_here" - } + type=ActivityTypes.invoke, + name="actionableMessage/executeAction", + value={"body": "body_here", "actionId": "action_id_here"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -443,15 +487,13 @@ async def test_on_teams_o365_connector_card_action(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_o365_connector_card_action" - + async def test_on_app_based_link_query(self): - #arrange + # arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/query", - value = { - "url": "http://www.test.com" - } + type=ActivityTypes.invoke, + name="composeExtension/query", + value={"url": "http://www.test.com"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -464,21 +506,21 @@ async def test_on_app_based_link_query(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_query" - + async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/submitAction", - value = { - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "comamndId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "edit", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), - } + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "edit", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -491,22 +533,25 @@ async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(se assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_edit_activity" + assert ( + bot.record[2] + == "on_teams_messaging_extension_bot_message_preview_edit_activity" + ) async def test_on_teams_messaging_extension_bot_message_send_activity(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/submitAction", - value = { - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "comamndId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "send", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), - } + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "send", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -520,21 +565,23 @@ async def test_on_teams_messaging_extension_bot_message_send_activity(self): assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" assert bot.record[2] == "on_teams_messaging_extension_bot_message_send_activity" - - async def test_on_teams_messaging_extension_bot_message_send_activity_with_none(self): - # Arrange + + async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( + self, + ): + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/submitAction", - value = { - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "comamndId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": None, - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), - } + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": None, + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -548,21 +595,23 @@ async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" assert bot.record[2] == "on_teams_messaging_extension_submit_action_activity" - - async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty_string(self): - # Arrange + + async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty_string( + self, + ): + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/submitAction", - value = { - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "comamndId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), - } + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -576,21 +625,21 @@ async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" assert bot.record[2] == "on_teams_messaging_extension_submit_action_activity" - + async def test_on_teams_messaging_extension_fetch_task(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/fetchTask", - value = { - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "comamndId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "message_action", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), - } + type=ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "comamndId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "message_action", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -603,18 +652,18 @@ async def test_on_teams_messaging_extension_fetch_task(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_fetch_task" - + async def test_on_teams_messaging_extension_configuration_query_settings_url(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/querySettingUrl", - value = { - "comamndId": "test_command", - "parameters": [], - "messagingExtensionQueryOptions": {"skip": 1, "count": 1}, - "state": "state_string", - } + type=ActivityTypes.invoke, + name="composeExtension/querySettingUrl", + value={ + "comamndId": "test_command", + "parameters": [], + "messagingExtensionQueryOptions": {"skip": 1, "count": 1}, + "state": "state_string", + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -626,16 +675,17 @@ async def test_on_teams_messaging_extension_configuration_query_settings_url(sel # Assert assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_configuration_query_settings_url" - + assert ( + bot.record[1] + == "on_teams_messaging_extension_configuration_query_settings_url" + ) + async def test_on_teams_messaging_extension_configuration_setting(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/setting", - value = { - "key": "value" - } + type=ActivityTypes.invoke, + name="composeExtension/setting", + value={"key": "value"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -648,15 +698,13 @@ async def test_on_teams_messaging_extension_configuration_setting(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_configuration_setting" - + async def test_on_teams_messaging_extension_card_button_clicked(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "composeExtension/onCardButtonClicked", - value = { - "key": "value" - } + type=ActivityTypes.invoke, + name="composeExtension/onCardButtonClicked", + value={"key": "value"}, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -669,16 +717,16 @@ async def test_on_teams_messaging_extension_card_button_clicked(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_card_button_clicked" - + async def test_on_teams_task_module_fetch(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "task/fetch", - value = { - "data": {"key": "value"}, - "context": TaskModuleRequestContext().serialize() - } + type=ActivityTypes.invoke, + name="task/fetch", + value={ + "data": {"key": "value"}, + "context": TaskModuleRequestContext().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) @@ -691,16 +739,16 @@ async def test_on_teams_task_module_fetch(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_task_module_fetch" - + async def test_on_teams_task_module_submit(self): - # Arrange + # Arrange activity = Activity( - type = ActivityTypes.invoke, - name = "task/submit", - value = { - "data": {"key": "value"}, - "context": TaskModuleRequestContext().serialize() - } + type=ActivityTypes.invoke, + name="task/submit", + value={ + "data": {"key": "value"}, + "context": TaskModuleRequestContext().serialize(), + }, ) turn_context = TurnContext(SimpleAdapter(), activity) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index f348245af..e80cee5f9 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1544,7 +1544,7 @@ def __init__(self, **kwargs): self.given_name = kwargs.get("given_name", None) self.surname = kwargs.get("surname", None) self.email = kwargs.get("email", None) - self.userPrincipalName = kwargs.get("userPrincipalName", None) + self.user_principal_name = kwargs.get("userPrincipalName", None) class TeamsChannelData(Model): diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 7d7dcbdeb..0f3a075a6 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1132,7 +1132,7 @@ class O365ConnectorCardActionQuery(Model): def __init__(self, *, body: str = None, actionId: str = None, **kwargs) -> None: super(O365ConnectorCardActionQuery, self).__init__(**kwargs) self.body = body - # This is how it comes in from Teams + # This is how it comes in from Teams self.action_id = actionId From d9d0fe4a08edb640ce10ad396dcb1252cf407798 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 4 Dec 2019 10:07:42 -0800 Subject: [PATCH 074/616] removing init and adding simple_adapter --- libraries/botbuilder-core/tests/__init__.py | 3 - .../botbuilder-core/tests/teams/__init__.py | 0 .../tests/teams/simple_adapter.py | 60 +++++++++++++++++++ .../teams/test_teams_activity_handler.py | 2 +- 4 files changed, 61 insertions(+), 4 deletions(-) delete mode 100644 libraries/botbuilder-core/tests/__init__.py delete mode 100644 libraries/botbuilder-core/tests/teams/__init__.py create mode 100644 libraries/botbuilder-core/tests/teams/simple_adapter.py diff --git a/libraries/botbuilder-core/tests/__init__.py b/libraries/botbuilder-core/tests/__init__.py deleted file mode 100644 index 6fff60cf5..000000000 --- a/libraries/botbuilder-core/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .simple_adapter import SimpleAdapter - -__all__ = ["SimpleAdapter"] diff --git a/libraries/botbuilder-core/tests/teams/__init__.py b/libraries/botbuilder-core/tests/teams/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/botbuilder-core/tests/teams/simple_adapter.py b/libraries/botbuilder-core/tests/teams/simple_adapter.py new file mode 100644 index 000000000..a80fa29b3 --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/simple_adapter.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from typing import List +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import Activity, ConversationReference, ResourceResponse + + +class SimpleAdapter(BotAdapter): + # pylint: disable=unused-argument + + def __init__(self, call_on_send=None, call_on_update=None, call_on_delete=None): + super(SimpleAdapter, self).__init__() + self.test_aux = unittest.TestCase("__init__") + self._call_on_send = call_on_send + self._call_on_update = call_on_update + self._call_on_delete = call_on_delete + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + self.test_aux.assertIsNotNone( + reference, "SimpleAdapter.delete_activity: missing reference" + ) + if self._call_on_delete is not None: + self._call_on_delete(reference) + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + self.test_aux.assertIsNotNone( + activities, "SimpleAdapter.delete_activity: missing reference" + ) + self.test_aux.assertTrue( + len(activities) > 0, + "SimpleAdapter.send_activities: empty activities array.", + ) + + if self._call_on_send is not None: + self._call_on_send(activities) + responses = [] + + for activity in activities: + responses.append(ResourceResponse(id=activity.id)) + + return responses + + async def update_activity(self, context: TurnContext, activity: Activity): + self.test_aux.assertIsNotNone( + activity, "SimpleAdapter.update_activity: missing activity" + ) + if self._call_on_update is not None: + self._call_on_update(activity) + + return ResourceResponse(activity.id) + + async def process_request(self, activity, handler): + context = TurnContext(self, activity) + return self.run_pipeline(context, handler) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 06152f21b..c1485e090 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -24,7 +24,7 @@ TeamsChannelAccount, ) from botframework.connector import Channels -from .. import SimpleAdapter +from simple_adapter import SimpleAdapter class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): From a61dbe9f302e7a229af644989b21a28b74f077b8 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 4 Dec 2019 10:17:00 -0800 Subject: [PATCH 075/616] fixing black --- .../botbuilder-core/tests/teams/test_teams_activity_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index c1485e090..540d5742b 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -26,6 +26,7 @@ from botframework.connector import Channels from simple_adapter import SimpleAdapter + class TestingTeamsActivityHandler(TeamsActivityHandler): def __init__(self): self.record: List[str] = [] From ec70a70a057ef1458418e253198c219a8107e39f Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 4 Dec 2019 12:50:07 -0800 Subject: [PATCH 076/616] TeamsInfo and TeamsConnectorClient updates (#462) * add teams_info and updates * add roster scenario to test teamsinfo and connector calls * fix get_mentions * add teams_info and updates * add roster scenario to test teamsinfo and connector calls * fix get_mentions * fixing linting --- .../botbuilder/core/teams/__init__.py | 6 +- .../core/teams/teams_activity_handler.py | 2 + .../botbuilder/core/teams/teams_info.py | 118 ++++++++++++++++++ .../botbuilder/core/turn_context.py | 1 + .../botframework/connector/models/__init__.py | 1 + .../teams/operations/teams_operations.py | 14 +-- .../connector/teams/teams_connector_client.py | 2 +- scenarios/roster/README.md | 30 +++++ scenarios/roster/app.py | 92 ++++++++++++++ scenarios/roster/bots/__init__.py | 6 + scenarios/roster/bots/roster_bot.py | 66 ++++++++++ scenarios/roster/config.py | 13 ++ scenarios/roster/requirements.txt | 2 + scenarios/roster/teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../roster/teams_app_manifest/manifest.json | 42 +++++++ .../roster/teams_app_manifest/outline.png | Bin 0 -> 383 bytes 16 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/teams/teams_info.py create mode 100644 scenarios/roster/README.md create mode 100644 scenarios/roster/app.py create mode 100644 scenarios/roster/bots/__init__.py create mode 100644 scenarios/roster/bots/roster_bot.py create mode 100644 scenarios/roster/config.py create mode 100644 scenarios/roster/requirements.txt create mode 100644 scenarios/roster/teams_app_manifest/color.png create mode 100644 scenarios/roster/teams_app_manifest/manifest.json create mode 100644 scenarios/roster/teams_app_manifest/outline.png diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py index 6683b49a0..1b9242875 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py @@ -6,5 +6,9 @@ # -------------------------------------------------------------------------- from .teams_activity_handler import TeamsActivityHandler +from .teams_info import TeamsInfo -__all__ = ["TeamsActivityHandler"] +__all__ = [ + "TeamsActivityHandler", + "TeamsInfo", +] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 6ab5f3830..139f45859 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -358,6 +358,7 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar """ team_accounts_added = [] for member in members_added: + # TODO: fix this new_account_json = member.serialize() if "additional_properties" in new_account_json: del new_account_json["additional_properties"] @@ -385,6 +386,7 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- ): teams_members_removed = [] for member in members_removed: + # TODO: fix this new_account_json = member.serialize() if "additional_properties" in new_account_json: del new_account_json["additional_properties"] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py new file mode 100644 index 000000000..b547180d0 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core.turn_context import TurnContext +from botbuilder.schema.teams import ( + ChannelInfo, + TeamDetails, + TeamsChannelData, + TeamsChannelAccount, +) +from botframework.connector.aio import ConnectorClient +from botframework.connector.teams.teams_connector_client import TeamsConnectorClient + + +class TeamsInfo: + @staticmethod + def get_team_details(turn_context: TurnContext, team_id: str = "") -> TeamDetails: + if not team_id: + team_id = TeamsInfo.get_team_id(turn_context) + + if not team_id: + raise TypeError( + "TeamsInfo.get_team_details: method is only valid within the scope of MS Teams Team." + ) + + return TeamsInfo.get_teams_connector_client( + turn_context + ).teams.get_team_details(team_id) + + @staticmethod + def get_team_channels( + turn_context: TurnContext, team_id: str = "" + ) -> List[ChannelInfo]: + if not team_id: + team_id = TeamsInfo.get_team_id(turn_context) + + if not team_id: + raise TypeError( + "TeamsInfo.get_team_channels: method is only valid within the scope of MS Teams Team." + ) + + return ( + TeamsInfo.get_teams_connector_client(turn_context) + .teams.get_teams_channels(team_id) + .conversations + ) + + @staticmethod + async def get_team_members(turn_context: TurnContext, team_id: str = ""): + if not team_id: + team_id = TeamsInfo.get_team_id(turn_context) + + if not team_id: + raise TypeError( + "TeamsInfo.get_team_members: method is only valid within the scope of MS Teams Team." + ) + + return await TeamsInfo._get_members( + TeamsInfo._get_connector_client(turn_context), + turn_context.activity.conversation.id, + ) + + @staticmethod + async def get_members(turn_context: TurnContext) -> List[TeamsChannelAccount]: + team_id = TeamsInfo.get_team_id(turn_context) + if not team_id: + conversation_id = turn_context.activity.conversation.id + return await TeamsInfo._get_members( + TeamsInfo._get_connector_client(turn_context), conversation_id + ) + + return await TeamsInfo.get_team_members(turn_context, team_id) + + @staticmethod + def get_teams_connector_client(turn_context: TurnContext) -> TeamsConnectorClient: + connector_client = TeamsInfo._get_connector_client(turn_context) + return TeamsConnectorClient( + connector_client.config.credentials, turn_context.activity.service_url + ) + + # TODO: should have access to adapter's credentials + # return TeamsConnectorClient(turn_context.adapter._credentials, turn_context.activity.service_url) + + @staticmethod + def get_team_id(turn_context: TurnContext): + channel_data = TeamsChannelData(**turn_context.activity.channel_data) + if channel_data.team: + # urllib.parse.quote_plus( + return channel_data.team["id"] + return "" + + @staticmethod + def _get_connector_client(turn_context: TurnContext) -> ConnectorClient: + return turn_context.adapter.create_connector_client( + turn_context.activity.service_url + ) + + @staticmethod + async def _get_members( + connector_client: ConnectorClient, conversation_id: str + ) -> List[TeamsChannelAccount]: + if connector_client is None: + raise TypeError("TeamsInfo._get_members.connector_client: cannot be None.") + + if not conversation_id: + raise TypeError("TeamsInfo._get_members.conversation_id: cannot be empty.") + + teams_members = [] + members = await connector_client.conversations.get_conversation_members( + conversation_id + ) + + for member in members: + new_account_json = member.serialize() + teams_members.append(TeamsChannelAccount(**new_account_json)) + + return teams_members diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 8e26aa16c..a16eed975 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -377,4 +377,5 @@ def get_mentions(activity: Activity) -> List[Mention]: for entity in activity.entities: if entity.type.lower() == "mention": result.append(entity) + return result diff --git a/libraries/botframework-connector/botframework/connector/models/__init__.py b/libraries/botframework-connector/botframework/connector/models/__init__.py index 084330d3b..c03adc0f5 100644 --- a/libraries/botframework-connector/botframework/connector/models/__init__.py +++ b/libraries/botframework-connector/botframework/connector/models/__init__.py @@ -10,3 +10,4 @@ # -------------------------------------------------------------------------- from botbuilder.schema import * +from botbuilder.schema.teams import * diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py index 73d95e246..e6a2d909d 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -12,7 +12,7 @@ from msrest.pipeline import ClientRawResponse from msrest.exceptions import HttpOperationError -from .. import models +from ... import models class TeamsOperations(object): @@ -34,7 +34,7 @@ def __init__(self, client, config, serializer, deserializer): self.config = config - def fetch_channel_list( + def get_teams_channels( self, team_id, custom_headers=None, raw=False, **operation_config ): """Fetches channel list for a given team. @@ -55,7 +55,7 @@ def fetch_channel_list( :class:`HttpOperationError` """ # Construct URL - url = self.fetch_channel_list.metadata["url"] + url = self.get_teams_channels.metadata["url"] path_format_arguments = { "teamId": self._serialize.url("team_id", team_id, "str") } @@ -88,9 +88,9 @@ def fetch_channel_list( return deserialized - fetch_channel_list.metadata = {"url": "/v3/teams/{teamId}/conversations"} + get_teams_channels.metadata = {"url": "/v3/teams/{teamId}/conversations"} - def fetch_team_details( + def get_team_details( self, team_id, custom_headers=None, raw=False, **operation_config ): """Fetches details related to a team. @@ -111,7 +111,7 @@ def fetch_team_details( :class:`HttpOperationError` """ # Construct URL - url = self.fetch_team_details.metadata["url"] + url = self.get_team_details.metadata["url"] path_format_arguments = { "teamId": self._serialize.url("team_id", team_id, "str") } @@ -144,4 +144,4 @@ def fetch_team_details( return deserialized - fetch_team_details.metadata = {"url": "/v3/teams/{teamId}"} + get_team_details.metadata = {"url": "/v3/teams/{teamId}"} diff --git a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py index 9f75295b3..ccf935032 100644 --- a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py +++ b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py @@ -11,7 +11,7 @@ from msrest.service_client import SDKClient from msrest import Configuration, Serializer, Deserializer -from botbuilder.schema import models +from .. import models from .version import VERSION from .operations.teams_operations import TeamsOperations diff --git a/scenarios/roster/README.md b/scenarios/roster/README.md new file mode 100644 index 000000000..39f77916c --- /dev/null +++ b/scenarios/roster/README.md @@ -0,0 +1,30 @@ +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/roster/app.py b/scenarios/roster/app.py new file mode 100644 index 000000000..f491845be --- /dev/null +++ b/scenarios/roster/app.py @@ -0,0 +1,92 @@ +# 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 RosterBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = RosterBot() + +# 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/roster/bots/__init__.py b/scenarios/roster/bots/__init__.py new file mode 100644 index 000000000..a2e035b9f --- /dev/null +++ b/scenarios/roster/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .roster_bot import RosterBot + +__all__ = ["RosterBot"] diff --git a/scenarios/roster/bots/roster_bot.py b/scenarios/roster/bots/roster_bot.py new file mode 100644 index 000000000..0b5661f64 --- /dev/null +++ b/scenarios/roster/bots/roster_bot.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +class RosterBot(TeamsActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + "Hello and welcome!" + ) + + async def on_message_activity( + self, turn_context: TurnContext + ): + await turn_context.send_activity(MessageFactory.text(f"Echo: {turn_context.activity.text}")) + + text = turn_context.activity.text.strip() + if "members" in text: + await self._show_members(turn_context) + elif "channels" in text: + await self._show_channels(turn_context) + elif "details" in text: + await self._show_details(turn_context) + else: + await turn_context.send_activity(MessageFactory.text(f"Invalid command. Type \"Show channels\" to see a channel list. Type \"Show members\" to see a list of members in a team. Type \"Show details\" to see team information.")) + + async def _show_members( + self, turn_context: TurnContext + ): + members = await TeamsInfo.get_team_members(turn_context) + reply = MessageFactory.text(f"Total of {len(members)} members are currently in team") + await turn_context.send_activity(reply) + messages = list(map(lambda m: (f'{m.aad_object_id} --> {m.name} --> {m.user_principal_name}'), members)) + await self._send_in_batches(turn_context, messages) + + async def _show_channels( + self, turn_context: TurnContext + ): + channels = TeamsInfo.get_team_channels(turn_context) + reply = MessageFactory.text(f"Total of {len(channels)} channels are currently in team") + await turn_context.send_activity(reply) + messages = list(map(lambda c: (f'{c.id} --> {c.name}'), channels)) + await self._send_in_batches(turn_context, messages) + + async def _show_details(self, turn_context: TurnContext): + team_details = TeamsInfo.get_team_details(turn_context) + reply = MessageFactory.text(f"The team name is {team_details.name}. The team ID is {team_details.id}. The AADGroupID is {team_details.aad_group_id}.") + await turn_context.send_activity(reply) + + async def _send_in_batches(self, turn_context: TurnContext, messages: List[str]): + batch = [] + for msg in messages: + batch.append(msg) + if len(batch) == 10: + await turn_context.send_activity(MessageFactory.text("
".join(batch))) + batch = [] + + if len(batch) > 0: + await turn_context.send_activity(MessageFactory.text("
".join(batch))) \ No newline at end of file diff --git a/scenarios/roster/config.py b/scenarios/roster/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/roster/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/roster/requirements.txt b/scenarios/roster/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/roster/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/roster/teams_app_manifest/color.png b/scenarios/roster/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZPx$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Thu, 5 Dec 2019 10:39:34 -0600 Subject: [PATCH 077/616] Find.find_choices would loop forever if Choice.synonyms was specified. --- libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find.py index 3cc951bb2..f7f5b3cab 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find.py @@ -54,7 +54,7 @@ def find_choices( synonyms.append(SortedValue(value=choice.action.title, index=index)) if choice.synonyms is not None: - for synonym in synonyms: + for synonym in choice.synonyms: synonyms.append(SortedValue(value=synonym, index=index)) def found_choice_constructor(value_model: ModelResult) -> ModelResult: From 2074655403b6fbcb7dc37af0776c4c1fb85e5133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 5 Dec 2019 15:57:54 -0800 Subject: [PATCH 078/616] Axsuarez/protocol test project (#464) * Skills Lower layers * return serialized response in controller * Updates to controller * exporting integration module on core * pylint: exporting integration module on core * Protocol test working Python to Python * black: Protocol test working Python to Python * Squash commit: Protocol layer with auth, unit testing pending. --- .../botbuilder/core/bot_framework_adapter.py | 79 ++- .../botbuilder/core/integration/__init__.py | 16 + .../integration/aiohttp_channel_service.py | 175 +++++++ .../integration/bot_framework_http_client.py | 123 +++++ .../integration/channel_service_handler.py | 460 ++++++++++++++++++ libraries/botbuilder-core/setup.py | 1 + .../tests/test_bot_framework_adapter.py | 14 +- .../botframework/connector/auth/__init__.py | 2 +- .../connector/auth/channel_validation.py | 26 +- .../botframework/connector/auth/constants.py | 31 -- .../connector/auth/emulator_validation.py | 10 +- .../connector/auth/jwt_token_validation.py | 5 +- .../auth/microsoft_app_credentials.py | 39 +- .../tests/test_microsoft_app_credentials.py | 4 +- samples/experimental/test-protocol/app.py | 55 +++ samples/experimental/test-protocol/config.py | 18 + .../test-protocol/routing_handler.py | 134 +++++ .../test-protocol/routing_id_factory.py | 22 + 18 files changed, 1122 insertions(+), 92 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/integration/__init__.py create mode 100644 libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py create mode 100644 libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py create mode 100644 libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py delete mode 100644 libraries/botframework-connector/botframework/connector/auth/constants.py create mode 100644 samples/experimental/test-protocol/app.py create mode 100644 samples/experimental/test-protocol/config.py create mode 100644 samples/experimental/test-protocol/routing_handler.py create mode 100644 samples/experimental/test-protocol/routing_id_factory.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a7727956d..bf3443c6e 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -18,13 +18,17 @@ from botframework.connector import Channels, EmulatorApiClient from botframework.connector.aio import ConnectorClient from botframework.connector.auth import ( + AuthenticationConfiguration, AuthenticationConstants, ChannelValidation, + ChannelProvider, + ClaimsIdentity, GovernmentChannelValidation, GovernmentConstants, MicrosoftAppCredentials, JwtTokenValidation, SimpleCredentialProvider, + SkillValidation, ) from botframework.connector.token_api import TokenApiClient from botframework.connector.token_api.models import TokenStatus @@ -37,6 +41,7 @@ USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" OAUTH_ENDPOINT = "https://api.botframework.com" US_GOV_OAUTH_ENDPOINT = "https://api.botframework.azure.us" +BOT_IDENTITY_KEY = "BotIdentity" class TokenExchangeState(Model): @@ -72,6 +77,8 @@ def __init__( oauth_endpoint: str = None, open_id_metadata: str = None, channel_service: str = None, + channel_provider: ChannelProvider = None, + auth_configuration: AuthenticationConfiguration = None, ): self.app_id = app_id self.app_password = app_password @@ -79,6 +86,8 @@ def __init__( self.oauth_endpoint = oauth_endpoint self.open_id_metadata = open_id_metadata self.channel_service = channel_service + self.channel_provider = channel_provider + self.auth_configuration = auth_configuration or AuthenticationConfiguration() class BotFrameworkAdapter(BotAdapter, UserTokenProvider): @@ -90,6 +99,7 @@ def __init__(self, settings: BotFrameworkAdapterSettings): self.settings.channel_service = self.settings.channel_service or os.environ.get( AuthenticationConstants.CHANNEL_SERVICE ) + self.settings.open_id_metadata = ( self.settings.open_id_metadata or os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) @@ -163,7 +173,7 @@ async def create_conversation( # Create conversation parameters = ConversationParameters(bot=reference.bot) - client = self.create_connector_client(reference.service_url) + client = await self.create_connector_client(reference.service_url) # Mix in the tenant ID if specified. This is required for MS Teams. if reference.conversation is not None and reference.conversation.tenant_id: @@ -207,8 +217,9 @@ async def process_activity(self, req, auth_header: str, logic: Callable): activity = await self.parse_request(req) auth_header = auth_header or "" - await self.authenticate_request(activity, auth_header) + identity = await self.authenticate_request(activity, auth_header) context = self.create_context(activity) + context.turn_state[BOT_IDENTITY_KEY] = identity # Fix to assign tenant_id from channelData to Conversation.tenant_id. # MS Teams currently sends the tenant ID in channelData and the correct behavior is to expose @@ -228,7 +239,9 @@ async def process_activity(self, req, auth_header: str, logic: Callable): return await self.run_pipeline(context, logic) - async def authenticate_request(self, request: Activity, auth_header: str): + async def authenticate_request( + self, request: Activity, auth_header: str + ) -> ClaimsIdentity: """ Allows for the overriding of authentication in unit tests. :param request: @@ -240,11 +253,14 @@ async def authenticate_request(self, request: Activity, auth_header: str): auth_header, self._credential_provider, self.settings.channel_service, + self.settings.auth_configuration, ) if not claims.is_authenticated: raise Exception("Unauthorized Access. Request is not authorized") + return claims + def create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. @@ -306,7 +322,8 @@ async def update_activity(self, context: TurnContext, activity: Activity): :return: """ try: - client = self.create_connector_client(activity.service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(activity.service_url, identity) return await client.conversations.update_activity( activity.conversation.id, activity.id, activity ) @@ -324,7 +341,8 @@ async def delete_activity( :return: """ try: - client = self.create_connector_client(reference.service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(reference.service_url, identity) await client.conversations.delete_activity( reference.conversation.id, reference.activity_id ) @@ -365,7 +383,10 @@ async def send_activities( "BotFrameworkAdapter.send_activity(): conversation.id can not be None." ) - client = self.create_connector_client(activity.service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client( + activity.service_url, identity + ) if activity.type == "trace" and activity.channel_id != "emulator": pass elif activity.reply_to_id: @@ -409,7 +430,8 @@ async def delete_conversation_member( ) service_url = context.activity.service_url conversation_id = context.activity.conversation.id - client = self.create_connector_client(service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(service_url, identity) return await client.conversations.delete_conversation_member( conversation_id, member_id ) @@ -446,7 +468,8 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): ) service_url = context.activity.service_url conversation_id = context.activity.conversation.id - client = self.create_connector_client(service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(service_url, identity) return await client.conversations.get_activity_members( conversation_id, activity_id ) @@ -474,7 +497,8 @@ async def get_conversation_members(self, context: TurnContext): ) service_url = context.activity.service_url conversation_id = context.activity.conversation.id - client = self.create_connector_client(service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(service_url, identity) return await client.conversations.get_conversation_members(conversation_id) except Exception as error: raise error @@ -488,7 +512,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :param continuation_token: :return: """ - client = self.create_connector_client(service_url) + client = await self.create_connector_client(service_url) return await client.conversations.get_conversations(continuation_token) async def get_user_token( @@ -595,13 +619,44 @@ async def get_aad_tokens( user_id, connection_name, context.activity.channel_id, resource_urls ) - def create_connector_client(self, service_url: str) -> ConnectorClient: + async def create_connector_client( + self, service_url: str, identity: ClaimsIdentity = None + ) -> ConnectorClient: """ Allows for mocking of the connector client in unit tests. :param service_url: + :param identity: :return: """ - client = ConnectorClient(self._credentials, base_url=service_url) + if identity: + bot_app_id_claim = identity.claims.get( + AuthenticationConstants.AUDIENCE_CLAIM + ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) + + credentials = None + if bot_app_id_claim and SkillValidation.is_skill_claim(identity.claims): + scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) + + password = await self._credential_provider.get_app_password( + bot_app_id_claim + ) + credentials = MicrosoftAppCredentials( + bot_app_id_claim, password, oauth_scope=scope + ) + if ( + self.settings.channel_provider + and self.settings.channel_provider.is_government() + ): + credentials.oauth_endpoint = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + ) + credentials.oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + else: + credentials = self._credentials + + client = ConnectorClient(credentials, base_url=service_url) client.config.add_user_agent(USER_AGENT) return client diff --git a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py new file mode 100644 index 000000000..3a579402b --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py @@ -0,0 +1,16 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .aiohttp_channel_service import aiohttp_channel_service_routes +from .bot_framework_http_client import BotFrameworkHttpClient +from .channel_service_handler import ChannelServiceHandler + +__all__ = [ + "aiohttp_channel_service_routes", + "BotFrameworkHttpClient", + "ChannelServiceHandler", +] diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py new file mode 100644 index 000000000..d61c0f0eb --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import json +from typing import List, Union, Type + +from aiohttp.web import RouteTableDef, Request, Response +from msrest.serialization import Model +from botbuilder.schema import ( + Activity, + AttachmentData, + ConversationParameters, + Transcript, +) + +from .channel_service_handler import ChannelServiceHandler + + +async def deserialize_from_body( + request: Request, target_model: Type[Model] +) -> Activity: + if "application/json" in request.headers["Content-Type"]: + body = await request.json() + else: + return Response(status=415) + + return target_model().deserialize(body) + + +def get_serialized_response(model_or_list: Union[Model, List[Model]]) -> Response: + if isinstance(model_or_list, Model): + json_obj = model_or_list.serialize() + else: + json_obj = [model.serialize() for model in model_or_list] + + return Response(body=json.dumps(json_obj), content_type="application/json") + + +def aiohttp_channel_service_routes( + handler: ChannelServiceHandler, base_url: str = "" +) -> RouteTableDef: + # pylint: disable=unused-variable + routes = RouteTableDef() + + @routes.post(base_url + "/v3/conversations/{conversation_id}/activities") + async def send_to_conversation(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_send_to_conversation( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.post( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def reply_to_activity(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_reply_to_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.put( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def update_activity(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_update_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.delete( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def delete_activity(request: Request): + await handler.handle_delete_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + ) + + return Response() + + @routes.get( + base_url + + "/v3/conversations/{conversation_id}/activities/{activity_id}/members" + ) + async def get_activity_members(request: Request): + result = await handler.handle_get_activity_members( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/") + async def create_conversation(request: Request): + conversation_parameters = deserialize_from_body(request, ConversationParameters) + result = await handler.handle_create_conversation( + request.headers.get("Authorization"), conversation_parameters + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/") + async def get_conversation(request: Request): + # TODO: continuation token? + result = await handler.handle_get_conversations( + request.headers.get("Authorization") + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/v3/conversations/{conversation_id}/members") + async def get_conversation_members(request: Request): + result = await handler.handle_get_conversation_members( + request.headers.get("Authorization"), request.match_info["conversation_id"], + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/v3/conversations/{conversation_id}/pagedmembers") + async def get_conversation_paged_members(request: Request): + # TODO: continuation token? page size? + result = await handler.handle_get_conversation_paged_members( + request.headers.get("Authorization"), request.match_info["conversation_id"], + ) + + return get_serialized_response(result) + + @routes.delete(base_url + "/v3/conversations/{conversation_id}/members/{member_id}") + async def delete_conversation_member(request: Request): + result = await handler.handle_delete_conversation_member( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["member_id"], + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/v3/conversations/{conversation_id}/activities/history") + async def send_conversation_history(request: Request): + transcript = deserialize_from_body(request, Transcript) + result = await handler.handle_send_conversation_history( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + transcript, + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/v3/conversations/{conversation_id}/attachments") + async def upload_attachment(request: Request): + attachment_data = deserialize_from_body(request, AttachmentData) + result = await handler.handle_upload_attachment( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + attachment_data, + ) + + return get_serialized_response(result) + + return routes diff --git a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py new file mode 100644 index 000000000..52a13230b --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +from typing import Dict +from logging import Logger +import aiohttp + +from botbuilder.core import InvokeResponse +from botbuilder.schema import Activity +from botframework.connector.auth import ( + ChannelProvider, + CredentialProvider, + GovernmentConstants, + MicrosoftAppCredentials, +) + + +class BotFrameworkHttpClient: + + """ + A skill host adapter implements API to forward activity to a skill and + implements routing ChannelAPI calls from the Skill up through the bot/adapter. + """ + + INVOKE_ACTIVITY_NAME = "SkillEvents.ChannelApiInvoke" + _BOT_IDENTITY_KEY = "BotIdentity" + _APP_CREDENTIALS_CACHE: Dict[str, MicrosoftAppCredentials] = {} + + def __init__( + self, + credential_provider: CredentialProvider, + channel_provider: ChannelProvider = None, + logger: Logger = None, + ): + if not credential_provider: + raise TypeError("credential_provider can't be None") + + self._credential_provider = credential_provider + self._channel_provider = channel_provider + self._logger = logger + self._session = aiohttp.ClientSession() + + async def post_activity( + self, + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, + conversation_id: str, + activity: Activity, + ) -> InvokeResponse: + app_credentials = await self._get_app_credentials(from_bot_id, to_bot_id) + + if not app_credentials: + raise RuntimeError("Unable to get appCredentials to connect to the skill") + + # Get token for the skill call + token = ( + app_credentials.get_access_token() + if app_credentials.microsoft_app_id + else None + ) + + # Capture current activity settings before changing them. + # TODO: DO we need to set the activity ID? (events that are created manually don't have it). + original_conversation_id = activity.conversation.id + original_service_url = activity.service_url + + try: + activity.conversation.id = conversation_id + activity.service_url = service_url + + headers_dict = { + "Content-type": "application/json; charset=utf-8", + } + if token: + headers_dict.update( + {"Authorization": f"Bearer {token}",} + ) + + json_content = json.dumps(activity.serialize()) + resp = await self._session.post( + to_url, data=json_content.encode("utf-8"), headers=headers_dict, + ) + resp.raise_for_status() + data = (await resp.read()).decode() + content = json.loads(data) if data else None + + if content: + return InvokeResponse(status=resp.status_code, body=content) + + finally: + # Restore activity properties. + activity.conversation.id = original_conversation_id + activity.service_url = original_service_url + + async def _get_app_credentials( + self, app_id: str, oauth_scope: str + ) -> MicrosoftAppCredentials: + if not app_id: + return MicrosoftAppCredentials(None, None) + + cache_key = f"{app_id}{oauth_scope}" + app_credentials = BotFrameworkHttpClient._APP_CREDENTIALS_CACHE.get(cache_key) + + if app_credentials: + return app_credentials + + app_password = await self._credential_provider.get_app_password(app_id) + app_credentials = MicrosoftAppCredentials( + app_id, app_password, oauth_scope=oauth_scope + ) + if self._channel_provider and self._channel_provider.is_government(): + app_credentials.oauth_endpoint = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + ) + app_credentials.oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + BotFrameworkHttpClient._APP_CREDENTIALS_CACHE[cache_key] = app_credentials + return app_credentials diff --git a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py new file mode 100644 index 000000000..4b9222de7 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py @@ -0,0 +1,460 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.schema import ( + Activity, + AttachmentData, + ChannelAccount, + ConversationParameters, + ConversationsResult, + ConversationResourceResponse, + PagedMembersResult, + ResourceResponse, + Transcript, +) + +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, + JwtTokenValidation, +) + + +class ChannelServiceHandler: + """ + Initializes a new instance of the class, + using a credential provider. + """ + + def __init__( + self, + credential_provider: CredentialProvider, + auth_config: AuthenticationConfiguration, + channel_provider: ChannelProvider = None, + ): + if not credential_provider: + raise TypeError("credential_provider can't be None") + + if not auth_config: + raise TypeError("auth_config can't be None") + + self._credential_provider = credential_provider + self._auth_config = auth_config + self._channel_provider = channel_provider + + async def handle_send_to_conversation( + self, auth_header, conversation_id, activity + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_send_to_conversation( + claims_identity, conversation_id, activity + ) + + async def handle_reply_to_activity( + self, auth_header, conversation_id, activity_id, activity + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_reply_to_activity( + claims_identity, conversation_id, activity_id, activity + ) + + async def handle_update_activity( + self, auth_header, conversation_id, activity_id, activity + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_update_activity( + claims_identity, conversation_id, activity_id, activity + ) + + async def handle_delete_activity(self, auth_header, conversation_id, activity_id): + claims_identity = await self._authenticate(auth_header) + await self.on_delete_activity(claims_identity, conversation_id, activity_id) + + async def handle_get_activity_members( + self, auth_header, conversation_id, activity_id + ) -> List[ChannelAccount]: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_activity_members( + claims_identity, conversation_id, activity_id + ) + + async def handle_create_conversation( + self, auth_header, parameters: ConversationParameters + ) -> ConversationResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_create_conversation(claims_identity, parameters) + + async def handle_get_conversations( + self, auth_header, continuation_token: str = "" + ) -> ConversationsResult: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversations(claims_identity, continuation_token) + + async def handle_get_conversation_members( + self, auth_header, conversation_id + ) -> List[ChannelAccount]: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversation_members(claims_identity, conversation_id) + + async def handle_get_conversation_paged_members( + self, + auth_header, + conversation_id, + page_size: int = 0, + continuation_token: str = "", + ) -> PagedMembersResult: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversation_paged_members( + claims_identity, conversation_id, page_size, continuation_token + ) + + async def handle_delete_conversation_member( + self, auth_header, conversation_id, member_id + ): + claims_identity = await self._authenticate(auth_header) + await self.on_delete_conversation_member( + claims_identity, conversation_id, member_id + ) + + async def handle_send_conversation_history( + self, auth_header, conversation_id, transcript: Transcript + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_send_conversation_history( + claims_identity, conversation_id, transcript + ) + + async def handle_upload_attachment( + self, auth_header, conversation_id, attachment_upload: AttachmentData + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_upload_attachment( + claims_identity, conversation_id, attachment_upload + ) + + async def on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + """ + get_conversations() API for Skill + + List the Conversations in which this bot has participated. + + GET from this method with a skip token + + The return value is a ConversationsResult, which contains an array of + ConversationMembers and a skip token. If the skip token is not empty, then + there are further values to be returned. Call this method again with the + returned token to get more values. + + Each ConversationMembers object contains the ID of the conversation and an + array of ChannelAccounts that describe the members of the conversation. + + :param claims_identity: + :param conversation_id: + :param continuation_token: + :return: + """ + raise NotImplementedError() + + async def on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + """ + create_conversation() API for Skill + + Create a new Conversation. + + POST to this method with a + * Bot being the bot creating the conversation + * IsGroup set to true if this is not a direct message (default is false) + * Array containing the members to include in the conversation + + The return value is a ResourceResponse which contains a conversation id + which is suitable for use + in the message payload and REST API uris. + + Most channels only support the semantics of bots initiating a direct + message conversation. An example of how to do that would be: + + var resource = await connector.conversations.CreateConversation(new + ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { new + ChannelAccount("user1") } ); + await connect.Conversations.SendToConversationAsync(resource.Id, new + Activity() ... ) ; + + end. + + :param claims_identity: + :param parameters: + :return: + """ + raise NotImplementedError() + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + """ + send_to_conversation() API for Skill + + This method allows you to send an activity to the end of a conversation. + + This is slightly different from ReplyToActivity(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + + :param claims_identity: + :param conversation_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_send_conversation_history( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + transcript: Transcript, + ) -> ResourceResponse: + """ + send_conversation_history() API for Skill. + + This method allows you to upload the historic activities to the + conversation. + + Sender must ensure that the historic activities have unique ids and + appropriate timestamps. The ids are used by the client to deal with + duplicate activities and the timestamps are used by the client to render + the activities in the right order. + + :param claims_identity: + :param conversation_id: + :param transcript: + :return: + """ + raise NotImplementedError() + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + """ + update_activity() API for Skill. + + Edit an existing activity. + + Some channels allow you to edit an existing activity to reflect the new + state of a bot conversation. + + For example, you can remove buttons after someone has clicked "Approve" + button. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + """ + reply_to_activity() API for Skill. + + This method allows you to reply to an activity. + + This is slightly different from SendToConversation(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + """ + delete_activity() API for Skill. + + Delete an existing activity. + + Some channels allow you to delete an existing activity, and if successful + this method will remove the specified activity. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :return: + """ + raise NotImplementedError() + + async def on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + """ + get_conversation_members() API for Skill. + + Enumerate the members of a conversation. + + This REST API takes a ConversationId and returns a list of ChannelAccount + objects representing the members of the conversation. + + :param claims_identity: + :param conversation_id: + :return: + """ + raise NotImplementedError() + + async def on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + """ + get_conversation_paged_members() API for Skill. + + Enumerate the members of a conversation one page at a time. + + This REST API takes a ConversationId. Optionally a page_size and/or + continuation_token can be provided. It returns a PagedMembersResult, which + contains an array + of ChannelAccounts representing the members of the conversation and a + continuation token that can be used to get more values. + + One page of ChannelAccounts records are returned with each call. The number + of records in a page may vary between channels and calls. The page_size + parameter can be used as + a suggestion. If there are no additional results the response will not + contain a continuation token. If there are no members in the conversation + the Members will be empty or not present in the response. + + A response to a request that has a continuation token from a prior request + may rarely return members from a previous request. + + :param claims_identity: + :param conversation_id: + :param page_size: + :param continuation_token: + :return: + """ + raise NotImplementedError() + + async def on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + """ + delete_conversation_member() API for Skill. + + Deletes a member from a conversation. + + This REST API takes a ConversationId and a memberId (of type string) and + removes that member from the conversation. If that member was the last + member + of the conversation, the conversation will also be deleted. + + :param claims_identity: + :param conversation_id: + :param member_id: + :return: + """ + raise NotImplementedError() + + async def on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + """ + get_activity_members() API for Skill. + + Enumerate the members of an activity. + + This REST API takes a ConversationId and a ActivityId, returning an array + of ChannelAccount objects representing the members of the particular + activity in the conversation. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :return: + """ + raise NotImplementedError() + + async def on_upload_attachment( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + attachment_upload: AttachmentData, + ) -> ResourceResponse: + """ + upload_attachment() API for Skill. + + Upload an attachment directly into a channel's blob storage. + + This is useful because it allows you to store data in a compliant store + when dealing with enterprises. + + The response is a ResourceResponse which contains an AttachmentId which is + suitable for using with the attachments API. + + :param claims_identity: + :param conversation_id: + :param attachment_upload: + :return: + """ + raise NotImplementedError() + + async def _authenticate(self, auth_header: str) -> ClaimsIdentity: + if not auth_header: + is_auth_disabled = ( + await self._credential_provider.is_authentication_disabled() + ) + if is_auth_disabled: + # In the scenario where Auth is disabled, we still want to have the + # IsAuthenticated flag set in the ClaimsIdentity. To do this requires + # adding in an empty claim. + return ClaimsIdentity({}, True) + + raise PermissionError() + + return await JwtTokenValidation.validate_auth_header( + auth_header, + self._credential_provider, + self._channel_provider, + "unknown", + auth_configuration=self._auth_config, + ) diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index f7ab3ae09..5b667ab06 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -35,6 +35,7 @@ "botbuilder.core", "botbuilder.core.adapters", "botbuilder.core.inspection", + "botbuilder.core.integration", ], install_requires=REQUIRES, classifiers=[ diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 6532b1e52..528bbf719 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -57,8 +57,8 @@ def __init__(self, settings=None): def aux_test_authenticate_request(self, request: Activity, auth_header: str): return super().authenticate_request(request, auth_header) - def aux_test_create_connector_client(self, service_url: str): - return super().create_connector_client(service_url) + async def aux_test_create_connector_client(self, service_url: str): + return await super().create_connector_client(service_url) async def authenticate_request(self, request: Activity, auth_header: str): self.tester.assertIsNotNone( @@ -71,7 +71,11 @@ async def authenticate_request(self, request: Activity, auth_header: str): ) return not self.fail_auth - def create_connector_client(self, service_url: str) -> ConnectorClient: + async def create_connector_client( + self, + service_url: str, + identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ) -> ConnectorClient: self.tester.assertIsNotNone( service_url, "create_connector_client() not passed service_url." ) @@ -181,9 +185,9 @@ async def aux_func(context): class TestBotFrameworkAdapter(aiounittest.AsyncTestCase): - def test_should_create_connector_client(self): + async def test_should_create_connector_client(self): adapter = AdapterUnderTest() - client = adapter.aux_test_create_connector_client(REFERENCE.service_url) + client = await adapter.aux_test_create_connector_client(REFERENCE.service_url) self.assertIsNotNone(client, "client not returned.") self.assertIsNotNone(client.conversations, "invalid client returned.") diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index 6d6b0b63c..8d90791bb 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -9,6 +9,7 @@ # regenerated. # -------------------------------------------------------------------------- # pylint: disable=missing-docstring +from .authentication_constants import * from .government_constants import * from .channel_provider import * from .simple_channel_provider import * @@ -19,5 +20,4 @@ from .channel_validation import * from .emulator_validation import * from .jwt_token_extractor import * -from .authentication_constants import * from .authentication_configuration import * diff --git a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py index ee0bc0315..7e9344c79 100644 --- a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py @@ -2,7 +2,7 @@ from .authentication_configuration import AuthenticationConfiguration from .verify_options import VerifyOptions -from .constants import Constants +from .authentication_constants import AuthenticationConstants from .jwt_token_extractor import JwtTokenExtractor from .claims_identity import ClaimsIdentity from .credential_provider import CredentialProvider @@ -18,7 +18,7 @@ class ChannelValidation: # TO BOT FROM CHANNEL: Token validation parameters when connecting to a bot # TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( - issuer=[Constants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], + issuer=[AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], # Audience validation takes place manually in code. audience=None, clock_tolerance=5 * 60, @@ -48,10 +48,8 @@ async def authenticate_channel_token_with_service_url( :return: A valid ClaimsIdentity. :raises Exception: """ - identity = await asyncio.ensure_future( - ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) + identity = await ChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration ) service_url_claim = identity.get_claim_value( @@ -87,19 +85,17 @@ async def authenticate_channel_token( metadata_endpoint = ( ChannelValidation.open_id_metadata_endpoint if ChannelValidation.open_id_metadata_endpoint - else Constants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL + else AuthenticationConstants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL ) token_extractor = JwtTokenExtractor( ChannelValidation.TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS, metadata_endpoint, - Constants.ALLOWED_SIGNING_ALGORITHMS, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, ) - identity = await asyncio.ensure_future( - token_extractor.get_identity_from_auth_header( - auth_header, channel_id, auth_configuration.required_endorsements - ) + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements ) return await ChannelValidation.validate_identity(identity, credentials) @@ -123,15 +119,15 @@ async def validate_identity( # Look for the "aud" claim, but only if issued from the Bot Framework if ( - identity.get_claim_value(Constants.ISSUER_CLAIM) - != Constants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) + != AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER ): # The relevant Audience Claim MUST be present. Not Authorized. raise Exception("Unauthorized. Audience Claim MUST be present.") # The AppId from the claim in the token must match the AppId specified by the developer. # Note that the Bot Framework uses the Audience claim ("aud") to pass the AppID. - aud_claim = identity.get_claim_value(Constants.AUDIENCE_CLAIM) + aud_claim = identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM) is_valid_app_id = await asyncio.ensure_future( credentials.is_valid_appid(aud_claim or "") ) diff --git a/libraries/botframework-connector/botframework/connector/auth/constants.py b/libraries/botframework-connector/botframework/connector/auth/constants.py deleted file mode 100644 index 03a95a908..000000000 --- a/libraries/botframework-connector/botframework/connector/auth/constants.py +++ /dev/null @@ -1,31 +0,0 @@ -class Constants: # pylint: disable=too-few-public-methods - """ - TO CHANNEL FROM BOT: Login URL prefix - """ - - TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX = "https://login.microsoftonline.com/" - - """ - TO CHANNEL FROM BOT: Login URL token endpoint path - """ - TO_CHANNEL_FROM_BOT_TOKEN_ENDPOINT_PATH = "/oauth2/v2.0/token" - - """ - TO CHANNEL FROM BOT: Default tenant from which to obtain a token for bot to channel communication - """ - DEFAULT_CHANNEL_AUTH_TENANT = "botframework.com" - - TO_BOT_FROM_CHANNEL_TOKEN_ISSUER = "https://api.botframework.com" - - TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL = ( - "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" - ) - TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL = ( - "https://login.botframework.com/v1/.well-known/openidconfiguration" - ) - - ALLOWED_SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512"] - - AUTHORIZED_PARTY = "azp" - AUDIENCE_CLAIM = "aud" - ISSUER_CLAIM = "iss" diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 2657e6222..12738f388 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -8,7 +8,7 @@ from .jwt_token_extractor import JwtTokenExtractor from .verify_options import VerifyOptions -from .constants import Constants +from .authentication_constants import AuthenticationConstants from .credential_provider import CredentialProvider from .claims_identity import ClaimsIdentity from .government_constants import GovernmentConstants @@ -112,13 +112,13 @@ async def authenticate_emulator_token( open_id_metadata = ( GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL if is_gov - else Constants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL + else AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL ) token_extractor = JwtTokenExtractor( EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS, open_id_metadata, - Constants.ALLOWED_SIGNING_ALGORITHMS, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, ) identity = await asyncio.ensure_future( @@ -158,7 +158,9 @@ async def authenticate_emulator_token( app_id = app_id_claim elif version_claim == "2.0": # Emulator, "2.0" puts the AppId in the "azp" claim. - app_authz_claim = identity.get_claim_value(Constants.AUTHORIZED_PARTY) + app_authz_claim = identity.get_claim_value( + AuthenticationConstants.AUTHORIZED_PARTY + ) if not app_authz_claim: # No claim around AppID. Not Authorized. raise Exception( diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index ef080c5d4..c4a0b26e3 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -27,6 +27,7 @@ async def authenticate_request( auth_header: str, credentials: CredentialProvider, channel_service_or_provider: Union[str, ChannelProvider] = "", + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: """Authenticates the request and sets the service url in the set of trusted urls. :param activity: The incoming Activity from the Bot Framework or the Emulator @@ -34,7 +35,8 @@ async def authenticate_request( :param auth_header: The Bearer token included as part of the request :type auth_header: str :param credentials: The set of valid credentials, such as the Bot Application ID - :param channel_service: String for the channel service + :param channel_service_or_provider: String for the channel service + :param auth_configuration: Authentication configuration :type credentials: CredentialProvider :raises Exception: @@ -55,6 +57,7 @@ async def authenticate_request( channel_service_or_provider, activity.channel_id, activity.service_url, + auth_configuration, ) # On the standard Auth path, we need to trust the URL that was incoming. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 317293ede..180fda6dd 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -1,12 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - from datetime import datetime, timedelta from urllib.parse import urlparse + +from adal import AuthenticationContext import requests from msrest.authentication import Authentication -from .constants import Constants +from .authentication_constants import AuthenticationConstants # TODO: Decide to move this to Constants or viceversa (when porting OAuth) AUTH_SETTINGS = { @@ -34,9 +35,9 @@ def __init__(self): def from_json(json_values): result = _OAuthResponse() try: - result.token_type = json_values["token_type"] - result.access_token = json_values["access_token"] - result.expires_in = json_values["expires_in"] + result.token_type = json_values["tokenType"] + result.access_token = json_values["accessToken"] + result.expires_in = json_values["expiresIn"] except KeyError: pass return result @@ -79,15 +80,16 @@ def __init__( tenant = ( channel_auth_tenant if channel_auth_tenant - else Constants.DEFAULT_CHANNEL_AUTH_TENANT + else AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT ) self.oauth_endpoint = ( - Constants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX - + tenant - + Constants.TO_CHANNEL_FROM_BOT_TOKEN_ENDPOINT_PATH + AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + tenant ) - self.oauth_scope = oauth_scope or AUTH_SETTINGS["refreshScope"] - self.token_cache_key = app_id + "-cache" + self.oauth_scope = ( + oauth_scope or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + ) + self.token_cache_key = app_id + "-cache" if app_id else None + self.authentication_context = AuthenticationContext(self.oauth_endpoint) # pylint: disable=arguments-differ def signed_session(self, session: requests.Session = None) -> requests.Session: @@ -140,19 +142,14 @@ def refresh_token(self) -> _OAuthResponse: """ returns: _OAuthResponse """ - options = { - "grant_type": "client_credentials", - "client_id": self.microsoft_app_id, - "client_secret": self.microsoft_app_password, - "scope": self.oauth_scope, - } - response = requests.post(self.oauth_endpoint, data=options) - response.raise_for_status() + token = self.authentication_context.acquire_token_with_client_credentials( + self.oauth_scope, self.microsoft_app_id, self.microsoft_app_password + ) - oauth_response = _OAuthResponse.from_json(response.json()) + oauth_response = _OAuthResponse.from_json(token) oauth_response.expiration_time = datetime.now() + timedelta( - seconds=(oauth_response.expires_in - 300) + seconds=(int(oauth_response.expires_in) - 300) ) return oauth_response diff --git a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py index 900fd927b..c276b8e48 100644 --- a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py +++ b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py @@ -7,7 +7,7 @@ class TestMicrosoftAppCredentials(aiounittest.AsyncTestCase): async def test_app_credentials(self): default_scope_case_1 = MicrosoftAppCredentials("some_app", "some_password") assert ( - AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER == default_scope_case_1.oauth_scope ) @@ -16,7 +16,7 @@ async def test_app_credentials(self): "some_app", "some_password", "some_tenant" ) assert ( - AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER == default_scope_case_2.oauth_scope ) diff --git a/samples/experimental/test-protocol/app.py b/samples/experimental/test-protocol/app.py new file mode 100644 index 000000000..e95d2f1be --- /dev/null +++ b/samples/experimental/test-protocol/app.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp import web +from aiohttp.web import Request, Response + +from botframework.connector.auth import AuthenticationConfiguration, SimpleCredentialProvider +from botbuilder.core.integration import BotFrameworkHttpClient, aiohttp_channel_service_routes +from botbuilder.schema import Activity + +from config import DefaultConfig +from routing_id_factory import RoutingIdFactory +from routing_handler import RoutingHandler + + +CONFIG = DefaultConfig() +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = BotFrameworkHttpClient(CREDENTIAL_PROVIDER) +AUTH_CONFIG = AuthenticationConfiguration() + +TO_URI = CONFIG.NEXT +SERVICE_URL = CONFIG.SERVICE_URL + +FACTORY = RoutingIdFactory() + +ROUTING_HANDLER = RoutingHandler(FACTORY, CREDENTIAL_PROVIDER, AUTH_CONFIG) + + +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + inbound_activity: Activity = Activity().deserialize(body) + + current_conversation_id = inbound_activity.conversation.id + current_service_url = inbound_activity.service_url + + next_conversation_id = FACTORY.create_skill_conversation_id(current_conversation_id, current_service_url) + + await CLIENT.post_activity(CONFIG.APP_ID, CONFIG.SKILL_APP_ID, TO_URI, SERVICE_URL, next_conversation_id, inbound_activity) + return Response(status=201) + +APP = web.Application() + +APP.router.add_post("/api/messages", messages) +APP.router.add_routes(aiohttp_channel_service_routes(ROUTING_HANDLER, "/api/connector")) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/test-protocol/config.py b/samples/experimental/test-protocol/config.py new file mode 100644 index 000000000..9a6ec94ea --- /dev/null +++ b/samples/experimental/test-protocol/config.py @@ -0,0 +1,18 @@ +#!/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 = 3428 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + NEXT = "http://localhost:3978/api/messages" + SERVICE_URL = "http://localhost:3428/api/connector" + SKILL_APP_ID = "" diff --git a/samples/experimental/test-protocol/routing_handler.py b/samples/experimental/test-protocol/routing_handler.py new file mode 100644 index 000000000..0de21123b --- /dev/null +++ b/samples/experimental/test-protocol/routing_handler.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core.integration import ChannelServiceHandler +from botbuilder.schema import ( + Activity, + ChannelAccount, + ConversationParameters, + ConversationResourceResponse, + ConversationsResult, + PagedMembersResult, + ResourceResponse +) +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, + MicrosoftAppCredentials +) + +from routing_id_factory import RoutingIdFactory + + +class RoutingHandler(ChannelServiceHandler): + def __init__( + self, + conversation_id_factory: RoutingIdFactory, + credential_provider: CredentialProvider, + auth_configuration: AuthenticationConfiguration, + channel_provider: ChannelProvider = None + ): + super().__init__(credential_provider, auth_configuration, channel_provider) + self._factory = conversation_id_factory + self._credentials = MicrosoftAppCredentials(None, None) + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.update_activity(back_conversation_id, activity.id, activity) + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + + return await connector_client.conversations.delete_activity(back_conversation_id, activity_id) + + async def on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + # This call will be used in Teams scenarios. + + # Scenario #1 - creating a thread with an activity in a Channel in a Team + # In order to know the serviceUrl in the case of Teams we would need to look it up based upon the + # TeamsChannelData. + # The inbound activity will contain the TeamsChannelData and so will the ConversationParameters. + + # Scenario #2 - starting a one on one conversation with a particular user + # - needs further analysis - + + back_service_url = "http://tempuri" + connector_client = self._get_connector_client(back_service_url) + + return await connector_client.conversations.create_conversation(parameters) + + async def on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + return await super().on_delete_conversation_member(claims_identity, conversation_id, member_id) + + async def on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + return await super().on_get_activity_members(claims_identity, conversation_id, activity_id) + + async def on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + return await super().on_get_conversation_members(claims_identity, conversation_id) + + async def on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + return await super().on_get_conversations(claims_identity, continuation_token) + + async def on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + return await super().on_get_conversation_paged_members(claims_identity, conversation_id, continuation_token) + + def _get_connector_client(self, service_url: str): + return ConnectorClient(self._credentials, service_url) diff --git a/samples/experimental/test-protocol/routing_id_factory.py b/samples/experimental/test-protocol/routing_id_factory.py new file mode 100644 index 000000000..c5ddb7524 --- /dev/null +++ b/samples/experimental/test-protocol/routing_id_factory.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from typing import Dict, Tuple + + +class RoutingIdFactory: + def __init__(self): + self._forward_x_ref: Dict[str, str] = {} + self._backward_x_ref: Dict[str, Tuple[str, str]] = {} + + def create_skill_conversation_id(self, conversation_id: str, service_url: str) -> str: + result = self._forward_x_ref.get(conversation_id, str(uuid4())) + + self._forward_x_ref[conversation_id] = result + self._backward_x_ref[result] = (conversation_id, service_url) + + return result + + def get_conversation_info(self, encoded_conversation_id) -> Tuple[str, str]: + return self._backward_x_ref[encoded_conversation_id] From 9a6193c4989f3928efc02d55759e5c3aeb50cf2b Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Thu, 5 Dec 2019 23:44:06 -0800 Subject: [PATCH 079/616] Adding Teams extension methods + tests (#470) * black + pylint * fixing copy/paste * adding whitespace to see if this unblocks coveralls * kicking off new build * black --- .../botbuilder/core/teams/__init__.py | 8 + .../core/teams/teams_activity_extensions.py | 40 +++++ .../tests/teams/test_teams_extension.py | 148 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py create mode 100644 libraries/botbuilder-core/tests/teams/test_teams_extension.py diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py index 1b9242875..d9d4847e8 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py @@ -7,8 +7,16 @@ from .teams_activity_handler import TeamsActivityHandler from .teams_info import TeamsInfo +from .teams_activity_extensions import ( + teams_get_channel_id, + teams_get_team_info, + teams_notify_user, +) __all__ = [ "TeamsActivityHandler", "TeamsInfo", + "teams_get_channel_id", + "teams_get_team_info", + "teams_notify_user", ] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py new file mode 100644 index 000000000..d47ab76e0 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -0,0 +1,40 @@ +from botbuilder.schema import Activity +from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo + + +def dummy(): + return 1 + + +def teams_get_channel_id(activity: Activity) -> str: + if not activity: + return None + + if activity.channel_data: + channel_data = TeamsChannelData().deserialize(activity.channel_data) + return channel_data.channel.id if channel_data.channel else None + + return None + + +def teams_get_team_info(activity: Activity) -> TeamInfo: + if not activity: + return None + + if activity.channel_data: + channel_data = TeamsChannelData().deserialize(activity.channel_data) + return channel_data.team + + return None + + +def teams_notify_user(activity: Activity): + if not activity: + return + + if not activity.channel_data: + activity.channel_data = {} + + channel_data = TeamsChannelData().deserialize(activity.channel_data) + channel_data.notification = NotificationInfo(alert=True) + activity.channel_data = channel_data diff --git a/libraries/botbuilder-core/tests/teams/test_teams_extension.py b/libraries/botbuilder-core/tests/teams/test_teams_extension.py new file mode 100644 index 000000000..8ac96a491 --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/test_teams_extension.py @@ -0,0 +1,148 @@ +import aiounittest + +from botbuilder.schema import Activity +from botbuilder.schema.teams import TeamInfo +from botbuilder.core.teams import ( + teams_get_channel_id, + teams_get_team_info, + teams_notify_user, +) + + +class TestTeamsActivityHandler(aiounittest.AsyncTestCase): + def test_teams_get_channel_id(self): + # Arrange + activity = Activity( + channel_data={"channel": {"id": "id123", "name": "channel_name"}} + ) + + # Act + result = teams_get_channel_id(activity) + + # Assert + assert result == "id123" + + def test_teams_get_channel_id_with_no_channel(self): + # Arrange + activity = Activity( + channel_data={"team": {"id": "id123", "name": "channel_name"}} + ) + + # Act + result = teams_get_channel_id(activity) + + # Assert + assert result is None + + def test_teams_get_channel_id_with_no_channel_id(self): + # Arrange + activity = Activity(channel_data={"team": {"name": "channel_name"}}) + + # Act + result = teams_get_channel_id(activity) + + # Assert + assert result is None + + def test_teams_get_channel_id_with_no_channel_data(self): + # Arrange + activity = Activity(type="type") + + # Act + result = teams_get_channel_id(activity) + + # Assert + assert result is None + + def test_teams_get_channel_id_with_none_activity(self): + # Arrange + activity = None + + # Act + result = teams_get_channel_id(activity) + + # Assert + assert result is None + + def test_teams_get_team_info(self): + # Arrange + activity = Activity( + channel_data={"team": {"id": "id123", "name": "channel_name"}} + ) + + # Act + result = teams_get_team_info(activity) + + # Assert + assert result == TeamInfo(id="id123", name="channel_name") + + def test_teams_get_team_info_with_no_channel_data(self): + # Arrange + activity = Activity(type="type") + + # Act + result = teams_get_team_info(activity) + + # Assert + assert result is None + + def test_teams_get_team_info_with_no_team_info(self): + # Arrange + activity = Activity(channel_data={"eventType": "eventType"}) + + # Act + result = teams_get_team_info(activity) + + # Assert + assert result is None + + def test_teams_get_team_info_with_none_activity(self): + # Arrange + activity = None + + # Act + result = teams_get_team_info(activity) + + # Assert + assert result is None + + def test_teams_notify_user(self): + # Arrange + activity = Activity(channel_data={"eventType": "eventType"}) + + # Act + teams_notify_user(activity) + + # Assert + assert activity.channel_data.notification.alert + + def test_teams_notify_user_with_no_activity(self): + # Arrange + activity = None + + # Act + teams_notify_user(activity) + + # Assert + assert activity is None + + def test_teams_notify_user_with_preexisting_notification(self): + # Arrange + activity = Activity(channel_data={"notification": {"alert": False}}) + + # Act + teams_notify_user(activity) + + # Assert + assert activity.channel_data.notification.alert + + def test_teams_notify_user_with_no_channel_data(self): + # Arrange + activity = Activity(id="id123") + + # Act + teams_notify_user(activity) + + # Assert + assert activity.channel_data.notification.alert + assert activity.id == "id123" From 9d0fef168f6aaa54f10a0f710cfed8c02a2d8302 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Thu, 5 Dec 2019 23:48:26 -0800 Subject: [PATCH 080/616] Cleaning up deserialization, models (#469) * initial set of deserialize * updating more deserializing updates * cleaning up models * black & linting --- .../core/teams/teams_activity_handler.py | 30 +++++++++++++------ .../teams/test_teams_activity_handler.py | 1 + .../botbuilder/schema/teams/_models.py | 3 +- .../botbuilder/schema/teams/_models_py3.py | 4 +-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 139f45859..3d8f1590c 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -61,20 +61,24 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "fileConsent/invoke": return await self.on_teams_file_consent( - turn_context, FileConsentCardResponse(**turn_context.activity.value) + turn_context, + FileConsentCardResponse().deserialize(turn_context.activity.value), ) if turn_context.activity.name == "actionableMessage/executeAction": await self.on_teams_o365_connector_card_action( turn_context, - O365ConnectorCardActionQuery(**turn_context.activity.value), + O365ConnectorCardActionQuery().deserialize( + turn_context.activity.value + ), ) return self._create_invoke_response() if turn_context.activity.name == "composeExtension/queryLink": return self._create_invoke_response( await self.on_teams_app_based_link_query( - turn_context, AppBasedLinkQuery(**turn_context.activity.value) + turn_context, + AppBasedLinkQuery().deserialize(turn_context.activity.value), ) ) @@ -82,7 +86,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_messaging_extension_query( turn_context, - MessagingExtensionQuery(**turn_context.activity.value), + MessagingExtensionQuery().deserialize( + turn_context.activity.value + ), ) ) @@ -113,7 +119,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_messaging_extension_configuration_query_settings_url( turn_context, - MessagingExtensionQuery(**turn_context.activity.value), + MessagingExtensionQuery().deserialize( + turn_context.activity.value + ), ) ) @@ -132,14 +140,16 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "task/fetch": return self._create_invoke_response( await self.on_teams_task_module_fetch( - turn_context, TaskModuleRequest(**turn_context.activity.value) + turn_context, + TaskModuleRequest().deserialize(turn_context.activity.value), ) ) if turn_context.activity.name == "task/submit": return self._create_invoke_response( await self.on_teams_task_module_submit( - turn_context, TaskModuleRequest(**turn_context.activity.value) + turn_context, + TaskModuleRequest().deserialize(turn_context.activity.value), ) ) @@ -280,7 +290,9 @@ async def on_teams_task_module_submit( # pylint: disable=unused-argument async def on_conversation_update_activity(self, turn_context: TurnContext): if turn_context.activity.channel_id == Channels.ms_teams: - channel_data = TeamsChannelData(**turn_context.activity.channel_data) + channel_data = TeamsChannelData().deserialize( + turn_context.activity.channel_data + ) if turn_context.activity.members_added: return await self.on_teams_members_added_dispatch_activity( turn_context.activity.members_added, channel_data.team, turn_context @@ -296,7 +308,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): if channel_data: if channel_data.event_type == "channelCreated": return await self.on_teams_channel_created_activity( - ChannelInfo(**channel_data.channel), + ChannelInfo().deserialize(channel_data.channel), channel_data.team, turn_context, ) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 540d5742b..102d39c3d 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -510,6 +510,7 @@ async def test_on_app_based_link_query(self): async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(self): # Arrange + activity = Activity( type=ActivityTypes.invoke, name="composeExtension/submitAction", diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index e80cee5f9..8cd32a677 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -10,6 +10,7 @@ # -------------------------------------------------------------------------- from msrest.serialization import Model +from botbuilder.schema import Activity class AppBasedLinkQuery(Model): @@ -1575,7 +1576,7 @@ def __init__(self, **kwargs): super(TeamsChannelData, self).__init__(**kwargs) self.channel = kwargs.get("channel", None) # doing camel case here since that's how the data comes in - self.event_type = kwargs.get("eventType", None) + self.event_type = kwargs.get("event_type", None) self.team = kwargs.get("team", None) self.notification = kwargs.get("notification", None) self.tenant = kwargs.get("tenant", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 0f3a075a6..4c42229c5 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -10,7 +10,7 @@ # -------------------------------------------------------------------------- from msrest.serialization import Model -from botbuilder.schema import Attachment, ChannelAccount +from botbuilder.schema import Activity, Attachment, ChannelAccount class TaskModuleRequest(Model): @@ -646,7 +646,7 @@ class MessagingExtensionAction(TaskModuleRequest): ~botframework.connector.teams.models.enum :param bot_activity_preview: :type bot_activity_preview: - list[~botframework.connector.teams.models.Activity] + list[~botframework.schema.models.Activity] :param message_payload: Message content sent as part of the command request. :type message_payload: From d166d4d5350ad8598347fa133ae2cba8a2c68eba Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Fri, 6 Dec 2019 09:29:42 -0800 Subject: [PATCH 081/616] Adding deserializer helper (#472) * adding deserializer, updating teams activity handler to use new deserializer * black updates --- .../botbuilder/core/teams/__init__.py | 2 + .../core/teams/teams_activity_handler.py | 37 +++++++++----- .../botbuilder/core/teams/teams_helper.py | 24 +++++++++ .../teams/test_teams_activity_handler.py | 29 +++++------ .../tests/teams/test_teams_helper.py | 51 +++++++++++++++++++ .../botbuilder/schema/teams/_models.py | 2 +- .../botbuilder/schema/teams/_models_py3.py | 4 +- 7 files changed, 119 insertions(+), 30 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py create mode 100644 libraries/botbuilder-core/tests/teams/test_teams_helper.py diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py index d9d4847e8..2e482ac88 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py @@ -7,6 +7,7 @@ from .teams_activity_handler import TeamsActivityHandler from .teams_info import TeamsInfo +from .teams_helper import deserializer_helper from .teams_activity_extensions import ( teams_get_channel_id, teams_get_team_info, @@ -14,6 +15,7 @@ ) __all__ = [ + "deserializer_helper", "TeamsActivityHandler", "TeamsInfo", "teams_get_channel_id", diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 3d8f1590c..f2f12f141 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -4,6 +4,7 @@ from http import HTTPStatus from botbuilder.schema import Activity, ActivityTypes, ChannelAccount from botbuilder.core.turn_context import TurnContext +from botbuilder.core.teams.teams_helper import deserializer_helper from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.schema.teams import ( AppBasedLinkQuery, @@ -62,14 +63,16 @@ async def on_invoke_activity(self, turn_context: TurnContext): if turn_context.activity.name == "fileConsent/invoke": return await self.on_teams_file_consent( turn_context, - FileConsentCardResponse().deserialize(turn_context.activity.value), + deserializer_helper( + FileConsentCardResponse, turn_context.activity.value + ), ) if turn_context.activity.name == "actionableMessage/executeAction": await self.on_teams_o365_connector_card_action( turn_context, - O365ConnectorCardActionQuery().deserialize( - turn_context.activity.value + deserializer_helper( + O365ConnectorCardActionQuery, turn_context.activity.value ), ) return self._create_invoke_response() @@ -78,7 +81,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_app_based_link_query( turn_context, - AppBasedLinkQuery().deserialize(turn_context.activity.value), + deserializer_helper( + AppBasedLinkQuery, turn_context.activity.value + ), ) ) @@ -86,8 +91,8 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_messaging_extension_query( turn_context, - MessagingExtensionQuery().deserialize( - turn_context.activity.value + deserializer_helper( + MessagingExtensionQuery, turn_context.activity.value ), ) ) @@ -103,7 +108,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_messaging_extension_submit_action_dispatch( turn_context, - MessagingExtensionAction(**turn_context.activity.value), + deserializer_helper( + MessagingExtensionAction, turn_context.activity.value + ), ) ) @@ -111,7 +118,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_messaging_extension_fetch_task( turn_context, - MessagingExtensionAction(**turn_context.activity.value), + deserializer_helper( + MessagingExtensionAction, turn_context.activity.value, + ), ) ) @@ -119,8 +128,8 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_messaging_extension_configuration_query_settings_url( turn_context, - MessagingExtensionQuery().deserialize( - turn_context.activity.value + deserializer_helper( + MessagingExtensionQuery, turn_context.activity.value ), ) ) @@ -141,7 +150,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_task_module_fetch( turn_context, - TaskModuleRequest().deserialize(turn_context.activity.value), + deserializer_helper( + TaskModuleRequest, turn_context.activity.value + ), ) ) @@ -149,7 +160,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): return self._create_invoke_response( await self.on_teams_task_module_submit( turn_context, - TaskModuleRequest().deserialize(turn_context.activity.value), + deserializer_helper( + TaskModuleRequest, turn_context.activity.value + ), ) ) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py new file mode 100644 index 000000000..2e11f2953 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -0,0 +1,24 @@ +from inspect import getmembers +from typing import Type +from enum import Enum + +from msrest.serialization import Model, Deserializer + +import botbuilder.schema as schema +import botbuilder.schema.teams as teams_schema + + +def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> Model: + dependencies = [ + schema_cls + for key, schema_cls in getmembers(schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) + ] + dependencies += [ + schema_cls + for key, schema_cls in getmembers(teams_schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) + ] + dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} + deserializer = Deserializer(dependencies_dict) + return deserializer(msrest_cls.__name__, dict_to_deserialize) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 102d39c3d..154e82345 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -517,11 +517,11 @@ async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(se value={ "data": {"key": "value"}, "context": {"theme": "dark"}, - "comamndId": "test_command", + "commandId": "test_command", "commandContext": "command_context_test", "botMessagePreviewAction": "edit", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), + "botActivityPreview": [{"id": "activity123"}], + "messagePayload": {"id": "payloadid"}, }, ) @@ -548,11 +548,11 @@ async def test_on_teams_messaging_extension_bot_message_send_activity(self): value={ "data": {"key": "value"}, "context": {"theme": "dark"}, - "comamndId": "test_command", + "commandId": "test_command", "commandContext": "command_context_test", "botMessagePreviewAction": "send", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), + "botActivityPreview": [{"id": "123"}], + "messagePayload": {"id": "abc"}, }, ) @@ -578,11 +578,11 @@ async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( value={ "data": {"key": "value"}, "context": {"theme": "dark"}, - "comamndId": "test_command", + "commandId": "test_command", "commandContext": "command_context_test", "botMessagePreviewAction": None, - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), + "botActivityPreview": [{"id": "test123"}], + "messagePayload": {"id": "payloadid123"}, }, ) @@ -608,7 +608,7 @@ async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty value={ "data": {"key": "value"}, "context": {"theme": "dark"}, - "comamndId": "test_command", + "commandId": "test_command", "commandContext": "command_context_test", "botMessagePreviewAction": "", "botActivityPreview": [Activity().serialize()], @@ -636,14 +636,13 @@ async def test_on_teams_messaging_extension_fetch_task(self): value={ "data": {"key": "value"}, "context": {"theme": "dark"}, - "comamndId": "test_command", + "commandId": "test_command", "commandContext": "command_context_test", "botMessagePreviewAction": "message_action", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), + "botActivityPreview": [{"id": "123"}], + "messagePayload": {"id": "abc123"}, }, ) - turn_context = TurnContext(SimpleAdapter(), activity) # Act @@ -661,7 +660,7 @@ async def test_on_teams_messaging_extension_configuration_query_settings_url(sel type=ActivityTypes.invoke, name="composeExtension/querySettingUrl", value={ - "comamndId": "test_command", + "commandId": "test_command", "parameters": [], "messagingExtensionQueryOptions": {"skip": 1, "count": 1}, "state": "state_string", diff --git a/libraries/botbuilder-core/tests/teams/test_teams_helper.py b/libraries/botbuilder-core/tests/teams/test_teams_helper.py new file mode 100644 index 000000000..21f074a73 --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/test_teams_helper.py @@ -0,0 +1,51 @@ +import aiounittest + +from botbuilder.core.teams.teams_helper import deserializer_helper +from botbuilder.schema import Activity, ChannelAccount, Mention +from botbuilder.schema.teams import ( + MessageActionsPayload, + MessagingExtensionAction, + TaskModuleRequestContext, +) + + +class TestTeamsActivityHandler(aiounittest.AsyncTestCase): + def test_teams_helper_teams_schema(self): + # Arrange + data = { + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "commandId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "edit", + "botActivityPreview": [{"id": "activity123"}], + "messagePayload": {"id": "payloadid"}, + } + + # Act + result = deserializer_helper(MessagingExtensionAction, data) + + # Assert + assert result.data == {"key": "value"} + assert result.context == TaskModuleRequestContext(theme="dark") + assert result.command_id == "test_command" + assert result.bot_message_preview_action == "edit" + assert len(result.bot_activity_preview) == 1 + assert result.bot_activity_preview[0] == Activity(id="activity123") + assert result.message_payload == MessageActionsPayload(id="payloadid") + + def test_teams_helper_schema(self): + # Arrange + data = { + "mentioned": {"id": "123", "name": "testName"}, + "text": "Hello testName", + "type": "mention", + } + + # Act + result = deserializer_helper(Mention, data) + + # Assert + assert result.mentioned == ChannelAccount(id="123", name="testName") + assert result.text == "Hello testName" + assert result.type == "mention" diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 8cd32a677..835846cb4 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -560,7 +560,7 @@ def __init__(self, **kwargs): super(MessagingExtensionAction, self).__init__(**kwargs) self.command_id = kwargs.get("command_id", None) self.command_context = kwargs.get("command_context", None) - self.bot_message_preview_action = kwargs.get("botMessagePreviewAction", None) + self.bot_message_preview_action = kwargs.get("bot_message_preview_action", None) self.bot_activity_preview = kwargs.get("bot_activity_preview", None) self.message_payload = kwargs.get("message_payload", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 4c42229c5..62d1e4a6f 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -670,7 +670,7 @@ def __init__( context=None, command_id: str = None, command_context=None, - botMessagePreviewAction=None, + bot_message_preview_action=None, bot_activity_preview=None, message_payload=None, **kwargs @@ -680,7 +680,7 @@ def __init__( ) self.command_id = command_id self.command_context = command_context - self.bot_message_preview_action = botMessagePreviewAction + self.bot_message_preview_action = bot_message_preview_action self.bot_activity_preview = bot_activity_preview self.message_payload = message_payload From e1892112222e1747b115e12168217784c33284f0 Mon Sep 17 00:00:00 2001 From: Michael Richardson <40401643+mdrichardson@users.noreply.github.com> Date: Fri, 6 Dec 2019 09:44:11 -0800 Subject: [PATCH 082/616] Cosmos partitioned - Parity with C# and Node | Also storage base tests (#459) * - added cosmosdb_partitioned_storage - added storage_base_tests - added test_cosmos_partitioned_storage * - added cosmosdb_partitioned_storage - added storage_base_tests - added test_cosmos_partitioned_storage * fixed bot_state so dialog tests pass * removed commented code * cosmos tests pass with storage_base_tests * blob storage uses storage_base_tests * memory_storage uses storage_base_tests * attempt to fix storage_base_tests import * moved storage_base_tests * black compliance * pylint compliance --- .../botbuilder/azure/__init__.py | 6 + .../botbuilder/azure/blob_storage.py | 35 +- .../azure/cosmosdb_partitioned_storage.py | 285 +++++++++++++++ .../botbuilder/azure/cosmosdb_storage.py | 15 +- .../tests/test_blob_storage.py | 165 ++++++--- .../tests/test_cosmos_partitioned_storage.py | 202 +++++++++++ .../tests/test_cosmos_storage.py | 196 +++++----- .../botbuilder/core/memory_storage.py | 43 ++- .../tests/test_memory_storage.py | 98 ++++- .../botbuilder/testing/__init__.py | 3 +- .../botbuilder/testing/storage_base_tests.py | 337 ++++++++++++++++++ 11 files changed, 1187 insertions(+), 198 deletions(-) create mode 100644 libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py create mode 100644 libraries/botbuilder-azure/tests/test_cosmos_partitioned_storage.py create mode 100644 libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py diff --git a/libraries/botbuilder-azure/botbuilder/azure/__init__.py b/libraries/botbuilder-azure/botbuilder/azure/__init__.py index 54dea209d..9980f8aa4 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/__init__.py +++ b/libraries/botbuilder-azure/botbuilder/azure/__init__.py @@ -7,6 +7,10 @@ from .about import __version__ from .cosmosdb_storage import CosmosDbStorage, CosmosDbConfig, CosmosDbKeyEscape +from .cosmosdb_partitioned_storage import ( + CosmosDbPartitionedStorage, + CosmosDbPartitionedConfig, +) from .blob_storage import BlobStorage, BlobStorageSettings __all__ = [ @@ -15,5 +19,7 @@ "CosmosDbStorage", "CosmosDbConfig", "CosmosDbKeyEscape", + "CosmosDbPartitionedStorage", + "CosmosDbPartitionedConfig", "__version__", ] diff --git a/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py b/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py index ae3ad1766..fada3fe53 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py @@ -3,7 +3,6 @@ from jsonpickle import encode from jsonpickle.unpickler import Unpickler - from azure.storage.blob import BlockBlobService, Blob, PublicAccess from botbuilder.core import Storage @@ -42,7 +41,7 @@ def __init__(self, settings: BlobStorageSettings): async def read(self, keys: List[str]) -> Dict[str, object]: if not keys: - raise Exception("Please provide at least one key to read from storage.") + raise Exception("Keys are required when reading") self.client.create_container(self.settings.container_name) self.client.set_container_acl( @@ -63,24 +62,31 @@ async def read(self, keys: List[str]) -> Dict[str, object]: return items async def write(self, changes: Dict[str, object]): + if changes is None: + raise Exception("Changes are required when writing") + if not changes: + return + self.client.create_container(self.settings.container_name) self.client.set_container_acl( self.settings.container_name, public_access=PublicAccess.Container ) - for name, item in changes.items(): - e_tag = ( - None if not hasattr(item, "e_tag") or item.e_tag == "*" else item.e_tag - ) - if e_tag: - item.e_tag = e_tag.replace('"', '\\"') + for (name, item) in changes.items(): + e_tag = item.e_tag if hasattr(item, "e_tag") else item.get("e_tag", None) + e_tag = None if e_tag == "*" else e_tag + if e_tag == "": + raise Exception("blob_storage.write(): etag missing") item_str = self._store_item_to_str(item) - self.client.create_blob_from_text( - container_name=self.settings.container_name, - blob_name=name, - text=item_str, - if_match=e_tag, - ) + try: + self.client.create_blob_from_text( + container_name=self.settings.container_name, + blob_name=name, + text=item_str, + if_match=e_tag, + ) + except Exception as error: + raise error async def delete(self, keys: List[str]): if keys is None: @@ -102,7 +108,6 @@ async def delete(self, keys: List[str]): def _blob_to_store_item(self, blob: Blob) -> object: item = json.loads(blob.content) item["e_tag"] = blob.properties.etag - item["id"] = blob.name result = Unpickler().restore(item) return result diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py new file mode 100644 index 000000000..00c3bb137 --- /dev/null +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py @@ -0,0 +1,285 @@ +"""CosmosDB Middleware for Python Bot Framework. + +This is middleware to store items in CosmosDB. +Part of the Azure Bot Framework in Python. +""" + +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Dict, List +from threading import Semaphore +import json + +from azure.cosmos import documents, http_constants +from jsonpickle.pickler import Pickler +from jsonpickle.unpickler import Unpickler +import azure.cosmos.cosmos_client as cosmos_client # pylint: disable=no-name-in-module,import-error +import azure.cosmos.errors as cosmos_errors # pylint: disable=no-name-in-module,import-error +from botbuilder.core.storage import Storage +from botbuilder.azure import CosmosDbKeyEscape + + +class CosmosDbPartitionedConfig: + """The class for partitioned CosmosDB configuration for the Azure Bot Framework.""" + + def __init__( + self, + cosmos_db_endpoint: str = None, + auth_key: str = None, + database_id: str = None, + container_id: str = None, + cosmos_client_options: dict = None, + container_throughput: int = None, + **kwargs, + ): + """Create the Config object. + + :param cosmos_db_endpoint: The CosmosDB endpoint. + :param auth_key: The authentication key for Cosmos DB. + :param database_id: The database identifier for Cosmos DB instance. + :param container_id: The container identifier. + :param cosmos_client_options: The options for the CosmosClient. Currently only supports connection_policy and + consistency_level + :param container_throughput: The throughput set when creating the Container. Defaults to 400. + :return CosmosDbPartitionedConfig: + """ + self.__config_file = kwargs.get("filename") + if self.__config_file: + kwargs = json.load(open(self.__config_file)) + self.cosmos_db_endpoint = cosmos_db_endpoint or kwargs.get("cosmos_db_endpoint") + self.auth_key = auth_key or kwargs.get("auth_key") + self.database_id = database_id or kwargs.get("database_id") + self.container_id = container_id or kwargs.get("container_id") + self.cosmos_client_options = cosmos_client_options or kwargs.get( + "cosmos_client_options", {} + ) + self.container_throughput = container_throughput or kwargs.get( + "container_throughput" + ) + + +class CosmosDbPartitionedStorage(Storage): + """The class for partitioned CosmosDB middleware for the Azure Bot Framework.""" + + def __init__(self, config: CosmosDbPartitionedConfig): + """Create the storage object. + + :param config: + """ + super(CosmosDbPartitionedStorage, self).__init__() + self.config = config + self.client = None + self.database = None + self.container = None + self.__semaphore = Semaphore() + + async def read(self, keys: List[str]) -> Dict[str, object]: + """Read storeitems from storage. + + :param keys: + :return dict: + """ + if not keys: + raise Exception("Keys are required when reading") + + await self.initialize() + + store_items = {} + + for key in keys: + try: + escaped_key = CosmosDbKeyEscape.sanitize_key(key) + + read_item_response = self.client.ReadItem( + self.__item_link(escaped_key), {"partitionKey": escaped_key} + ) + document_store_item = read_item_response + if document_store_item: + store_items[document_store_item["realId"]] = self.__create_si( + document_store_item + ) + # When an item is not found a CosmosException is thrown, but we want to + # return an empty collection so in this instance we catch and do not rethrow. + # Throw for any other exception. + except cosmos_errors.HTTPFailure as err: + if ( + err.status_code + == cosmos_errors.http_constants.StatusCodes.NOT_FOUND + ): + continue + raise err + except Exception as err: + raise err + return store_items + + async def write(self, changes: Dict[str, object]): + """Save storeitems to storage. + + :param changes: + :return: + """ + if changes is None: + raise Exception("Changes are required when writing") + if not changes: + return + + await self.initialize() + + for (key, change) in changes.items(): + e_tag = change.get("e_tag", None) + doc = { + "id": CosmosDbKeyEscape.sanitize_key(key), + "realId": key, + "document": self.__create_dict(change), + } + if e_tag == "": + raise Exception("cosmosdb_storage.write(): etag missing") + + access_condition = { + "accessCondition": {"type": "IfMatch", "condition": e_tag} + } + options = ( + access_condition if e_tag != "*" and e_tag and e_tag != "" else None + ) + try: + self.client.UpsertItem( + database_or_Container_link=self.__container_link, + document=doc, + options=options, + ) + except cosmos_errors.HTTPFailure as err: + raise err + except Exception as err: + raise err + + async def delete(self, keys: List[str]): + """Remove storeitems from storage. + + :param keys: + :return: + """ + await self.initialize() + + for key in keys: + escaped_key = CosmosDbKeyEscape.sanitize_key(key) + try: + self.client.DeleteItem( + document_link=self.__item_link(escaped_key), + options={"partitionKey": escaped_key}, + ) + except cosmos_errors.HTTPFailure as err: + if ( + err.status_code + == cosmos_errors.http_constants.StatusCodes.NOT_FOUND + ): + continue + raise err + except Exception as err: + raise err + + async def initialize(self): + if not self.container: + if not self.client: + self.client = cosmos_client.CosmosClient( + self.config.cosmos_db_endpoint, + {"masterKey": self.config.auth_key}, + self.config.cosmos_client_options.get("connection_policy", None), + self.config.cosmos_client_options.get("consistency_level", None), + ) + + if not self.database: + with self.__semaphore: + try: + self.database = self.client.CreateDatabase( + {"id": self.config.database_id} + ) + except cosmos_errors.HTTPFailure: + self.database = self.client.ReadDatabase( + "dbs/" + self.config.database_id + ) + + if not self.container: + with self.__semaphore: + container_def = { + "id": self.config.container_id, + "partitionKey": { + "paths": ["/id"], + "kind": documents.PartitionKind.Hash, + }, + } + try: + self.container = self.client.CreateContainer( + "dbs/" + self.database["id"], + container_def, + {"offerThroughput": 400}, + ) + except cosmos_errors.HTTPFailure as err: + if err.status_code == http_constants.StatusCodes.CONFLICT: + self.container = self.client.ReadContainer( + "dbs/" + + self.database["id"] + + "/colls/" + + container_def["id"] + ) + else: + raise err + + @staticmethod + def __create_si(result) -> object: + """Create an object from a result out of CosmosDB. + + :param result: + :return object: + """ + # get the document item from the result and turn into a dict + doc = result.get("document") + # read the e_tag from Cosmos + if result.get("_etag"): + doc["e_tag"] = result["_etag"] + + result_obj = Unpickler().restore(doc) + + # create and return the object + return result_obj + + @staticmethod + def __create_dict(store_item: object) -> Dict: + """Return the dict of an object. + + This eliminates non_magic attributes and the e_tag. + + :param store_item: + :return dict: + """ + # read the content + json_dict = Pickler().flatten(store_item) + if "e_tag" in json_dict: + del json_dict["e_tag"] + + # loop through attributes and write and return a dict + return json_dict + + def __item_link(self, identifier) -> str: + """Return the item link of a item in the container. + + :param identifier: + :return str: + """ + return self.__container_link + "/docs/" + identifier + + @property + def __container_link(self) -> str: + """Return the container link in the database. + + :param: + :return str: + """ + return self.__database_link + "/colls/" + self.config.container_id + + @property + def __database_link(self) -> str: + """Return the database link. + + :return str: + """ + return "dbs/" + self.config.database_id diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py index c8a25a017..3d588a864 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py @@ -160,6 +160,10 @@ async def write(self, changes: Dict[str, object]): :param changes: :return: """ + if changes is None: + raise Exception("Changes are required when writing") + if not changes: + return try: # check if the database and container exists and if not create if not self.__container_exists: @@ -167,13 +171,19 @@ async def write(self, changes: Dict[str, object]): # iterate over the changes for (key, change) in changes.items(): # store the e_tag - e_tag = change.e_tag + e_tag = ( + change.e_tag + if hasattr(change, "e_tag") + else change.get("e_tag", None) + ) # create the new document doc = { "id": CosmosDbKeyEscape.sanitize_key(key), "realId": key, "document": self.__create_dict(change), } + if e_tag == "": + raise Exception("cosmosdb_storage.write(): etag missing") # the e_tag will be * for new docs so do an insert if e_tag == "*" or not e_tag: self.client.UpsertItem( @@ -191,9 +201,6 @@ async def write(self, changes: Dict[str, object]): new_document=doc, options={"accessCondition": access_condition}, ) - # error when there is no e_tag - else: - raise Exception("cosmosdb_storage.write(): etag missing") except Exception as error: raise error diff --git a/libraries/botbuilder-azure/tests/test_blob_storage.py b/libraries/botbuilder-azure/tests/test_blob_storage.py index 40db0f61e..31f54a231 100644 --- a/libraries/botbuilder-azure/tests/test_blob_storage.py +++ b/libraries/botbuilder-azure/tests/test_blob_storage.py @@ -4,16 +4,29 @@ import pytest from botbuilder.core import StoreItem from botbuilder.azure import BlobStorage, BlobStorageSettings +from botbuilder.testing import StorageBaseTests # local blob emulator instance blob + BLOB_STORAGE_SETTINGS = BlobStorageSettings( - account_name="", account_key="", container_name="test" + account_name="", + account_key="", + container_name="test", + # Default Azure Storage Emulator Connection String + connection_string="AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq" + + "2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=" + + "http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;" + + "TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;", ) EMULATOR_RUNNING = False +def get_storage(): + return BlobStorage(BLOB_STORAGE_SETTINGS) + + async def reset(): - storage = BlobStorage(BLOB_STORAGE_SETTINGS) + storage = get_storage() try: await storage.client.delete_container( container_name=BLOB_STORAGE_SETTINGS.container_name @@ -29,7 +42,7 @@ def __init__(self, counter=1, e_tag="*"): self.e_tag = e_tag -class TestBlobStorage: +class TestBlobStorageConstructor: @pytest.mark.asyncio async def test_blob_storage_init_should_error_without_cosmos_db_config(self): try: @@ -37,17 +50,104 @@ async def test_blob_storage_init_should_error_without_cosmos_db_config(self): except Exception as error: assert error + +class TestBlobStorageBaseTests: @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio - async def test_blob_storage_read_should_return_data_with_valid_key(self): - storage = BlobStorage(BLOB_STORAGE_SETTINGS) - await storage.write({"user": SimpleStoreItem()}) + async def test_return_empty_object_when_reading_unknown_key(self): + await reset() - data = await storage.read(["user"]) - assert "user" in data - assert data["user"].counter == 1 - assert len(data.keys()) == 1 + test_ran = await StorageBaseTests.return_empty_object_when_reading_unknown_key( + get_storage() + ) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_null_keys_when_reading(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_reading(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_null_keys_when_writing(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_writing(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_does_not_raise_when_writing_no_items(self): + await reset() + + test_ran = await StorageBaseTests.does_not_raise_when_writing_no_items( + get_storage() + ) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_create_object(self): + await reset() + + test_ran = await StorageBaseTests.create_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_crazy_keys(self): + await reset() + + test_ran = await StorageBaseTests.handle_crazy_keys(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_update_object(self): + await reset() + test_ran = await StorageBaseTests.update_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_delete_object(self): + await reset() + + test_ran = await StorageBaseTests.delete_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_perform_batch_operations(self): + await reset() + + test_ran = await StorageBaseTests.perform_batch_operations(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_proceeds_through_waterfall(self): + await reset() + + test_ran = await StorageBaseTests.proceeds_through_waterfall(get_storage()) + + assert test_ran + + +class TestBlobStorage: @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_read_update_should_return_new_etag(self): @@ -60,25 +160,6 @@ async def test_blob_storage_read_update_should_return_new_etag(self): assert data_updated["test"].counter == 2 assert data_updated["test"].e_tag != data_result["test"].e_tag - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_blob_storage_read_no_key_should_throw(self): - try: - storage = BlobStorage(BLOB_STORAGE_SETTINGS) - await storage.read([]) - except Exception as error: - assert error - - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_blob_storage_write_should_add_new_value(self): - storage = BlobStorage(BLOB_STORAGE_SETTINGS) - await storage.write({"user": SimpleStoreItem(counter=1)}) - - data = await storage.read(["user"]) - assert "user" in data - assert data["user"].counter == 1 - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk( @@ -91,32 +172,6 @@ async def test_blob_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk data = await storage.read(["user"]) assert data["user"].counter == 10 - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_blob_storage_write_batch_operation(self): - storage = BlobStorage(BLOB_STORAGE_SETTINGS) - await storage.write( - { - "batch1": SimpleStoreItem(counter=1), - "batch2": SimpleStoreItem(counter=1), - "batch3": SimpleStoreItem(counter=1), - } - ) - data = await storage.read(["batch1", "batch2", "batch3"]) - assert len(data.keys()) == 3 - assert data["batch1"] - assert data["batch2"] - assert data["batch3"] - assert data["batch1"].counter == 1 - assert data["batch2"].counter == 1 - assert data["batch3"].counter == 1 - assert data["batch1"].e_tag - assert data["batch2"].e_tag - assert data["batch3"].e_tag - await storage.delete(["batch1", "batch2", "batch3"]) - data = await storage.read(["batch1", "batch2", "batch3"]) - assert not data.keys() - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_blob_storage_delete_should_delete_according_cached_data(self): diff --git a/libraries/botbuilder-azure/tests/test_cosmos_partitioned_storage.py b/libraries/botbuilder-azure/tests/test_cosmos_partitioned_storage.py new file mode 100644 index 000000000..cb6dd0822 --- /dev/null +++ b/libraries/botbuilder-azure/tests/test_cosmos_partitioned_storage.py @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import azure.cosmos.errors as cosmos_errors +from azure.cosmos import documents +import pytest +from botbuilder.azure import CosmosDbPartitionedStorage, CosmosDbPartitionedConfig +from botbuilder.testing import StorageBaseTests + +EMULATOR_RUNNING = False + + +def get_settings() -> CosmosDbPartitionedConfig: + return CosmosDbPartitionedConfig( + cosmos_db_endpoint="https://localhost:8081", + auth_key="C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + database_id="test-db", + container_id="bot-storage", + ) + + +def get_storage(): + return CosmosDbPartitionedStorage(get_settings()) + + +async def reset(): + storage = CosmosDbPartitionedStorage(get_settings()) + await storage.initialize() + try: + storage.client.DeleteDatabase(database_link="dbs/" + get_settings().database_id) + except cosmos_errors.HTTPFailure: + pass + + +class TestCosmosDbPartitionedStorageConstructor: + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_raises_error_when_instantiated_with_no_arguments(self): + try: + # noinspection PyArgumentList + # pylint: disable=no-value-for-parameter + CosmosDbPartitionedStorage() + except Exception as error: + assert error + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_raises_error_when_no_endpoint_provided(self): + no_endpoint = get_settings() + no_endpoint.cosmos_db_endpoint = None + try: + CosmosDbPartitionedStorage(no_endpoint) + except Exception as error: + assert error + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_raises_error_when_no_auth_key_provided(self): + no_auth_key = get_settings() + no_auth_key.auth_key = None + try: + CosmosDbPartitionedStorage(no_auth_key) + except Exception as error: + assert error + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_raises_error_when_no_database_id_provided(self): + no_database_id = get_settings() + no_database_id.database_id = None + try: + CosmosDbPartitionedStorage(no_database_id) + except Exception as error: + assert error + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_raises_error_when_no_container_id_provided(self): + no_container_id = get_settings() + no_container_id.container_id = None + try: + CosmosDbPartitionedStorage(no_container_id) + except Exception as error: + assert error + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_passes_cosmos_client_options(self): + settings_with_options = get_settings() + + connection_policy = documents.ConnectionPolicy() + connection_policy.DisableSSLVerification = True + + settings_with_options.cosmos_client_options = { + "connection_policy": connection_policy, + "consistency_level": documents.ConsistencyLevel.Eventual, + } + + client = CosmosDbPartitionedStorage(settings_with_options) + await client.initialize() + + assert client.client.connection_policy.DisableSSLVerification is True + assert ( + client.client.default_headers["x-ms-consistency-level"] + == documents.ConsistencyLevel.Eventual + ) + + +class TestCosmosDbPartitionedStorageBaseStorageTests: + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_return_empty_object_when_reading_unknown_key(self): + await reset() + + test_ran = await StorageBaseTests.return_empty_object_when_reading_unknown_key( + get_storage() + ) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_null_keys_when_reading(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_reading(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_null_keys_when_writing(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_writing(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_does_not_raise_when_writing_no_items(self): + await reset() + + test_ran = await StorageBaseTests.does_not_raise_when_writing_no_items( + get_storage() + ) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_create_object(self): + await reset() + + test_ran = await StorageBaseTests.create_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_crazy_keys(self): + await reset() + + test_ran = await StorageBaseTests.handle_crazy_keys(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_update_object(self): + await reset() + + test_ran = await StorageBaseTests.update_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_delete_object(self): + await reset() + + test_ran = await StorageBaseTests.delete_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_perform_batch_operations(self): + await reset() + + test_ran = await StorageBaseTests.perform_batch_operations(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_proceeds_through_waterfall(self): + await reset() + + test_ran = await StorageBaseTests.proceeds_through_waterfall(get_storage()) + + assert test_ran diff --git a/libraries/botbuilder-azure/tests/test_cosmos_storage.py b/libraries/botbuilder-azure/tests/test_cosmos_storage.py index a9bfe5191..c66660857 100644 --- a/libraries/botbuilder-azure/tests/test_cosmos_storage.py +++ b/libraries/botbuilder-azure/tests/test_cosmos_storage.py @@ -7,6 +7,7 @@ import pytest from botbuilder.core import StoreItem from botbuilder.azure import CosmosDbStorage, CosmosDbConfig +from botbuilder.testing import StorageBaseTests # local cosmosdb emulator instance cosmos_db_config COSMOS_DB_CONFIG = CosmosDbConfig( @@ -18,6 +19,10 @@ EMULATOR_RUNNING = False +def get_storage(): + return CosmosDbStorage(COSMOS_DB_CONFIG) + + async def reset(): storage = CosmosDbStorage(COSMOS_DB_CONFIG) try: @@ -50,7 +55,7 @@ def __init__(self, counter=1, e_tag="*"): self.e_tag = e_tag -class TestCosmosDbStorage: +class TestCosmosDbStorageConstructor: @pytest.mark.asyncio async def test_cosmos_storage_init_should_error_without_cosmos_db_config(self): try: @@ -59,7 +64,7 @@ async def test_cosmos_storage_init_should_error_without_cosmos_db_config(self): assert error @pytest.mark.asyncio - async def test_creation_request_options_era_being_called(self): + async def test_creation_request_options_are_being_called(self): # pylint: disable=protected-access test_config = CosmosDbConfig( endpoint="https://localhost:8081", @@ -86,6 +91,104 @@ async def test_creation_request_options_era_being_called(self): "dbs/" + test_id, {"id": test_id}, test_config.container_creation_options ) + +class TestCosmosDbStorageBaseStorageTests: + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_return_empty_object_when_reading_unknown_key(self): + await reset() + + test_ran = await StorageBaseTests.return_empty_object_when_reading_unknown_key( + get_storage() + ) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_null_keys_when_reading(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_reading(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_null_keys_when_writing(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_writing(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_does_not_raise_when_writing_no_items(self): + await reset() + + test_ran = await StorageBaseTests.does_not_raise_when_writing_no_items( + get_storage() + ) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_create_object(self): + await reset() + + test_ran = await StorageBaseTests.create_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_handle_crazy_keys(self): + await reset() + + test_ran = await StorageBaseTests.handle_crazy_keys(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_update_object(self): + await reset() + + test_ran = await StorageBaseTests.update_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_delete_object(self): + await reset() + + test_ran = await StorageBaseTests.delete_object(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_perform_batch_operations(self): + await reset() + + test_ran = await StorageBaseTests.perform_batch_operations(get_storage()) + + assert test_ran + + @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") + @pytest.mark.asyncio + async def test_proceeds_through_waterfall(self): + await reset() + + test_ran = await StorageBaseTests.proceeds_through_waterfall(get_storage()) + + assert test_ran + + +class TestCosmosDbStorage: @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_init_should_work_with_just_endpoint_and_key(self): @@ -100,18 +203,6 @@ async def test_cosmos_storage_init_should_work_with_just_endpoint_and_key(self): assert data["user"].counter == 1 assert len(data.keys()) == 1 - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_cosmos_storage_read_should_return_data_with_valid_key(self): - await reset() - storage = CosmosDbStorage(COSMOS_DB_CONFIG) - await storage.write({"user": SimpleStoreItem()}) - - data = await storage.read(["user"]) - assert "user" in data - assert data["user"].counter == 1 - assert len(data.keys()) == 1 - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_read_update_should_return_new_etag(self): @@ -135,27 +226,6 @@ async def test_cosmos_storage_read_with_invalid_key_should_return_empty_dict(sel assert isinstance(data, dict) assert not data.keys() - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_cosmos_storage_read_no_key_should_throw(self): - try: - await reset() - storage = CosmosDbStorage(COSMOS_DB_CONFIG) - await storage.read([]) - except Exception as error: - assert error - - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_cosmos_storage_write_should_add_new_value(self): - await reset() - storage = CosmosDbStorage(COSMOS_DB_CONFIG) - await storage.write({"user": SimpleStoreItem(counter=1)}) - - data = await storage.read(["user"]) - assert "user" in data - assert data["user"].counter == 1 - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk( @@ -169,62 +239,6 @@ async def test_cosmos_storage_write_should_overwrite_when_new_e_tag_is_an_asteri data = await storage.read(["user"]) assert data["user"].counter == 10 - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_cosmos_storage_write_batch_operation(self): - await reset() - storage = CosmosDbStorage(COSMOS_DB_CONFIG) - await storage.write( - { - "batch1": SimpleStoreItem(counter=1), - "batch2": SimpleStoreItem(counter=1), - "batch3": SimpleStoreItem(counter=1), - } - ) - data = await storage.read(["batch1", "batch2", "batch3"]) - assert len(data.keys()) == 3 - assert data["batch1"] - assert data["batch2"] - assert data["batch3"] - assert data["batch1"].counter == 1 - assert data["batch2"].counter == 1 - assert data["batch3"].counter == 1 - assert data["batch1"].e_tag - assert data["batch2"].e_tag - assert data["batch3"].e_tag - await storage.delete(["batch1", "batch2", "batch3"]) - data = await storage.read(["batch1", "batch2", "batch3"]) - assert not data.keys() - - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_cosmos_storage_write_crazy_keys_work(self): - await reset() - storage = CosmosDbStorage(COSMOS_DB_CONFIG) - crazy_key = '!@#$%^&*()_+??><":QASD~`' - await storage.write({crazy_key: SimpleStoreItem(counter=1)}) - data = await storage.read([crazy_key]) - assert len(data.keys()) == 1 - assert data[crazy_key] - assert data[crazy_key].counter == 1 - assert data[crazy_key].e_tag - - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") - @pytest.mark.asyncio - async def test_cosmos_storage_delete_should_delete_according_cached_data(self): - await reset() - storage = CosmosDbStorage(COSMOS_DB_CONFIG) - await storage.write({"test": SimpleStoreItem()}) - try: - await storage.delete(["test"]) - except Exception as error: - raise error - else: - data = await storage.read(["test"]) - - assert isinstance(data, dict) - assert not data.keys() - @pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.") @pytest.mark.asyncio async def test_cosmos_storage_delete_should_delete_multiple_values_when_given_multiple_valid_keys( diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index 73ff77bc4..b85b3d368 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -22,6 +22,8 @@ async def delete(self, keys: List[str]): async def read(self, keys: List[str]): data = {} + if not keys: + return data try: for key in keys: if key in self.memory: @@ -32,10 +34,14 @@ async def read(self, keys: List[str]): return data async def write(self, changes: Dict[str, StoreItem]): + if changes is None: + raise Exception("Changes are required when writing") + if not changes: + return try: # iterate over the changes for (key, change) in changes.items(): - new_value = change + new_value = deepcopy(change) old_state_etag = None # Check if the a matching key already exists in self.memory @@ -43,26 +49,35 @@ async def write(self, changes: Dict[str, StoreItem]): if key in self.memory: old_state = self.memory[key] if not isinstance(old_state, StoreItem): - if "eTag" in old_state: - old_state_etag = old_state["eTag"] + old_state_etag = old_state.get("e_tag", None) elif old_state.e_tag: old_state_etag = old_state.e_tag new_state = new_value # Set ETag if applicable - if hasattr(new_value, "e_tag"): - if ( - old_state_etag is not None - and new_value.e_tag != "*" - and new_value.e_tag < old_state_etag - ): - raise KeyError( - "Etag conflict.\nOriginal: %s\r\nCurrent: %s" - % (new_value.e_tag, old_state_etag) - ) + new_value_etag = ( + new_value.e_tag + if hasattr(new_value, "e_tag") + else new_value.get("e_tag", None) + ) + if new_value_etag == "": + raise Exception("blob_storage.write(): etag missing") + if ( + old_state_etag is not None + and new_value_etag is not None + and new_value_etag != "*" + and new_value_etag < old_state_etag + ): + raise KeyError( + "Etag conflict.\nOriginal: %s\r\nCurrent: %s" + % (new_value_etag, old_state_etag) + ) + if hasattr(new_state, "e_tag"): new_state.e_tag = str(self._e_tag) - self._e_tag += 1 + else: + new_state["e_tag"] = str(self._e_tag) + self._e_tag += 1 self.memory[key] = deepcopy(new_state) except Exception as error: diff --git a/libraries/botbuilder-core/tests/test_memory_storage.py b/libraries/botbuilder-core/tests/test_memory_storage.py index 63946ad60..a34e2a94e 100644 --- a/libraries/botbuilder-core/tests/test_memory_storage.py +++ b/libraries/botbuilder-core/tests/test_memory_storage.py @@ -1,9 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import aiounittest +import pytest from botbuilder.core import MemoryStorage, StoreItem +from botbuilder.testing import StorageBaseTests + + +def get_storage(): + return MemoryStorage() class SimpleStoreItem(StoreItem): @@ -13,7 +18,7 @@ def __init__(self, counter=1, e_tag="*"): self.e_tag = e_tag -class TestMemoryStorage(aiounittest.AsyncTestCase): +class TestMemoryStorageConstructor: def test_initializing_memory_storage_without_data_should_still_have_memory(self): storage = MemoryStorage() assert storage.memory is not None @@ -23,6 +28,7 @@ def test_memory_storage__e_tag_should_start_at_0(self): storage = MemoryStorage() assert storage._e_tag == 0 # pylint: disable=protected-access + @pytest.mark.asyncio async def test_memory_storage_initialized_with_memory_should_have_accessible_data( self, ): @@ -32,26 +38,75 @@ async def test_memory_storage_initialized_with_memory_should_have_accessible_dat assert data["test"].counter == 1 assert len(data.keys()) == 1 - async def test_memory_storage_read_should_return_data_with_valid_key(self): - storage = MemoryStorage() - await storage.write({"user": SimpleStoreItem()}) - data = await storage.read(["user"]) - assert "user" in data - assert data["user"].counter == 1 - assert len(data.keys()) == 1 - assert storage._e_tag == 1 # pylint: disable=protected-access - assert int(data["user"].e_tag) == 0 +class TestMemoryStorageBaseTests: + @pytest.mark.asyncio + async def test_return_empty_object_when_reading_unknown_key(self): + test_ran = await StorageBaseTests.return_empty_object_when_reading_unknown_key( + get_storage() + ) - async def test_memory_storage_write_should_add_new_value(self): - storage = MemoryStorage() - aux = {"user": SimpleStoreItem(counter=1)} - await storage.write(aux) + assert test_ran + + @pytest.mark.asyncio + async def test_handle_null_keys_when_reading(self): + test_ran = await StorageBaseTests.handle_null_keys_when_reading(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_handle_null_keys_when_writing(self): + test_ran = await StorageBaseTests.handle_null_keys_when_writing(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_does_not_raise_when_writing_no_items(self): + test_ran = await StorageBaseTests.does_not_raise_when_writing_no_items( + get_storage() + ) + + assert test_ran + + @pytest.mark.asyncio + async def test_create_object(self): + test_ran = await StorageBaseTests.create_object(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_handle_crazy_keys(self): + test_ran = await StorageBaseTests.handle_crazy_keys(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_update_object(self): + test_ran = await StorageBaseTests.update_object(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_delete_object(self): + test_ran = await StorageBaseTests.delete_object(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_perform_batch_operations(self): + test_ran = await StorageBaseTests.perform_batch_operations(get_storage()) + + assert test_ran + + @pytest.mark.asyncio + async def test_proceeds_through_waterfall(self): + test_ran = await StorageBaseTests.proceeds_through_waterfall(get_storage()) + + assert test_ran - data = await storage.read(["user"]) - assert "user" in data - assert data["user"].counter == 1 +class TestMemoryStorage: + @pytest.mark.asyncio async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk_1( self, ): @@ -62,6 +117,7 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri data = await storage.read(["user"]) assert data["user"].counter == 10 + @pytest.mark.asyncio async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk_2( self, ): @@ -72,6 +128,7 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri data = await storage.read(["user"]) assert data["user"].counter == 5 + @pytest.mark.asyncio async def test_memory_storage_read_with_invalid_key_should_return_empty_dict(self): storage = MemoryStorage() data = await storage.read(["test"]) @@ -79,6 +136,7 @@ async def test_memory_storage_read_with_invalid_key_should_return_empty_dict(sel assert isinstance(data, dict) assert not data.keys() + @pytest.mark.asyncio async def test_memory_storage_delete_should_delete_according_cached_data(self): storage = MemoryStorage({"test": "test"}) try: @@ -91,6 +149,7 @@ async def test_memory_storage_delete_should_delete_according_cached_data(self): assert isinstance(data, dict) assert not data.keys() + @pytest.mark.asyncio async def test_memory_storage_delete_should_delete_multiple_values_when_given_multiple_valid_keys( self, ): @@ -102,6 +161,7 @@ async def test_memory_storage_delete_should_delete_multiple_values_when_given_mu data = await storage.read(["test", "test2"]) assert not data.keys() + @pytest.mark.asyncio async def test_memory_storage_delete_should_delete_values_when_given_multiple_valid_keys_and_ignore_other_data( self, ): @@ -117,6 +177,7 @@ async def test_memory_storage_delete_should_delete_values_when_given_multiple_va data = await storage.read(["test", "test2", "test3"]) assert len(data.keys()) == 1 + @pytest.mark.asyncio async def test_memory_storage_delete_invalid_key_should_do_nothing_and_not_affect_cached_data( self, ): @@ -128,6 +189,7 @@ async def test_memory_storage_delete_invalid_key_should_do_nothing_and_not_affec data = await storage.read(["foo"]) assert not data.keys() + @pytest.mark.asyncio async def test_memory_storage_delete_invalid_keys_should_do_nothing_and_not_affect_cached_data( self, ): diff --git a/libraries/botbuilder-testing/botbuilder/testing/__init__.py b/libraries/botbuilder-testing/botbuilder/testing/__init__.py index 681a168e4..af82e1a65 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/__init__.py +++ b/libraries/botbuilder-testing/botbuilder/testing/__init__.py @@ -3,6 +3,7 @@ from .dialog_test_client import DialogTestClient from .dialog_test_logger import DialogTestLogger +from .storage_base_tests import StorageBaseTests -__all__ = ["DialogTestClient", "DialogTestLogger"] +__all__ = ["DialogTestClient", "DialogTestLogger", "StorageBaseTests"] diff --git a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py new file mode 100644 index 000000000..defa5040f --- /dev/null +++ b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py @@ -0,0 +1,337 @@ +""" +Base tests that all storage providers should implement in their own tests. +They handle the storage-based assertions, internally. + +All tests return true if assertions pass to indicate that the code ran to completion, passing internal assertions. +Therefore, all tests using theses static tests should strictly check that the method returns true. + +:Example: + async def test_handle_null_keys_when_reading(self): + await reset() + + test_ran = await StorageBaseTests.handle_null_keys_when_reading(get_storage()) + + assert test_ran +""" +import pytest +from botbuilder.azure import CosmosDbStorage +from botbuilder.core import ( + ConversationState, + TurnContext, + MessageFactory, + MemoryStorage, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.dialogs import ( + DialogSet, + DialogTurnStatus, + TextPrompt, + PromptValidatorContext, + WaterfallStepContext, + Dialog, + WaterfallDialog, + PromptOptions, +) + + +class StorageBaseTests: + @staticmethod + async def return_empty_object_when_reading_unknown_key(storage) -> bool: + result = await storage.read(["unknown"]) + + assert result is not None + assert len(result) == 0 + + return True + + @staticmethod + async def handle_null_keys_when_reading(storage) -> bool: + if isinstance(storage, (CosmosDbStorage, MemoryStorage)): + result = await storage.read(None) + assert len(result.keys()) == 0 + # Catch-all + else: + with pytest.raises(Exception) as err: + await storage.read(None) + assert err.value.args[0] == "Keys are required when reading" + + return True + + @staticmethod + async def handle_null_keys_when_writing(storage) -> bool: + with pytest.raises(Exception) as err: + await storage.write(None) + assert err.value.args[0] == "Changes are required when writing" + + return True + + @staticmethod + async def does_not_raise_when_writing_no_items(storage) -> bool: + # noinspection PyBroadException + try: + await storage.write([]) + except: + pytest.fail("Should not raise") + + return True + + @staticmethod + async def create_object(storage) -> bool: + store_items = { + "createPoco": {"id": 1}, + "createPocoStoreItem": {"id": 2}, + } + + await storage.write(store_items) + + read_store_items = await storage.read(store_items.keys()) + + assert store_items["createPoco"]["id"] == read_store_items["createPoco"]["id"] + assert ( + store_items["createPocoStoreItem"]["id"] + == read_store_items["createPocoStoreItem"]["id"] + ) + assert read_store_items["createPoco"]["e_tag"] is not None + assert read_store_items["createPocoStoreItem"]["e_tag"] is not None + + return True + + @staticmethod + async def handle_crazy_keys(storage) -> bool: + key = '!@#$%^&*()_+??><":QASD~`' + store_item = {"id": 1} + store_items = {key: store_item} + + await storage.write(store_items) + + read_store_items = await storage.read(store_items.keys()) + + assert read_store_items[key] is not None + assert read_store_items[key]["id"] == 1 + + return True + + @staticmethod + async def update_object(storage) -> bool: + original_store_items = { + "pocoItem": {"id": 1, "count": 1}, + "pocoStoreItem": {"id": 1, "count": 1}, + } + + # 1st write should work + await storage.write(original_store_items) + + loaded_store_items = await storage.read(["pocoItem", "pocoStoreItem"]) + + update_poco_item = loaded_store_items["pocoItem"] + update_poco_item["e_tag"] = None + update_poco_store_item = loaded_store_items["pocoStoreItem"] + assert update_poco_store_item["e_tag"] is not None + + # 2nd write should work + update_poco_item["count"] += 1 + update_poco_store_item["count"] += 1 + + await storage.write(loaded_store_items) + + reloaded_store_items = await storage.read(loaded_store_items.keys()) + + reloaded_update_poco_item = reloaded_store_items["pocoItem"] + reloaded_update_poco_store_item = reloaded_store_items["pocoStoreItem"] + + assert reloaded_update_poco_item["e_tag"] is not None + assert ( + update_poco_store_item["e_tag"] != reloaded_update_poco_store_item["e_tag"] + ) + assert reloaded_update_poco_item["count"] == 2 + assert reloaded_update_poco_store_item["count"] == 2 + + # Write with old e_tag should succeed for non-storeItem + update_poco_item["count"] = 123 + await storage.write({"pocoItem": update_poco_item}) + + # Write with old eTag should FAIL for storeItem + update_poco_store_item["count"] = 123 + + with pytest.raises(Exception) as err: + await storage.write({"pocoStoreItem": update_poco_store_item}) + assert err.value is not None + + reloaded_store_items2 = await storage.read(["pocoItem", "pocoStoreItem"]) + + reloaded_poco_item2 = reloaded_store_items2["pocoItem"] + reloaded_poco_item2["e_tag"] = None + reloaded_poco_store_item2 = reloaded_store_items2["pocoStoreItem"] + + assert reloaded_poco_item2["count"] == 123 + assert reloaded_poco_store_item2["count"] == 2 + + # write with wildcard etag should work + reloaded_poco_item2["count"] = 100 + reloaded_poco_store_item2["count"] = 100 + reloaded_poco_store_item2["e_tag"] = "*" + + wildcard_etag_dict = { + "pocoItem": reloaded_poco_item2, + "pocoStoreItem": reloaded_poco_store_item2, + } + + await storage.write(wildcard_etag_dict) + + reloaded_store_items3 = await storage.read(["pocoItem", "pocoStoreItem"]) + + assert reloaded_store_items3["pocoItem"]["count"] == 100 + assert reloaded_store_items3["pocoStoreItem"]["count"] == 100 + + # Write with empty etag should not work + reloaded_store_items4 = await storage.read(["pocoStoreItem"]) + reloaded_store_item4 = reloaded_store_items4["pocoStoreItem"] + + assert reloaded_store_item4 is not None + + reloaded_store_item4["e_tag"] = "" + dict2 = {"pocoStoreItem": reloaded_store_item4} + + with pytest.raises(Exception) as err: + await storage.write(dict2) + assert err.value is not None + + final_store_items = await storage.read(["pocoItem", "pocoStoreItem"]) + assert final_store_items["pocoItem"]["count"] == 100 + assert final_store_items["pocoStoreItem"]["count"] == 100 + + return True + + @staticmethod + async def delete_object(storage) -> bool: + store_items = {"delete1": {"id": 1, "count": 1}} + + await storage.write(store_items) + + read_store_items = await storage.read(["delete1"]) + + assert read_store_items["delete1"]["e_tag"] + assert read_store_items["delete1"]["count"] == 1 + + await storage.delete(["delete1"]) + + reloaded_store_items = await storage.read(["delete1"]) + + assert reloaded_store_items.get("delete1", None) is None + + return True + + @staticmethod + async def delete_unknown_object(storage) -> bool: + # noinspection PyBroadException + try: + await storage.delete(["unknown_key"]) + except: + pytest.fail("Should not raise") + + return True + + @staticmethod + async def perform_batch_operations(storage) -> bool: + await storage.write( + {"batch1": {"count": 10}, "batch2": {"count": 20}, "batch3": {"count": 30},} + ) + + result = await storage.read(["batch1", "batch2", "batch3"]) + + assert result.get("batch1", None) is not None + assert result.get("batch2", None) is not None + assert result.get("batch3", None) is not None + assert result["batch1"]["count"] == 10 + assert result["batch2"]["count"] == 20 + assert result["batch3"]["count"] == 30 + assert result["batch1"].get("e_tag", None) is not None + assert result["batch2"].get("e_tag", None) is not None + assert result["batch3"].get("e_tag", None) is not None + + await storage.delete(["batch1", "batch2", "batch3"]) + + result = await storage.read(["batch1", "batch2", "batch3"]) + + assert result.get("batch1", None) is None + assert result.get("batch2", None) is None + assert result.get("batch3", None) is None + + return True + + @staticmethod + async def proceeds_through_waterfall(storage) -> bool: + convo_state = ConversationState(storage) + + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def exec_test(turn_context: TurnContext) -> None: + dialog_context = await dialogs.create_context(turn_context) + + await dialog_context.continue_dialog() + if not turn_context.responded: + await dialog_context.begin_dialog(WaterfallDialog.__name__) + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + async def prompt_validator(prompt_context: PromptValidatorContext): + result = prompt_context.recognized.value + if len(result) > 3: + succeeded_message = MessageFactory.text( + f"You got it at the {prompt_context.options.number_of_attempts}rd try!" + ) + await prompt_context.context.send_activity(succeeded_message) + return True + + reply = MessageFactory.text( + f"Please send a name that is longer than 3 characters. {prompt_context.options.number_of_attempts}" + ) + await prompt_context.context.send_activity(reply) + return False + + async def step_1(step_context: WaterfallStepContext) -> DialogTurnStatus: + assert isinstance(step_context.active_dialog.state["stepIndex"], int) + await step_context.context.send_activity("step1") + return Dialog.end_of_turn + + async def step_2(step_context: WaterfallStepContext) -> None: + assert isinstance(step_context.active_dialog.state["stepIndex"], int) + await step_context.prompt( + TextPrompt.__name__, + PromptOptions(prompt=MessageFactory.text("Please type your name")), + ) + + async def step_3(step_context: WaterfallStepContext) -> DialogTurnStatus: + assert isinstance(step_context.active_dialog.state["stepIndex"], int) + await step_context.context.send_activity("step3") + return Dialog.end_of_turn + + steps = [step_1, step_2, step_3] + + dialogs.add(WaterfallDialog(WaterfallDialog.__name__, steps)) + + dialogs.add(TextPrompt(TextPrompt.__name__, prompt_validator)) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply("step1") + step3 = await step2.send("hello") + step4 = await step3.assert_reply("Please type your name") # None + step5 = await step4.send("hi") + step6 = await step5.assert_reply( + "Please send a name that is longer than 3 characters. 0" + ) + step7 = await step6.send("hi") + step8 = await step7.assert_reply( + "Please send a name that is longer than 3 characters. 1" + ) + step9 = await step8.send("hi") + step10 = await step9.assert_reply( + "Please send a name that is longer than 3 characters. 2" + ) + step11 = await step10.send("Kyle") + step12 = await step11.assert_reply("You got it at the 3rd try!") + await step12.assert_reply("step3") + + return True From dfaf3e8df070924f1a7d27a3637be3c771159b79 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Fri, 6 Dec 2019 15:20:53 -0800 Subject: [PATCH 083/616] fixing bug in jwt token extrator (#475) * fixing bug in jwt token extrator * adding fix for credentials --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 2 ++ .../botframework/connector/auth/jwt_token_extractor.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 53181089f..8424517e7 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -653,6 +653,8 @@ async def create_connector_client( credentials.oauth_scope = ( GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE ) + else: + credentials = self._credentials else: credentials = self._credentials diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py index 6f2cd5869..4a0850763 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py @@ -142,7 +142,7 @@ async def _refresh(self): def _find(self, key_id: str): if not self.keys: return None - key = next(x for x in self.keys if x["kid"] == key_id) + key = [x for x in self.keys if x["kid"] == key_id][0] public_key = RSAAlgorithm.from_jwk(json.dumps(key)) endorsements = key.get("endorsements", []) return _OpenIdConfig(public_key, endorsements) From 0db9c7fe019ee5929d724617f677044219d8220a Mon Sep 17 00:00:00 2001 From: mdrichardson Date: Mon, 9 Dec 2019 13:51:03 -0800 Subject: [PATCH 084/616] remove payments --- .../botbuilder/schema/__init__.py | 45 - .../schema/_connector_client_enums.py | 1 - .../botbuilder/schema/_models.py | 478 +- .../botbuilder/schema/_models_py3.py | 572 +- libraries/swagger/ConnectorAPI.json | 4983 +++++++++-------- 5 files changed, 2502 insertions(+), 3577 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index b484cc672..bb3e7d75f 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -39,23 +39,8 @@ from ._models_py3 import MediaUrl from ._models_py3 import Mention from ._models_py3 import MessageReaction - from ._models_py3 import MicrosoftPayMethodData from ._models_py3 import OAuthCard from ._models_py3 import PagedMembersResult - from ._models_py3 import PaymentAddress - from ._models_py3 import PaymentCurrencyAmount - from ._models_py3 import PaymentDetails - from ._models_py3 import PaymentDetailsModifier - from ._models_py3 import PaymentItem - from ._models_py3 import PaymentMethodData - from ._models_py3 import PaymentOptions - from ._models_py3 import PaymentRequest - from ._models_py3 import PaymentRequestComplete - from ._models_py3 import PaymentRequestCompleteResult - from ._models_py3 import PaymentRequestUpdate - from ._models_py3 import PaymentRequestUpdateResult - from ._models_py3 import PaymentResponse - from ._models_py3 import PaymentShippingOption from ._models_py3 import Place from ._models_py3 import ReceiptCard from ._models_py3 import ReceiptItem @@ -101,23 +86,8 @@ from ._models import MediaUrl from ._models import Mention from ._models import MessageReaction - from ._models import MicrosoftPayMethodData from ._models import OAuthCard from ._models import PagedMembersResult - from ._models import PaymentAddress - from ._models import PaymentCurrencyAmount - from ._models import PaymentDetails - from ._models import PaymentDetailsModifier - from ._models import PaymentItem - from ._models import PaymentMethodData - from ._models import PaymentOptions - from ._models import PaymentRequest - from ._models import PaymentRequestComplete - from ._models import PaymentRequestCompleteResult - from ._models import PaymentRequestUpdate - from ._models import PaymentRequestUpdateResult - from ._models import PaymentResponse - from ._models import PaymentShippingOption from ._models import Place from ._models import ReceiptCard from ._models import ReceiptItem @@ -179,23 +149,8 @@ "MediaUrl", "Mention", "MessageReaction", - "MicrosoftPayMethodData", "OAuthCard", "PagedMembersResult", - "PaymentAddress", - "PaymentCurrencyAmount", - "PaymentDetails", - "PaymentDetailsModifier", - "PaymentItem", - "PaymentMethodData", - "PaymentOptions", - "PaymentRequest", - "PaymentRequestComplete", - "PaymentRequestCompleteResult", - "PaymentRequestUpdate", - "PaymentRequestUpdateResult", - "PaymentResponse", - "PaymentShippingOption", "Place", "ReceiptCard", "ReceiptItem", diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index 300ccddd8..a725f880b 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -75,7 +75,6 @@ class ActionTypes(str, Enum): download_file = "downloadFile" signin = "signin" call = "call" - payment = "payment" message_back = "messageBack" diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 9574df14a..2cb85d663 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -520,7 +520,7 @@ class CardAction(Model): :param type: The type of action implemented by this button. Possible values include: 'openUrl', 'imBack', 'postBack', 'playAudio', 'playVideo', - 'showImage', 'downloadFile', 'signin', 'call', 'payment', 'messageBack' + 'showImage', 'downloadFile', 'signin', 'call', 'messageBack' :type type: str or ~botframework.connector.models.ActionTypes :param title: Text description which appears on the button :type title: str @@ -1128,31 +1128,6 @@ def __init__(self, **kwargs): self.type = kwargs.get("type", None) -class MicrosoftPayMethodData(Model): - """W3C Payment Method Data for Microsoft Pay. - - :param merchant_id: Microsoft Pay Merchant ID - :type merchant_id: str - :param supported_networks: Supported payment networks (e.g., "visa" and - "mastercard") - :type supported_networks: list[str] - :param supported_types: Supported payment types (e.g., "credit") - :type supported_types: list[str] - """ - - _attribute_map = { - "merchant_id": {"key": "merchantId", "type": "str"}, - "supported_networks": {"key": "supportedNetworks", "type": "[str]"}, - "supported_types": {"key": "supportedTypes", "type": "[str]"}, - } - - def __init__(self, **kwargs): - super(MicrosoftPayMethodData, self).__init__(**kwargs) - self.merchant_id = kwargs.get("merchant_id", None) - self.supported_networks = kwargs.get("supported_networks", None) - self.supported_types = kwargs.get("supported_types", None) - - class OAuthCard(Model): """A card representing a request to perform a sign in via OAuth. @@ -1197,457 +1172,6 @@ def __init__(self, **kwargs): self.members = kwargs.get("members", None) -class PaymentAddress(Model): - """Address within a Payment Request. - - :param country: This is the CLDR (Common Locale Data Repository) region - code. For example, US, GB, CN, or JP - :type country: str - :param address_line: This is the most specific part of the address. It can - include, for example, a street name, a house number, apartment number, a - rural delivery route, descriptive instructions, or a post office box - number. - :type address_line: list[str] - :param region: This is the top level administrative subdivision of the - country. For example, this can be a state, a province, an oblast, or a - prefecture. - :type region: str - :param city: This is the city/town portion of the address. - :type city: str - :param dependent_locality: This is the dependent locality or sublocality - within a city. For example, used for neighborhoods, boroughs, districts, - or UK dependent localities. - :type dependent_locality: str - :param postal_code: This is the postal code or ZIP code, also known as PIN - code in India. - :type postal_code: str - :param sorting_code: This is the sorting code as used in, for example, - France. - :type sorting_code: str - :param language_code: This is the BCP-47 language code for the address. - It's used to determine the field separators and the order of fields when - formatting the address for display. - :type language_code: str - :param organization: This is the organization, firm, company, or - institution at this address. - :type organization: str - :param recipient: This is the name of the recipient or contact person. - :type recipient: str - :param phone: This is the phone number of the recipient or contact person. - :type phone: str - """ - - _attribute_map = { - "country": {"key": "country", "type": "str"}, - "address_line": {"key": "addressLine", "type": "[str]"}, - "region": {"key": "region", "type": "str"}, - "city": {"key": "city", "type": "str"}, - "dependent_locality": {"key": "dependentLocality", "type": "str"}, - "postal_code": {"key": "postalCode", "type": "str"}, - "sorting_code": {"key": "sortingCode", "type": "str"}, - "language_code": {"key": "languageCode", "type": "str"}, - "organization": {"key": "organization", "type": "str"}, - "recipient": {"key": "recipient", "type": "str"}, - "phone": {"key": "phone", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentAddress, self).__init__(**kwargs) - self.country = kwargs.get("country", None) - self.address_line = kwargs.get("address_line", None) - self.region = kwargs.get("region", None) - self.city = kwargs.get("city", None) - self.dependent_locality = kwargs.get("dependent_locality", None) - self.postal_code = kwargs.get("postal_code", None) - self.sorting_code = kwargs.get("sorting_code", None) - self.language_code = kwargs.get("language_code", None) - self.organization = kwargs.get("organization", None) - self.recipient = kwargs.get("recipient", None) - self.phone = kwargs.get("phone", None) - - -class PaymentCurrencyAmount(Model): - """Supplies monetary amounts. - - :param currency: A currency identifier - :type currency: str - :param value: Decimal monetary value - :type value: str - :param currency_system: Currency system - :type currency_system: str - """ - - _attribute_map = { - "currency": {"key": "currency", "type": "str"}, - "value": {"key": "value", "type": "str"}, - "currency_system": {"key": "currencySystem", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentCurrencyAmount, self).__init__(**kwargs) - self.currency = kwargs.get("currency", None) - self.value = kwargs.get("value", None) - self.currency_system = kwargs.get("currency_system", None) - - -class PaymentDetails(Model): - """Provides information about the requested transaction. - - :param total: Contains the total amount of the payment request - :type total: ~botframework.connector.models.PaymentItem - :param display_items: Contains line items for the payment request that the - user agent may display - :type display_items: list[~botframework.connector.models.PaymentItem] - :param shipping_options: A sequence containing the different shipping - options for the user to choose from - :type shipping_options: - list[~botframework.connector.models.PaymentShippingOption] - :param modifiers: Contains modifiers for particular payment method - identifiers - :type modifiers: - list[~botframework.connector.models.PaymentDetailsModifier] - :param error: Error description - :type error: str - """ - - _attribute_map = { - "total": {"key": "total", "type": "PaymentItem"}, - "display_items": {"key": "displayItems", "type": "[PaymentItem]"}, - "shipping_options": { - "key": "shippingOptions", - "type": "[PaymentShippingOption]", - }, - "modifiers": {"key": "modifiers", "type": "[PaymentDetailsModifier]"}, - "error": {"key": "error", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentDetails, self).__init__(**kwargs) - self.total = kwargs.get("total", None) - self.display_items = kwargs.get("display_items", None) - self.shipping_options = kwargs.get("shipping_options", None) - self.modifiers = kwargs.get("modifiers", None) - self.error = kwargs.get("error", None) - - -class PaymentDetailsModifier(Model): - """Provides details that modify the PaymentDetails based on payment method - identifier. - - :param supported_methods: Contains a sequence of payment method - identifiers - :type supported_methods: list[str] - :param total: This value overrides the total field in the PaymentDetails - dictionary for the payment method identifiers in the supportedMethods - field - :type total: ~botframework.connector.models.PaymentItem - :param additional_display_items: Provides additional display items that - are appended to the displayItems field in the PaymentDetails dictionary - for the payment method identifiers in the supportedMethods field - :type additional_display_items: - list[~botframework.connector.models.PaymentItem] - :param data: A JSON-serializable object that provides optional information - that might be needed by the supported payment methods - :type data: object - """ - - _attribute_map = { - "supported_methods": {"key": "supportedMethods", "type": "[str]"}, - "total": {"key": "total", "type": "PaymentItem"}, - "additional_display_items": { - "key": "additionalDisplayItems", - "type": "[PaymentItem]", - }, - "data": {"key": "data", "type": "object"}, - } - - def __init__(self, **kwargs): - super(PaymentDetailsModifier, self).__init__(**kwargs) - self.supported_methods = kwargs.get("supported_methods", None) - self.total = kwargs.get("total", None) - self.additional_display_items = kwargs.get("additional_display_items", None) - self.data = kwargs.get("data", None) - - -class PaymentItem(Model): - """Indicates what the payment request is for and the value asked for. - - :param label: Human-readable description of the item - :type label: str - :param amount: Monetary amount for the item - :type amount: ~botframework.connector.models.PaymentCurrencyAmount - :param pending: When set to true this flag means that the amount field is - not final. - :type pending: bool - """ - - _attribute_map = { - "label": {"key": "label", "type": "str"}, - "amount": {"key": "amount", "type": "PaymentCurrencyAmount"}, - "pending": {"key": "pending", "type": "bool"}, - } - - def __init__(self, **kwargs): - super(PaymentItem, self).__init__(**kwargs) - self.label = kwargs.get("label", None) - self.amount = kwargs.get("amount", None) - self.pending = kwargs.get("pending", None) - - -class PaymentMethodData(Model): - """Indicates a set of supported payment methods and any associated payment - method specific data for those methods. - - :param supported_methods: Required sequence of strings containing payment - method identifiers for payment methods that the merchant web site accepts - :type supported_methods: list[str] - :param data: A JSON-serializable object that provides optional information - that might be needed by the supported payment methods - :type data: object - """ - - _attribute_map = { - "supported_methods": {"key": "supportedMethods", "type": "[str]"}, - "data": {"key": "data", "type": "object"}, - } - - def __init__(self, **kwargs): - super(PaymentMethodData, self).__init__(**kwargs) - self.supported_methods = kwargs.get("supported_methods", None) - self.data = kwargs.get("data", None) - - -class PaymentOptions(Model): - """Provides information about the options desired for the payment request. - - :param request_payer_name: Indicates whether the user agent should collect - and return the payer's name as part of the payment request - :type request_payer_name: bool - :param request_payer_email: Indicates whether the user agent should - collect and return the payer's email address as part of the payment - request - :type request_payer_email: bool - :param request_payer_phone: Indicates whether the user agent should - collect and return the payer's phone number as part of the payment request - :type request_payer_phone: bool - :param request_shipping: Indicates whether the user agent should collect - and return a shipping address as part of the payment request - :type request_shipping: bool - :param shipping_type: If requestShipping is set to true, then the - shippingType field may be used to influence the way the user agent - presents the user interface for gathering the shipping address - :type shipping_type: str - """ - - _attribute_map = { - "request_payer_name": {"key": "requestPayerName", "type": "bool"}, - "request_payer_email": {"key": "requestPayerEmail", "type": "bool"}, - "request_payer_phone": {"key": "requestPayerPhone", "type": "bool"}, - "request_shipping": {"key": "requestShipping", "type": "bool"}, - "shipping_type": {"key": "shippingType", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentOptions, self).__init__(**kwargs) - self.request_payer_name = kwargs.get("request_payer_name", None) - self.request_payer_email = kwargs.get("request_payer_email", None) - self.request_payer_phone = kwargs.get("request_payer_phone", None) - self.request_shipping = kwargs.get("request_shipping", None) - self.shipping_type = kwargs.get("shipping_type", None) - - -class PaymentRequest(Model): - """A request to make a payment. - - :param id: ID of this payment request - :type id: str - :param method_data: Allowed payment methods for this request - :type method_data: list[~botframework.connector.models.PaymentMethodData] - :param details: Details for this request - :type details: ~botframework.connector.models.PaymentDetails - :param options: Provides information about the options desired for the - payment request - :type options: ~botframework.connector.models.PaymentOptions - :param expires: Expiration for this request, in ISO 8601 duration format - (e.g., 'P1D') - :type expires: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "method_data": {"key": "methodData", "type": "[PaymentMethodData]"}, - "details": {"key": "details", "type": "PaymentDetails"}, - "options": {"key": "options", "type": "PaymentOptions"}, - "expires": {"key": "expires", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentRequest, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.method_data = kwargs.get("method_data", None) - self.details = kwargs.get("details", None) - self.options = kwargs.get("options", None) - self.expires = kwargs.get("expires", None) - - -class PaymentRequestComplete(Model): - """Payload delivered when completing a payment request. - - :param id: Payment request ID - :type id: str - :param payment_request: Initial payment request - :type payment_request: ~botframework.connector.models.PaymentRequest - :param payment_response: Corresponding payment response - :type payment_response: ~botframework.connector.models.PaymentResponse - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "payment_request": {"key": "paymentRequest", "type": "PaymentRequest"}, - "payment_response": {"key": "paymentResponse", "type": "PaymentResponse"}, - } - - def __init__(self, **kwargs): - super(PaymentRequestComplete, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.payment_request = kwargs.get("payment_request", None) - self.payment_response = kwargs.get("payment_response", None) - - -class PaymentRequestCompleteResult(Model): - """Result from a completed payment request. - - :param result: Result of the payment request completion - :type result: str - """ - - _attribute_map = {"result": {"key": "result", "type": "str"}} - - def __init__(self, **kwargs): - super(PaymentRequestCompleteResult, self).__init__(**kwargs) - self.result = kwargs.get("result", None) - - -class PaymentRequestUpdate(Model): - """An update to a payment request. - - :param id: ID for the payment request to update - :type id: str - :param details: Update payment details - :type details: ~botframework.connector.models.PaymentDetails - :param shipping_address: Updated shipping address - :type shipping_address: ~botframework.connector.models.PaymentAddress - :param shipping_option: Updated shipping options - :type shipping_option: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "details": {"key": "details", "type": "PaymentDetails"}, - "shipping_address": {"key": "shippingAddress", "type": "PaymentAddress"}, - "shipping_option": {"key": "shippingOption", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentRequestUpdate, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.details = kwargs.get("details", None) - self.shipping_address = kwargs.get("shipping_address", None) - self.shipping_option = kwargs.get("shipping_option", None) - - -class PaymentRequestUpdateResult(Model): - """A result object from a Payment Request Update invoke operation. - - :param details: Update payment details - :type details: ~botframework.connector.models.PaymentDetails - """ - - _attribute_map = {"details": {"key": "details", "type": "PaymentDetails"}} - - def __init__(self, **kwargs): - super(PaymentRequestUpdateResult, self).__init__(**kwargs) - self.details = kwargs.get("details", None) - - -class PaymentResponse(Model): - """A PaymentResponse is returned when a user has selected a payment method and - approved a payment request. - - :param method_name: The payment method identifier for the payment method - that the user selected to fulfil the transaction - :type method_name: str - :param details: A JSON-serializable object that provides a payment method - specific message used by the merchant to process the transaction and - determine successful fund transfer - :type details: object - :param shipping_address: If the requestShipping flag was set to true in - the PaymentOptions passed to the PaymentRequest constructor, then - shippingAddress will be the full and final shipping address chosen by the - user - :type shipping_address: ~botframework.connector.models.PaymentAddress - :param shipping_option: If the requestShipping flag was set to true in the - PaymentOptions passed to the PaymentRequest constructor, then - shippingOption will be the id attribute of the selected shipping option - :type shipping_option: str - :param payer_email: If the requestPayerEmail flag was set to true in the - PaymentOptions passed to the PaymentRequest constructor, then payerEmail - will be the email address chosen by the user - :type payer_email: str - :param payer_phone: If the requestPayerPhone flag was set to true in the - PaymentOptions passed to the PaymentRequest constructor, then payerPhone - will be the phone number chosen by the user - :type payer_phone: str - """ - - _attribute_map = { - "method_name": {"key": "methodName", "type": "str"}, - "details": {"key": "details", "type": "object"}, - "shipping_address": {"key": "shippingAddress", "type": "PaymentAddress"}, - "shipping_option": {"key": "shippingOption", "type": "str"}, - "payer_email": {"key": "payerEmail", "type": "str"}, - "payer_phone": {"key": "payerPhone", "type": "str"}, - } - - def __init__(self, **kwargs): - super(PaymentResponse, self).__init__(**kwargs) - self.method_name = kwargs.get("method_name", None) - self.details = kwargs.get("details", None) - self.shipping_address = kwargs.get("shipping_address", None) - self.shipping_option = kwargs.get("shipping_option", None) - self.payer_email = kwargs.get("payer_email", None) - self.payer_phone = kwargs.get("payer_phone", None) - - -class PaymentShippingOption(Model): - """Describes a shipping option. - - :param id: String identifier used to reference this PaymentShippingOption - :type id: str - :param label: Human-readable description of the item - :type label: str - :param amount: Contains the monetary amount for the item - :type amount: ~botframework.connector.models.PaymentCurrencyAmount - :param selected: Indicates whether this is the default selected - PaymentShippingOption - :type selected: bool - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "label": {"key": "label", "type": "str"}, - "amount": {"key": "amount", "type": "PaymentCurrencyAmount"}, - "selected": {"key": "selected", "type": "bool"}, - } - - def __init__(self, **kwargs): - super(PaymentShippingOption, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.label = kwargs.get("label", None) - self.amount = kwargs.get("amount", None) - self.selected = kwargs.get("selected", None) - - class Place(Model): """Place (entity type: "https://schema.org/Place"). diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 58caa1567..fe583a9b8 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -627,7 +627,7 @@ class CardAction(Model): :param type: The type of action implemented by this button. Possible values include: 'openUrl', 'imBack', 'postBack', 'playAudio', 'playVideo', - 'showImage', 'downloadFile', 'signin', 'call', 'payment', 'messageBack' + 'showImage', 'downloadFile', 'signin', 'call', 'messageBack' :type type: str or ~botframework.connector.models.ActionTypes :param title: Text description which appears on the button :type title: str @@ -1339,38 +1339,6 @@ def __init__(self, *, type=None, **kwargs) -> None: self.type = type -class MicrosoftPayMethodData(Model): - """W3C Payment Method Data for Microsoft Pay. - - :param merchant_id: Microsoft Pay Merchant ID - :type merchant_id: str - :param supported_networks: Supported payment networks (e.g., "visa" and - "mastercard") - :type supported_networks: list[str] - :param supported_types: Supported payment types (e.g., "credit") - :type supported_types: list[str] - """ - - _attribute_map = { - "merchant_id": {"key": "merchantId", "type": "str"}, - "supported_networks": {"key": "supportedNetworks", "type": "[str]"}, - "supported_types": {"key": "supportedTypes", "type": "[str]"}, - } - - def __init__( - self, - *, - merchant_id: str = None, - supported_networks=None, - supported_types=None, - **kwargs - ) -> None: - super(MicrosoftPayMethodData, self).__init__(**kwargs) - self.merchant_id = merchant_id - self.supported_networks = supported_networks - self.supported_types = supported_types - - class OAuthCard(Model): """A card representing a request to perform a sign in via OAuth. @@ -1419,544 +1387,6 @@ def __init__( self.members = members -class PaymentAddress(Model): - """Address within a Payment Request. - - :param country: This is the CLDR (Common Locale Data Repository) region - code. For example, US, GB, CN, or JP - :type country: str - :param address_line: This is the most specific part of the address. It can - include, for example, a street name, a house number, apartment number, a - rural delivery route, descriptive instructions, or a post office box - number. - :type address_line: list[str] - :param region: This is the top level administrative subdivision of the - country. For example, this can be a state, a province, an oblast, or a - prefecture. - :type region: str - :param city: This is the city/town portion of the address. - :type city: str - :param dependent_locality: This is the dependent locality or sublocality - within a city. For example, used for neighborhoods, boroughs, districts, - or UK dependent localities. - :type dependent_locality: str - :param postal_code: This is the postal code or ZIP code, also known as PIN - code in India. - :type postal_code: str - :param sorting_code: This is the sorting code as used in, for example, - France. - :type sorting_code: str - :param language_code: This is the BCP-47 language code for the address. - It's used to determine the field separators and the order of fields when - formatting the address for display. - :type language_code: str - :param organization: This is the organization, firm, company, or - institution at this address. - :type organization: str - :param recipient: This is the name of the recipient or contact person. - :type recipient: str - :param phone: This is the phone number of the recipient or contact person. - :type phone: str - """ - - _attribute_map = { - "country": {"key": "country", "type": "str"}, - "address_line": {"key": "addressLine", "type": "[str]"}, - "region": {"key": "region", "type": "str"}, - "city": {"key": "city", "type": "str"}, - "dependent_locality": {"key": "dependentLocality", "type": "str"}, - "postal_code": {"key": "postalCode", "type": "str"}, - "sorting_code": {"key": "sortingCode", "type": "str"}, - "language_code": {"key": "languageCode", "type": "str"}, - "organization": {"key": "organization", "type": "str"}, - "recipient": {"key": "recipient", "type": "str"}, - "phone": {"key": "phone", "type": "str"}, - } - - def __init__( - self, - *, - country: str = None, - address_line=None, - region: str = None, - city: str = None, - dependent_locality: str = None, - postal_code: str = None, - sorting_code: str = None, - language_code: str = None, - organization: str = None, - recipient: str = None, - phone: str = None, - **kwargs - ) -> None: - super(PaymentAddress, self).__init__(**kwargs) - self.country = country - self.address_line = address_line - self.region = region - self.city = city - self.dependent_locality = dependent_locality - self.postal_code = postal_code - self.sorting_code = sorting_code - self.language_code = language_code - self.organization = organization - self.recipient = recipient - self.phone = phone - - -class PaymentCurrencyAmount(Model): - """Supplies monetary amounts. - - :param currency: A currency identifier - :type currency: str - :param value: Decimal monetary value - :type value: str - :param currency_system: Currency system - :type currency_system: str - """ - - _attribute_map = { - "currency": {"key": "currency", "type": "str"}, - "value": {"key": "value", "type": "str"}, - "currency_system": {"key": "currencySystem", "type": "str"}, - } - - def __init__( - self, - *, - currency: str = None, - value: str = None, - currency_system: str = None, - **kwargs - ) -> None: - super(PaymentCurrencyAmount, self).__init__(**kwargs) - self.currency = currency - self.value = value - self.currency_system = currency_system - - -class PaymentDetails(Model): - """Provides information about the requested transaction. - - :param total: Contains the total amount of the payment request - :type total: ~botframework.connector.models.PaymentItem - :param display_items: Contains line items for the payment request that the - user agent may display - :type display_items: list[~botframework.connector.models.PaymentItem] - :param shipping_options: A sequence containing the different shipping - options for the user to choose from - :type shipping_options: - list[~botframework.connector.models.PaymentShippingOption] - :param modifiers: Contains modifiers for particular payment method - identifiers - :type modifiers: - list[~botframework.connector.models.PaymentDetailsModifier] - :param error: Error description - :type error: str - """ - - _attribute_map = { - "total": {"key": "total", "type": "PaymentItem"}, - "display_items": {"key": "displayItems", "type": "[PaymentItem]"}, - "shipping_options": { - "key": "shippingOptions", - "type": "[PaymentShippingOption]", - }, - "modifiers": {"key": "modifiers", "type": "[PaymentDetailsModifier]"}, - "error": {"key": "error", "type": "str"}, - } - - def __init__( - self, - *, - total=None, - display_items=None, - shipping_options=None, - modifiers=None, - error: str = None, - **kwargs - ) -> None: - super(PaymentDetails, self).__init__(**kwargs) - self.total = total - self.display_items = display_items - self.shipping_options = shipping_options - self.modifiers = modifiers - self.error = error - - -class PaymentDetailsModifier(Model): - """Provides details that modify the PaymentDetails based on payment method - identifier. - - :param supported_methods: Contains a sequence of payment method - identifiers - :type supported_methods: list[str] - :param total: This value overrides the total field in the PaymentDetails - dictionary for the payment method identifiers in the supportedMethods - field - :type total: ~botframework.connector.models.PaymentItem - :param additional_display_items: Provides additional display items that - are appended to the displayItems field in the PaymentDetails dictionary - for the payment method identifiers in the supportedMethods field - :type additional_display_items: - list[~botframework.connector.models.PaymentItem] - :param data: A JSON-serializable object that provides optional information - that might be needed by the supported payment methods - :type data: object - """ - - _attribute_map = { - "supported_methods": {"key": "supportedMethods", "type": "[str]"}, - "total": {"key": "total", "type": "PaymentItem"}, - "additional_display_items": { - "key": "additionalDisplayItems", - "type": "[PaymentItem]", - }, - "data": {"key": "data", "type": "object"}, - } - - def __init__( - self, - *, - supported_methods=None, - total=None, - additional_display_items=None, - data=None, - **kwargs - ) -> None: - super(PaymentDetailsModifier, self).__init__(**kwargs) - self.supported_methods = supported_methods - self.total = total - self.additional_display_items = additional_display_items - self.data = data - - -class PaymentItem(Model): - """Indicates what the payment request is for and the value asked for. - - :param label: Human-readable description of the item - :type label: str - :param amount: Monetary amount for the item - :type amount: ~botframework.connector.models.PaymentCurrencyAmount - :param pending: When set to true this flag means that the amount field is - not final. - :type pending: bool - """ - - _attribute_map = { - "label": {"key": "label", "type": "str"}, - "amount": {"key": "amount", "type": "PaymentCurrencyAmount"}, - "pending": {"key": "pending", "type": "bool"}, - } - - def __init__( - self, *, label: str = None, amount=None, pending: bool = None, **kwargs - ) -> None: - super(PaymentItem, self).__init__(**kwargs) - self.label = label - self.amount = amount - self.pending = pending - - -class PaymentMethodData(Model): - """Indicates a set of supported payment methods and any associated payment - method specific data for those methods. - - :param supported_methods: Required sequence of strings containing payment - method identifiers for payment methods that the merchant web site accepts - :type supported_methods: list[str] - :param data: A JSON-serializable object that provides optional information - that might be needed by the supported payment methods - :type data: object - """ - - _attribute_map = { - "supported_methods": {"key": "supportedMethods", "type": "[str]"}, - "data": {"key": "data", "type": "object"}, - } - - def __init__(self, *, supported_methods=None, data=None, **kwargs) -> None: - super(PaymentMethodData, self).__init__(**kwargs) - self.supported_methods = supported_methods - self.data = data - - -class PaymentOptions(Model): - """Provides information about the options desired for the payment request. - - :param request_payer_name: Indicates whether the user agent should collect - and return the payer's name as part of the payment request - :type request_payer_name: bool - :param request_payer_email: Indicates whether the user agent should - collect and return the payer's email address as part of the payment - request - :type request_payer_email: bool - :param request_payer_phone: Indicates whether the user agent should - collect and return the payer's phone number as part of the payment request - :type request_payer_phone: bool - :param request_shipping: Indicates whether the user agent should collect - and return a shipping address as part of the payment request - :type request_shipping: bool - :param shipping_type: If requestShipping is set to true, then the - shippingType field may be used to influence the way the user agent - presents the user interface for gathering the shipping address - :type shipping_type: str - """ - - _attribute_map = { - "request_payer_name": {"key": "requestPayerName", "type": "bool"}, - "request_payer_email": {"key": "requestPayerEmail", "type": "bool"}, - "request_payer_phone": {"key": "requestPayerPhone", "type": "bool"}, - "request_shipping": {"key": "requestShipping", "type": "bool"}, - "shipping_type": {"key": "shippingType", "type": "str"}, - } - - def __init__( - self, - *, - request_payer_name: bool = None, - request_payer_email: bool = None, - request_payer_phone: bool = None, - request_shipping: bool = None, - shipping_type: str = None, - **kwargs - ) -> None: - super(PaymentOptions, self).__init__(**kwargs) - self.request_payer_name = request_payer_name - self.request_payer_email = request_payer_email - self.request_payer_phone = request_payer_phone - self.request_shipping = request_shipping - self.shipping_type = shipping_type - - -class PaymentRequest(Model): - """A request to make a payment. - - :param id: ID of this payment request - :type id: str - :param method_data: Allowed payment methods for this request - :type method_data: list[~botframework.connector.models.PaymentMethodData] - :param details: Details for this request - :type details: ~botframework.connector.models.PaymentDetails - :param options: Provides information about the options desired for the - payment request - :type options: ~botframework.connector.models.PaymentOptions - :param expires: Expiration for this request, in ISO 8601 duration format - (e.g., 'P1D') - :type expires: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "method_data": {"key": "methodData", "type": "[PaymentMethodData]"}, - "details": {"key": "details", "type": "PaymentDetails"}, - "options": {"key": "options", "type": "PaymentOptions"}, - "expires": {"key": "expires", "type": "str"}, - } - - def __init__( - self, - *, - id: str = None, - method_data=None, - details=None, - options=None, - expires: str = None, - **kwargs - ) -> None: - super(PaymentRequest, self).__init__(**kwargs) - self.id = id - self.method_data = method_data - self.details = details - self.options = options - self.expires = expires - - -class PaymentRequestComplete(Model): - """Payload delivered when completing a payment request. - - :param id: Payment request ID - :type id: str - :param payment_request: Initial payment request - :type payment_request: ~botframework.connector.models.PaymentRequest - :param payment_response: Corresponding payment response - :type payment_response: ~botframework.connector.models.PaymentResponse - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "payment_request": {"key": "paymentRequest", "type": "PaymentRequest"}, - "payment_response": {"key": "paymentResponse", "type": "PaymentResponse"}, - } - - def __init__( - self, *, id: str = None, payment_request=None, payment_response=None, **kwargs - ) -> None: - super(PaymentRequestComplete, self).__init__(**kwargs) - self.id = id - self.payment_request = payment_request - self.payment_response = payment_response - - -class PaymentRequestCompleteResult(Model): - """Result from a completed payment request. - - :param result: Result of the payment request completion - :type result: str - """ - - _attribute_map = {"result": {"key": "result", "type": "str"}} - - def __init__(self, *, result: str = None, **kwargs) -> None: - super(PaymentRequestCompleteResult, self).__init__(**kwargs) - self.result = result - - -class PaymentRequestUpdate(Model): - """An update to a payment request. - - :param id: ID for the payment request to update - :type id: str - :param details: Update payment details - :type details: ~botframework.connector.models.PaymentDetails - :param shipping_address: Updated shipping address - :type shipping_address: ~botframework.connector.models.PaymentAddress - :param shipping_option: Updated shipping options - :type shipping_option: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "details": {"key": "details", "type": "PaymentDetails"}, - "shipping_address": {"key": "shippingAddress", "type": "PaymentAddress"}, - "shipping_option": {"key": "shippingOption", "type": "str"}, - } - - def __init__( - self, - *, - id: str = None, - details=None, - shipping_address=None, - shipping_option: str = None, - **kwargs - ) -> None: - super(PaymentRequestUpdate, self).__init__(**kwargs) - self.id = id - self.details = details - self.shipping_address = shipping_address - self.shipping_option = shipping_option - - -class PaymentRequestUpdateResult(Model): - """A result object from a Payment Request Update invoke operation. - - :param details: Update payment details - :type details: ~botframework.connector.models.PaymentDetails - """ - - _attribute_map = {"details": {"key": "details", "type": "PaymentDetails"}} - - def __init__(self, *, details=None, **kwargs) -> None: - super(PaymentRequestUpdateResult, self).__init__(**kwargs) - self.details = details - - -class PaymentResponse(Model): - """A PaymentResponse is returned when a user has selected a payment method and - approved a payment request. - - :param method_name: The payment method identifier for the payment method - that the user selected to fulfil the transaction - :type method_name: str - :param details: A JSON-serializable object that provides a payment method - specific message used by the merchant to process the transaction and - determine successful fund transfer - :type details: object - :param shipping_address: If the requestShipping flag was set to true in - the PaymentOptions passed to the PaymentRequest constructor, then - shippingAddress will be the full and final shipping address chosen by the - user - :type shipping_address: ~botframework.connector.models.PaymentAddress - :param shipping_option: If the requestShipping flag was set to true in the - PaymentOptions passed to the PaymentRequest constructor, then - shippingOption will be the id attribute of the selected shipping option - :type shipping_option: str - :param payer_email: If the requestPayerEmail flag was set to true in the - PaymentOptions passed to the PaymentRequest constructor, then payerEmail - will be the email address chosen by the user - :type payer_email: str - :param payer_phone: If the requestPayerPhone flag was set to true in the - PaymentOptions passed to the PaymentRequest constructor, then payerPhone - will be the phone number chosen by the user - :type payer_phone: str - """ - - _attribute_map = { - "method_name": {"key": "methodName", "type": "str"}, - "details": {"key": "details", "type": "object"}, - "shipping_address": {"key": "shippingAddress", "type": "PaymentAddress"}, - "shipping_option": {"key": "shippingOption", "type": "str"}, - "payer_email": {"key": "payerEmail", "type": "str"}, - "payer_phone": {"key": "payerPhone", "type": "str"}, - } - - def __init__( - self, - *, - method_name: str = None, - details=None, - shipping_address=None, - shipping_option: str = None, - payer_email: str = None, - payer_phone: str = None, - **kwargs - ) -> None: - super(PaymentResponse, self).__init__(**kwargs) - self.method_name = method_name - self.details = details - self.shipping_address = shipping_address - self.shipping_option = shipping_option - self.payer_email = payer_email - self.payer_phone = payer_phone - - -class PaymentShippingOption(Model): - """Describes a shipping option. - - :param id: String identifier used to reference this PaymentShippingOption - :type id: str - :param label: Human-readable description of the item - :type label: str - :param amount: Contains the monetary amount for the item - :type amount: ~botframework.connector.models.PaymentCurrencyAmount - :param selected: Indicates whether this is the default selected - PaymentShippingOption - :type selected: bool - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "label": {"key": "label", "type": "str"}, - "amount": {"key": "amount", "type": "PaymentCurrencyAmount"}, - "selected": {"key": "selected", "type": "bool"}, - } - - def __init__( - self, - *, - id: str = None, - label: str = None, - amount=None, - selected: bool = None, - **kwargs - ) -> None: - super(PaymentShippingOption, self).__init__(**kwargs) - self.id = id - self.label = label - self.amount = amount - self.selected = selected - - class Place(Model): """Place (entity type: "https://schema.org/Place"). diff --git a/libraries/swagger/ConnectorAPI.json b/libraries/swagger/ConnectorAPI.json index 827186e12..f3a5b6e49 100644 --- a/libraries/swagger/ConnectorAPI.json +++ b/libraries/swagger/ConnectorAPI.json @@ -1,2674 +1,2691 @@ { - "swagger": "2.0", - "info": { - "version": "v3", - "title": "Microsoft Bot Connector API - v3.0", - "description": "The Bot Connector REST API allows your bot to send and receive messages to channels configured in the\r\n[Bot Framework Developer Portal](https://dev.botframework.com). The Connector service uses industry-standard REST\r\nand JSON over HTTPS.\r\n\r\nClient libraries for this REST API are available. See below for a list.\r\n\r\nMany bots will use both the Bot Connector REST API and the associated [Bot State REST API](/en-us/restapi/state). The\r\nBot State REST API allows a bot to store and retrieve state associated with users and conversations.\r\n\r\nAuthentication for both the Bot Connector and Bot State REST APIs is accomplished with JWT Bearer tokens, and is\r\ndescribed in detail in the [Connector Authentication](/en-us/restapi/authentication) document.\r\n\r\n# Client Libraries for the Bot Connector REST API\r\n\r\n* [Bot Builder for C#](/en-us/csharp/builder/sdkreference/)\r\n* [Bot Builder for Node.js](/en-us/node/builder/overview/)\r\n* Generate your own from the [Connector API Swagger file](https://raw.githubusercontent.com/Microsoft/BotBuilder/master/CSharp/Library/Microsoft.Bot.Connector.Shared/Swagger/ConnectorAPI.json)\r\n\r\n© 2016 Microsoft", - "termsOfService": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright/default.aspx", - "contact": { - "name": "Bot Framework", - "url": "https://botframework.com", - "email": "botframework@microsoft.com" - }, - "license": { - "name": "The MIT License (MIT)", - "url": "https://opensource.org/licenses/MIT" - } - }, - "host": "api.botframework.com", - "schemes": [ - "https" - ], - "paths": { - "/v3/attachments/{attachmentId}": { - "get": { - "tags": [ - "Attachments" - ], - "summary": "GetAttachmentInfo", - "description": "Get AttachmentInfo structure describing the attachment views", - "operationId": "Attachments_GetAttachmentInfo", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "attachmentId", - "in": "path", - "description": "attachment id", - "required": true, - "type": "string" + "swagger": "2.0", + "info": { + "version": "v3", + "title": "Microsoft Bot Connector API - v3.0", + "description": "The Bot Connector REST API allows your bot to send and receive messages to channels configured in the\r\n[Bot Framework Developer Portal](https://dev.botframework.com). The Connector service uses industry-standard REST\r\nand JSON over HTTPS.\r\n\r\nClient libraries for this REST API are available. See below for a list.\r\n\r\nMany bots will use both the Bot Connector REST API and the associated [Bot State REST API](/en-us/restapi/state). The\r\nBot State REST API allows a bot to store and retrieve state associated with users and conversations.\r\n\r\nAuthentication for both the Bot Connector and Bot State REST APIs is accomplished with JWT Bearer tokens, and is\r\ndescribed in detail in the [Connector Authentication](/en-us/restapi/authentication) document.\r\n\r\n# Client Libraries for the Bot Connector REST API\r\n\r\n* [Bot Builder for C#](/en-us/csharp/builder/sdkreference/)\r\n* [Bot Builder for Node.js](/en-us/node/builder/overview/)\r\n* Generate your own from the [Connector API Swagger file](https://raw.githubusercontent.com/Microsoft/BotBuilder/master/CSharp/Library/Microsoft.Bot.Connector.Shared/Swagger/ConnectorAPI.json)\r\n\r\n© 2016 Microsoft", + "termsOfService": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright/default.aspx", + "contact": { + "name": "Bot Framework", + "url": "https://botframework.com", + "email": "botframework@microsoft.com" + }, + "license": { + "name": "The MIT License (MIT)", + "url": "https://opensource.org/licenses/MIT" + } + }, + "host": "api.botframework.com", + "schemes": [ + "https" + ], + "paths": { + "/v3/attachments/{attachmentId}": { + "get": { + "tags": [ + "Attachments" + ], + "summary": "GetAttachmentInfo", + "description": "Get AttachmentInfo structure describing the attachment views", + "operationId": "Attachments_GetAttachmentInfo", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "attachmentId", + "in": "path", + "description": "attachment id", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "An attachmentInfo object is returned which describes the:\r\n* type of the attachment\r\n* name of the attachment\r\n\r\n\r\nand an array of views:\r\n* Size - size of the object\r\n* ViewId - View Id which can be used to fetch a variation on the content (ex: original or thumbnail)", + "schema": { + "$ref": "#/definitions/AttachmentInfo" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } } - ], - "responses": { - "200": { - "description": "An attachmentInfo object is returned which describes the:\r\n* type of the attachment\r\n* name of the attachment\r\n\r\n\r\nand an array of views:\r\n* Size - size of the object\r\n* ViewId - View Id which can be used to fetch a variation on the content (ex: original or thumbnail)", - "schema": { - "$ref": "#/definitions/AttachmentInfo" + } + }, + "/v3/attachments/{attachmentId}/views/{viewId}": { + "get": { + "tags": [ + "Attachments" + ], + "summary": "GetAttachment", + "description": "Get the named view as binary content", + "operationId": "Attachments_GetAttachment", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "attachmentId", + "in": "path", + "description": "attachment id", + "required": true, + "type": "string" + }, + { + "name": "viewId", + "in": "path", + "description": "View id from attachmentInfo", + "required": true, + "type": "string" } - }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + ], + "responses": { + "200": { + "description": "Attachment stream", + "schema": { + "format": "byte", + "type": "file" + } + }, + "301": { + "description": "The Location header describes where the content is now." + }, + "302": { + "description": "The Location header describes where the content is now." + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } } - } - }, - "/v3/attachments/{attachmentId}/views/{viewId}": { - "get": { - "tags": [ - "Attachments" - ], - "summary": "GetAttachment", - "description": "Get the named view as binary content", - "operationId": "Attachments_GetAttachment", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "attachmentId", - "in": "path", - "description": "attachment id", - "required": true, - "type": "string" - }, - { - "name": "viewId", - "in": "path", - "description": "View id from attachmentInfo", - "required": true, - "type": "string" + }, + "/v3/conversations": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "GetConversations", + "description": "List the Conversations in which this bot has participated.\r\n\r\nGET from this method with a skip token\r\n\r\nThe return value is a ConversationsResult, which contains an array of ConversationMembers and a skip token. If the skip token is not empty, then \r\nthere are further values to be returned. Call this method again with the returned token to get more values.\r\n\r\nEach ConversationMembers object contains the ID of the conversation and an array of ChannelAccounts that describe the members of the conversation.", + "operationId": "Conversations_GetConversations", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "continuationToken", + "in": "query", + "description": "skip or continuation token", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "An object will be returned containing \r\n* an array (Conversations) of ConversationMembers objects\r\n* a continuation token\r\n\r\nEach ConversationMembers object contains:\r\n* the Id of the conversation\r\n* an array (Members) of ChannelAccount objects", + "schema": { + "$ref": "#/definitions/ConversationsResult" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } } - ], - "responses": { - "200": { - "description": "Attachment stream", - "schema": { - "format": "byte", - "type": "file" + }, + "post": { + "tags": [ + "Conversations" + ], + "summary": "CreateConversation", + "description": "Create a new Conversation.\r\n\r\nPOST to this method with a\r\n* Bot being the bot creating the conversation\r\n* IsGroup set to true if this is not a direct message (default is false)\r\n* Array containing the members to include in the conversation\r\n\r\nThe return value is a ResourceResponse which contains a conversation id which is suitable for use\r\nin the message payload and REST API uris.\r\n\r\nMost channels only support the semantics of bots initiating a direct message conversation. An example of how to do that would be:\r\n\r\n```\r\nvar resource = await connector.conversations.CreateConversation(new ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { new ChannelAccount(\"user1\") } );\r\nawait connect.Conversations.SendToConversationAsync(resource.Id, new Activity() ... ) ;\r\n\r\n```", + "operationId": "Conversations_CreateConversation", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "parameters", + "in": "body", + "description": "Parameters to create the conversation from", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationParameters" + } } - }, - "301": { - "description": "The Location header describes where the content is now." - }, - "302": { - "description": "The Location header describes where the content is now." - }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + ], + "responses": { + "200": { + "description": "An object will be returned containing \r\n* the ID for the conversation\r\n* ActivityId for the activity if provided. If ActivityId is null then the channel doesn't support returning resource id's for activity.", + "schema": { + "$ref": "#/definitions/ConversationResourceResponse" + } + }, + "201": { + "description": "An object will be returned containing \r\n* the ID for the conversation\r\n* ActivityId for the activity if provided. If ActivityId is null then the channel doesn't support returning resource id's for activity.", + "schema": { + "$ref": "#/definitions/ConversationResourceResponse" + } + }, + "202": { + "description": "An object will be returned containing \r\n* the ID for the conversation\r\n* ActivityId for the activity if provided. If ActivityId is null then the channel doesn't support returning resource id's for activity.", + "schema": { + "$ref": "#/definitions/ConversationResourceResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } } - } - }, - "/v3/conversations": { - "get": { - "tags": [ - "Conversations" - ], - "summary": "GetConversations", - "description": "List the Conversations in which this bot has participated.\r\n\r\nGET from this method with a skip token\r\n\r\nThe return value is a ConversationsResult, which contains an array of ConversationMembers and a skip token. If the skip token is not empty, then \r\nthere are further values to be returned. Call this method again with the returned token to get more values.\r\n\r\nEach ConversationMembers object contains the ID of the conversation and an array of ChannelAccounts that describe the members of the conversation.", - "operationId": "Conversations_GetConversations", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "continuationToken", - "in": "query", - "description": "skip or continuation token", - "required": false, - "type": "string" - } - ], - "responses": { - "200": { - "description": "An object will be returned containing \r\n* an array (Conversations) of ConversationMembers objects\r\n* a continuation token\r\n\r\nEach ConversationMembers object contains:\r\n* the Id of the conversation\r\n* an array (Members) of ChannelAccount objects", - "schema": { - "$ref": "#/definitions/ConversationsResult" + }, + "/v3/conversations/{conversationId}/activities": { + "post": { + "tags": [ + "Conversations" + ], + "summary": "SendToConversation", + "description": "This method allows you to send an activity to the end of a conversation.\r\n\r\nThis is slightly different from ReplyToActivity().\r\n* SendToConversation(conversationId) - will append the activity to the end of the conversation according to the timestamp or semantics of the channel.\r\n* ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply to another activity, if the channel supports it. If the channel does not support nested replies, ReplyToActivity falls back to SendToConversation.\r\n\r\nUse ReplyToActivity when replying to a specific activity in the conversation.\r\n\r\nUse SendToConversation in all other cases.", + "operationId": "Conversations_SendToConversation", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "activity", + "in": "body", + "description": "Activity to send", + "required": true, + "schema": { + "$ref": "#/definitions/Activity" + } } - }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + ], + "responses": { + "200": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "201": { + "description": "A ResourceResponse object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "202": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } } }, - "post": { - "tags": [ - "Conversations" - ], - "summary": "CreateConversation", - "description": "Create a new Conversation.\r\n\r\nPOST to this method with a\r\n* Bot being the bot creating the conversation\r\n* IsGroup set to true if this is not a direct message (default is false)\r\n* Array containing the members to include in the conversation\r\n\r\nThe return value is a ResourceResponse which contains a conversation id which is suitable for use\r\nin the message payload and REST API uris.\r\n\r\nMost channels only support the semantics of bots initiating a direct message conversation. An example of how to do that would be:\r\n\r\n```\r\nvar resource = await connector.conversations.CreateConversation(new ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { new ChannelAccount(\"user1\") } );\r\nawait connect.Conversations.SendToConversationAsync(resource.Id, new Activity() ... ) ;\r\n\r\n```", - "operationId": "Conversations_CreateConversation", - "consumes": [ - "application/json", - "text/json", - "application/xml", - "text/xml", - "application/x-www-form-urlencoded" - ], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "parameters", - "in": "body", - "description": "Parameters to create the conversation from", - "required": true, - "schema": { - "$ref": "#/definitions/ConversationParameters" + "/v3/conversations/{conversationId}/activities/history": { + "post": { + "tags": [ + "Conversations" + ], + "summary": "SendConversationHistory", + "description": "This method allows you to upload the historic activities to the conversation.\r\n\r\nSender must ensure that the historic activities have unique ids and appropriate timestamps. The ids are used by the client to deal with duplicate activities and the timestamps are used by the client to render the activities in the right order.", + "operationId": "Conversations_SendConversationHistory", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "history", + "in": "body", + "description": "Historic activities", + "required": true, + "schema": { + "$ref": "#/definitions/Transcript" + } + } + ], + "responses": { + "200": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "201": { + "description": "A ResourceResponse object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "202": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } - ], - "responses": { - "200": { - "description": "An object will be returned containing \r\n* the ID for the conversation\r\n* ActivityId for the activity if provided. If ActivityId is null then the channel doesn't support returning resource id's for activity.", - "schema": { - "$ref": "#/definitions/ConversationResourceResponse" + } + }, + "/v3/conversations/{conversationId}/activities/{activityId}": { + "put": { + "tags": [ + "Conversations" + ], + "summary": "UpdateActivity", + "description": "Edit an existing activity.\r\n\r\nSome channels allow you to edit an existing activity to reflect the new state of a bot conversation.\r\n\r\nFor example, you can remove buttons after someone has clicked \"Approve\" button.", + "operationId": "Conversations_UpdateActivity", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "activityId", + "in": "path", + "description": "activityId to update", + "required": true, + "type": "string" + }, + { + "name": "activity", + "in": "body", + "description": "replacement Activity", + "required": true, + "schema": { + "$ref": "#/definitions/Activity" + } } - }, - "201": { - "description": "An object will be returned containing \r\n* the ID for the conversation\r\n* ActivityId for the activity if provided. If ActivityId is null then the channel doesn't support returning resource id's for activity.", - "schema": { - "$ref": "#/definitions/ConversationResourceResponse" + ], + "responses": { + "200": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "201": { + "description": "A ResourceResponse object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "202": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } - }, - "202": { - "description": "An object will be returned containing \r\n* the ID for the conversation\r\n* ActivityId for the activity if provided. If ActivityId is null then the channel doesn't support returning resource id's for activity.", - "schema": { - "$ref": "#/definitions/ConversationResourceResponse" + } + }, + "post": { + "tags": [ + "Conversations" + ], + "summary": "ReplyToActivity", + "description": "This method allows you to reply to an activity.\r\n\r\nThis is slightly different from SendToConversation().\r\n* SendToConversation(conversationId) - will append the activity to the end of the conversation according to the timestamp or semantics of the channel.\r\n* ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply to another activity, if the channel supports it. If the channel does not support nested replies, ReplyToActivity falls back to SendToConversation.\r\n\r\nUse ReplyToActivity when replying to a specific activity in the conversation.\r\n\r\nUse SendToConversation in all other cases.", + "operationId": "Conversations_ReplyToActivity", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "activityId", + "in": "path", + "description": "activityId the reply is to (OPTIONAL)", + "required": true, + "type": "string" + }, + { + "name": "activity", + "in": "body", + "description": "Activity to send", + "required": true, + "schema": { + "$ref": "#/definitions/Activity" + } } - }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + ], + "responses": { + "200": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "201": { + "description": "A ResourceResponse object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "202": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } - } - } - }, - "/v3/conversations/{conversationId}/activities": { - "post": { - "tags": [ - "Conversations" - ], - "summary": "SendToConversation", - "description": "This method allows you to send an activity to the end of a conversation.\r\n\r\nThis is slightly different from ReplyToActivity().\r\n* SendToConversation(conversationId) - will append the activity to the end of the conversation according to the timestamp or semantics of the channel.\r\n* ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply to another activity, if the channel supports it. If the channel does not support nested replies, ReplyToActivity falls back to SendToConversation.\r\n\r\nUse ReplyToActivity when replying to a specific activity in the conversation.\r\n\r\nUse SendToConversation in all other cases.", - "operationId": "Conversations_SendToConversation", - "consumes": [ - "application/json", - "text/json", - "application/xml", - "text/xml", - "application/x-www-form-urlencoded" - ], - "produces": [ - "application/json", - "text/json" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, - "type": "string" - }, - { - "name": "activity", - "in": "body", - "description": "Activity to send", - "required": true, - "schema": { - "$ref": "#/definitions/Activity" + }, + "delete": { + "tags": [ + "Conversations" + ], + "summary": "DeleteActivity", + "description": "Delete an existing activity.\r\n\r\nSome channels allow you to delete an existing activity, and if successful this method will remove the specified activity.", + "operationId": "Conversations_DeleteActivity", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "activityId", + "in": "path", + "description": "activityId to delete", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "The operation succeeded, there is no response." + }, + "202": { + "description": "The request has been accepted for processing, but the processing has not been completed" + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } - ], - "responses": { - "200": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + } + }, + "/v3/conversations/{conversationId}/members": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "GetConversationMembers", + "description": "Enumerate the members of a conversation. \r\n\r\nThis REST API takes a ConversationId and returns an array of ChannelAccount objects representing the members of the conversation.", + "operationId": "Conversations_GetConversationMembers", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" } - }, - "201": { - "description": "A ResourceResponse object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + ], + "responses": { + "200": { + "description": "An array of ChannelAccount objects", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" + } + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } - }, - "202": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + } + } + }, + "/v3/conversations/{conversationId}/pagedmembers": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "GetConversationPagedMembers", + "description": "Enumerate the members of a conversation one page at a time.\r\n\r\nThis REST API takes a ConversationId. Optionally a pageSize and/or continuationToken can be provided. It returns a PagedMembersResult, which contains an array\r\nof ChannelAccounts representing the members of the conversation and a continuation token that can be used to get more values.\r\n\r\nOne page of ChannelAccounts records are returned with each call. The number of records in a page may vary between channels and calls. The pageSize parameter can be used as \r\na suggestion. If there are no additional results the response will not contain a continuation token. If there are no members in the conversation the Members will be empty or not present in the response.\r\n\r\nA response to a request that has a continuation token from a prior request may rarely return members from a previous request.", + "operationId": "Conversations_GetConversationPagedMembers", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "pageSize", + "in": "query", + "description": "Suggested page size", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "continuationToken", + "in": "query", + "description": "Continuation Token", + "required": false, + "type": "string" } - }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/PagedMembersResult" + } } } } - } - }, - "/v3/conversations/{conversationId}/activities/history": { - "post": { - "tags": [ - "Conversations" - ], - "summary": "SendConversationHistory", - "description": "This method allows you to upload the historic activities to the conversation.\r\n\r\nSender must ensure that the historic activities have unique ids and appropriate timestamps. The ids are used by the client to deal with duplicate activities and the timestamps are used by the client to render the activities in the right order.", - "operationId": "Conversations_SendConversationHistory", - "consumes": [ - "application/json", - "text/json", - "application/xml", - "text/xml", - "application/x-www-form-urlencoded" - ], - "produces": [ - "application/json", - "text/json" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, - "type": "string" - }, - { - "name": "history", - "in": "body", - "description": "Historic activities", - "required": true, - "schema": { - "$ref": "#/definitions/Transcript" + }, + "/v3/conversations/{conversationId}/members/{memberId}": { + "delete": { + "tags": [ + "Conversations" + ], + "summary": "DeleteConversationMember", + "description": "Deletes a member from a conversation. \r\n\r\nThis REST API takes a ConversationId and a memberId (of type string) and removes that member from the conversation. If that member was the last member\r\nof the conversation, the conversation will also be deleted.", + "operationId": "Conversations_DeleteConversationMember", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "memberId", + "in": "path", + "description": "ID of the member to delete from this conversation", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "The operation succeeded, there is no response." + }, + "204": { + "description": "The operation succeeded but no content was returned." + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } - ], - "responses": { - "200": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + } + }, + "/v3/conversations/{conversationId}/activities/{activityId}/members": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "GetActivityMembers", + "description": "Enumerate the members of an activity. \r\n\r\nThis REST API takes a ConversationId and a ActivityId, returning an array of ChannelAccount objects representing the members of the particular activity in the conversation.", + "operationId": "Conversations_GetActivityMembers", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "activityId", + "in": "path", + "description": "Activity ID", + "required": true, + "type": "string" } - }, - "201": { - "description": "A ResourceResponse object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + ], + "responses": { + "200": { + "description": "An array of ChannelAccount objects", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" + } + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } - }, - "202": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + } + } + }, + "/v3/conversations/{conversationId}/attachments": { + "post": { + "tags": [ + "Conversations" + ], + "summary": "UploadAttachment", + "description": "Upload an attachment directly into a channel's blob storage.\r\n\r\nThis is useful because it allows you to store data in a compliant store when dealing with enterprises.\r\n\r\nThe response is a ResourceResponse which contains an AttachmentId which is suitable for using with the attachments API.", + "operationId": "Conversations_UploadAttachment", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json" + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Conversation ID", + "required": true, + "type": "string" + }, + { + "name": "attachmentUpload", + "in": "body", + "description": "Attachment data", + "required": true, + "schema": { + "$ref": "#/definitions/AttachmentData" + } } - }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + ], + "responses": { + "200": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "201": { + "description": "A ResourceResponse object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "202": { + "description": "An object will be returned containing the ID for the resource.", + "schema": { + "$ref": "#/definitions/ResourceResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } } } } } }, - "/v3/conversations/{conversationId}/activities/{activityId}": { - "put": { - "tags": [ - "Conversations" - ], - "summary": "UpdateActivity", - "description": "Edit an existing activity.\r\n\r\nSome channels allow you to edit an existing activity to reflect the new state of a bot conversation.\r\n\r\nFor example, you can remove buttons after someone has clicked \"Approve\" button.", - "operationId": "Conversations_UpdateActivity", - "consumes": [ - "application/json", - "text/json", - "application/xml", - "text/xml", - "application/x-www-form-urlencoded" - ], - "produces": [ - "application/json", - "text/json" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + "definitions": { + "AttachmentInfo": { + "description": "Metadata for an attachment", + "type": "object", + "properties": { + "name": { + "description": "Name of the attachment", "type": "string" }, - { - "name": "activityId", - "in": "path", - "description": "activityId to update", - "required": true, + "type": { + "description": "ContentType of the attachment", "type": "string" }, - { - "name": "activity", - "in": "body", - "description": "replacement Activity", - "required": true, - "schema": { - "$ref": "#/definitions/Activity" + "views": { + "description": "attachment views", + "type": "array", + "items": { + "$ref": "#/definitions/AttachmentView" } } - ], - "responses": { - "200": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" - } - }, - "201": { - "description": "A ResourceResponse object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" - } - }, - "202": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" - } + } + }, + "AttachmentView": { + "description": "Attachment View name and size", + "type": "object", + "properties": { + "viewId": { + "description": "Id of the attachment", + "type": "string" }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } + "size": { + "format": "int32", + "description": "Size of the attachment", + "type": "integer" } } }, - "post": { - "tags": [ - "Conversations" - ], - "summary": "ReplyToActivity", - "description": "This method allows you to reply to an activity.\r\n\r\nThis is slightly different from SendToConversation().\r\n* SendToConversation(conversationId) - will append the activity to the end of the conversation according to the timestamp or semantics of the channel.\r\n* ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply to another activity, if the channel supports it. If the channel does not support nested replies, ReplyToActivity falls back to SendToConversation.\r\n\r\nUse ReplyToActivity when replying to a specific activity in the conversation.\r\n\r\nUse SendToConversation in all other cases.", - "operationId": "Conversations_ReplyToActivity", - "consumes": [ - "application/json", - "text/json", - "application/xml", - "text/xml", - "application/x-www-form-urlencoded" - ], - "produces": [ - "application/json", - "text/json" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + "ErrorResponse": { + "description": "An HTTP API response", + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/Error", + "description": "Error message" + } + } + }, + "Error": { + "description": "Object representing error information", + "type": "object", + "properties": { + "code": { + "description": "Error code", "type": "string" }, - { - "name": "activityId", - "in": "path", - "description": "activityId the reply is to (OPTIONAL)", - "required": true, + "message": { + "description": "Error message", "type": "string" }, - { - "name": "activity", - "in": "body", - "description": "Activity to send", - "required": true, - "schema": { - "$ref": "#/definitions/Activity" - } + "innerHttpError": { + "$ref": "#/definitions/InnerHttpError", + "description": "Error from inner http call" } - ], - "responses": { - "200": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" - } - }, - "201": { - "description": "A ResourceResponse object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" - } - }, - "202": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" - } + } + }, + "InnerHttpError": { + "description": "Object representing inner http error", + "type": "object", + "properties": { + "statusCode": { + "format": "int32", + "description": "HttpStatusCode from failed request", + "type": "integer" }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } + "body": { + "description": "Body from failed request", + "type": "object" } } }, - "delete": { - "tags": [ - "Conversations" - ], - "summary": "DeleteActivity", - "description": "Delete an existing activity.\r\n\r\nSome channels allow you to delete an existing activity, and if successful this method will remove the specified activity.", - "operationId": "Conversations_DeleteActivity", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + "ConversationParameters": { + "description": "Parameters for creating a new conversation", + "type": "object", + "properties": { + "isGroup": { + "description": "IsGroup", + "type": "boolean" + }, + "bot": { + "$ref": "#/definitions/ChannelAccount", + "description": "The bot address for this conversation" + }, + "members": { + "description": "Members to add to the conversation", + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" + } + }, + "topicName": { + "description": "(Optional) Topic of the conversation (if supported by the channel)", "type": "string" }, - { - "name": "activityId", - "in": "path", - "description": "activityId to delete", - "required": true, + "tenantId": { + "description": "(Optional) The tenant ID in which the conversation should be created", "type": "string" - } - ], - "responses": { - "200": { - "description": "The operation succeeded, there is no response." }, - "202": { - "description": "The request has been accepted for processing, but the processing has not been completed" + "activity": { + "$ref": "#/definitions/Activity", + "description": "(Optional) When creating a new conversation, use this activity as the initial message to the conversation" }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } + "channelData": { + "description": "Channel specific payload for creating the conversation", + "type": "object" } } - } - }, - "/v3/conversations/{conversationId}/members": { - "get": { - "tags": [ - "Conversations" - ], - "summary": "GetConversationMembers", - "description": "Enumerate the members of a conversation. \r\n\r\nThis REST API takes a ConversationId and returns an array of ChannelAccount objects representing the members of the conversation.", - "operationId": "Conversations_GetConversationMembers", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + }, + "ChannelAccount": { + "description": "Channel account information needed to route a message", + "type": "object", + "properties": { + "id": { + "description": "Channel id for the user or bot on this channel (Example: joe@smith.com, or @joesmith or 123456)", "type": "string" - } - ], - "responses": { - "200": { - "description": "An array of ChannelAccount objects", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" - } - } }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, - "/v3/conversations/{conversationId}/pagedmembers": { - "get": { - "tags": [ - "Conversations" - ], - "summary": "GetConversationPagedMembers", - "description": "Enumerate the members of a conversation one page at a time.\r\n\r\nThis REST API takes a ConversationId. Optionally a pageSize and/or continuationToken can be provided. It returns a PagedMembersResult, which contains an array\r\nof ChannelAccounts representing the members of the conversation and a continuation token that can be used to get more values.\r\n\r\nOne page of ChannelAccounts records are returned with each call. The number of records in a page may vary between channels and calls. The pageSize parameter can be used as \r\na suggestion. If there are no additional results the response will not contain a continuation token. If there are no members in the conversation the Members will be empty or not present in the response.\r\n\r\nA response to a request that has a continuation token from a prior request may rarely return members from a previous request.", - "operationId": "Conversations_GetConversationPagedMembers", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + "name": { + "description": "Display friendly name", "type": "string" }, - { - "name": "pageSize", - "in": "query", - "description": "Suggested page size", - "required": false, - "type": "integer", - "format": "int32" - }, - { - "name": "continuationToken", - "in": "query", - "description": "Continuation Token", - "required": false, + "aadObjectId": { + "description": "This account's object ID within Azure Active Directory (AAD)", "type": "string" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/PagedMembersResult" - } + }, + "role": { + "$ref": "#/definitions/RoleTypes", + "description": "Role of the entity behind the account (Example: User, Bot, etc.)" } } - } - }, - "/v3/conversations/{conversationId}/members/{memberId}": { - "delete": { - "tags": [ - "Conversations" - ], - "summary": "DeleteConversationMember", - "description": "Deletes a member from a conversation. \r\n\r\nThis REST API takes a ConversationId and a memberId (of type string) and removes that member from the conversation. If that member was the last member\r\nof the conversation, the conversation will also be deleted.", - "operationId": "Conversations_DeleteConversationMember", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + }, + "Activity": { + "description": "An Activity is the basic communication type for the Bot Framework 3.0 protocol.", + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/ActivityTypes", + "description": "Contains the activity type." + }, + "id": { + "description": "Contains an ID that uniquely identifies the activity on the channel.", "type": "string" }, - { - "name": "memberId", - "in": "path", - "description": "ID of the member to delete from this conversation", - "required": true, + "timestamp": { + "format": "date-time", + "description": "Contains the date and time that the message was sent, in UTC, expressed in ISO-8601 format.", "type": "string" - } - ], - "responses": { - "200": { - "description": "The operation succeeded, there is no response." }, - "204": { - "description": "The operation succeeded but no content was returned." + "localTimestamp": { + "format": "date-time", + "description": "Contains the local date and time of the message, expressed in ISO-8601 format.\r\nFor example, 2016-09-23T13:07:49.4714686-07:00.", + "type": "string" }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, - "/v3/conversations/{conversationId}/activities/{activityId}/members": { - "get": { - "tags": [ - "Conversations" - ], - "summary": "GetActivityMembers", - "description": "Enumerate the members of an activity. \r\n\r\nThis REST API takes a ConversationId and a ActivityId, returning an array of ChannelAccount objects representing the members of the particular activity in the conversation.", - "operationId": "Conversations_GetActivityMembers", - "consumes": [], - "produces": [ - "application/json", - "text/json", - "application/xml", - "text/xml" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + "localTimezone": { + "description": "Contains the name of the local timezone of the message, expressed in IANA Time Zone database format.\r\nFor example, America/Los_Angeles.", "type": "string" }, - { - "name": "activityId", - "in": "path", - "description": "Activity ID", - "required": true, + "callerId": { + "description": "A string containing an IRI identifying the caller of a bot. This field is not intended to be transmitted\r\nover the wire, but is instead populated by bots and clients based on cryptographically verifiable data\r\nthat asserts the identity of the callers (e.g. tokens).", "type": "string" - } - ], - "responses": { - "200": { - "description": "An array of ChannelAccount objects", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" - } - } }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" - } - } - } - } - }, - "/v3/conversations/{conversationId}/attachments": { - "post": { - "tags": [ - "Conversations" - ], - "summary": "UploadAttachment", - "description": "Upload an attachment directly into a channel's blob storage.\r\n\r\nThis is useful because it allows you to store data in a compliant store when dealing with enterprises.\r\n\r\nThe response is a ResourceResponse which contains an AttachmentId which is suitable for using with the attachments API.", - "operationId": "Conversations_UploadAttachment", - "consumes": [ - "application/json", - "text/json", - "application/xml", - "text/xml", - "application/x-www-form-urlencoded" - ], - "produces": [ - "application/json", - "text/json" - ], - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "Conversation ID", - "required": true, + "serviceUrl": { + "description": "Contains the URL that specifies the channel's service endpoint. Set by the channel.", + "type": "string" + }, + "channelId": { + "description": "Contains an ID that uniquely identifies the channel. Set by the channel.", "type": "string" }, - { - "name": "attachmentUpload", - "in": "body", - "description": "Attachment data", - "required": true, - "schema": { - "$ref": "#/definitions/AttachmentData" + "from": { + "$ref": "#/definitions/ChannelAccount", + "description": "Identifies the sender of the message." + }, + "conversation": { + "$ref": "#/definitions/ConversationAccount", + "description": "Identifies the conversation to which the activity belongs." + }, + "recipient": { + "$ref": "#/definitions/ChannelAccount", + "description": "Identifies the recipient of the message." + }, + "textFormat": { + "$ref": "#/definitions/TextFormatTypes", + "description": "Format of text fields Default:markdown" + }, + "attachmentLayout": { + "$ref": "#/definitions/AttachmentLayoutTypes", + "description": "The layout hint for multiple attachments. Default: list." + }, + "membersAdded": { + "description": "The collection of members added to the conversation.", + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" } - } - ], - "responses": { - "200": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + }, + "membersRemoved": { + "description": "The collection of members removed from the conversation.", + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" } }, - "201": { - "description": "A ResourceResponse object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + "reactionsAdded": { + "description": "The collection of reactions added to the conversation.", + "type": "array", + "items": { + "$ref": "#/definitions/MessageReaction" } }, - "202": { - "description": "An object will be returned containing the ID for the resource.", - "schema": { - "$ref": "#/definitions/ResourceResponse" + "reactionsRemoved": { + "description": "The collection of reactions removed from the conversation.", + "type": "array", + "items": { + "$ref": "#/definitions/MessageReaction" } }, - "default": { - "description": "The operation failed and the response is an error object describing the status code and failure.", - "schema": { - "$ref": "#/definitions/ErrorResponse" + "topicName": { + "description": "The updated topic name of the conversation.", + "type": "string" + }, + "historyDisclosed": { + "description": "Indicates whether the prior history of the channel is disclosed.", + "type": "boolean" + }, + "locale": { + "description": "A locale name for the contents of the text field.\r\nThe locale name is a combination of an ISO 639 two- or three-letter culture code associated with a language\r\nand an ISO 3166 two-letter subculture code associated with a country or region.\r\nThe locale name can also correspond to a valid BCP-47 language tag.", + "type": "string" + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "speak": { + "description": "The text to speak.", + "type": "string" + }, + "inputHint": { + "$ref": "#/definitions/InputHints", + "description": "Indicates whether your bot is accepting,\r\nexpecting, or ignoring user input after the message is delivered to the client." + }, + "summary": { + "description": "The text to display if the channel cannot render cards.", + "type": "string" + }, + "suggestedActions": { + "$ref": "#/definitions/SuggestedActions", + "description": "The suggested actions for the activity." + }, + "attachments": { + "description": "Attachments", + "type": "array", + "items": { + "$ref": "#/definitions/Attachment" } + }, + "entities": { + "description": "Represents the entities that were mentioned in the message.", + "type": "array", + "items": { + "$ref": "#/definitions/Entity" + } + }, + "channelData": { + "description": "Contains channel-specific content.", + "type": "object" + }, + "action": { + "description": "Indicates whether the recipient of a contactRelationUpdate was added or removed from the sender's contact list.", + "type": "string" + }, + "replyToId": { + "description": "Contains the ID of the message to which this message is a reply.", + "type": "string" + }, + "label": { + "description": "A descriptive label for the activity.", + "type": "string" + }, + "valueType": { + "description": "The type of the activity's value object.", + "type": "string" + }, + "value": { + "description": "A value that is associated with the activity.", + "type": "object" + }, + "name": { + "description": "The name of the operation associated with an invoke or event activity.", + "type": "string" + }, + "relatesTo": { + "$ref": "#/definitions/ConversationReference", + "description": "A reference to another conversation or activity." + }, + "code": { + "$ref": "#/definitions/EndOfConversationCodes", + "description": "The a code for endOfConversation activities that indicates why the conversation ended." + }, + "expiration": { + "format": "date-time", + "description": "The time at which the activity should be considered to be \"expired\" and should not be presented to the recipient.", + "type": "string" + }, + "importance": { + "$ref": "#/definitions/ActivityImportance", + "description": "The importance of the activity." + }, + "deliveryMode": { + "$ref": "#/definitions/DeliveryModes", + "description": "A delivery hint to signal to the recipient alternate delivery paths for the activity.\r\nThe default delivery mode is \"default\"." + }, + "listenFor": { + "description": "List of phrases and references that speech and language priming systems should listen for", + "type": "array", + "items": { + "type": "string" + } + }, + "textHighlights": { + "description": "The collection of text fragments to highlight when the activity contains a ReplyToId value.", + "type": "array", + "items": { + "$ref": "#/definitions/TextHighlight" + } + }, + "semanticAction": { + "$ref": "#/definitions/SemanticAction", + "description": "An optional programmatic action accompanying this request" } } - } - } - }, - "definitions": { - "AttachmentInfo": { - "description": "Metadata for an attachment", - "type": "object", - "properties": { - "name": { - "description": "Name of the attachment", - "type": "string" - }, - "type": { - "description": "ContentType of the attachment", - "type": "string" - }, - "views": { - "description": "attachment views", - "type": "array", - "items": { - "$ref": "#/definitions/AttachmentView" - } - } - } - }, - "AttachmentView": { - "description": "Attachment View name and size", - "type": "object", - "properties": { - "viewId": { - "description": "Id of the attachment", - "type": "string" - }, - "size": { - "format": "int32", - "description": "Size of the attachment", - "type": "integer" - } - } - }, - "ErrorResponse": { - "description": "An HTTP API response", - "type": "object", - "properties": { - "error": { - "$ref": "#/definitions/Error", - "description": "Error message" - } - } - }, - "Error": { - "description": "Object representing error information", - "type": "object", - "properties": { - "code": { - "description": "Error code", - "type": "string" - }, - "message": { - "description": "Error message", - "type": "string" - }, - "innerHttpError": { - "$ref": "#/definitions/InnerHttpError", - "description": "Error from inner http call" - } - } - }, - "InnerHttpError": { - "description": "Object representing inner http error", - "type": "object", - "properties": { - "statusCode": { - "format": "int32", - "description": "HttpStatusCode from failed request", - "type": "integer" - }, - "body": { - "description": "Body from failed request", - "type": "object" - } - } - }, - "ConversationParameters": { - "description": "Parameters for creating a new conversation", - "type": "object", - "properties": { - "isGroup": { - "description": "IsGroup", - "type": "boolean" - }, - "bot": { - "$ref": "#/definitions/ChannelAccount", - "description": "The bot address for this conversation" - }, - "members": { - "description": "Members to add to the conversation", - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" + }, + "ConversationAccount": { + "description": "Conversation account represents the identity of the conversation within a channel", + "type": "object", + "properties": { + "isGroup": { + "description": "Indicates whether the conversation contains more than two participants at the time the activity was generated", + "type": "boolean" + }, + "conversationType": { + "description": "Indicates the type of the conversation in channels that distinguish between conversation types", + "type": "string" + }, + "tenantId": { + "description": "This conversation's tenant ID", + "type": "string" + }, + "id": { + "description": "Channel id for the user or bot on this channel (Example: joe@smith.com, or @joesmith or 123456)", + "type": "string" + }, + "name": { + "description": "Display friendly name", + "type": "string" + }, + "aadObjectId": { + "description": "This account's object ID within Azure Active Directory (AAD)", + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleTypes", + "description": "Role of the entity behind the account (Example: User, Bot, etc.)" } - }, - "topicName": { - "description": "(Optional) Topic of the conversation (if supported by the channel)", - "type": "string" - }, - "tenantId": { - "description": "(Optional) The tenant ID in which the conversation should be created", - "type": "string" - }, - "activity": { - "$ref": "#/definitions/Activity", - "description": "(Optional) When creating a new conversation, use this activity as the initial message to the conversation" - }, - "channelData": { - "description": "Channel specific payload for creating the conversation", - "type": "object" } - } - }, - "ChannelAccount": { - "description": "Channel account information needed to route a message", - "type": "object", - "properties": { - "id": { - "description": "Channel id for the user or bot on this channel (Example: joe@smith.com, or @joesmith or 123456)", - "type": "string" - }, - "name": { - "description": "Display friendly name", - "type": "string" - }, - "aadObjectId": { - "description": "This account's object ID within Azure Active Directory (AAD)", - "type": "string" - }, - "role": { - "$ref": "#/definitions/RoleTypes", - "description": "Role of the entity behind the account (Example: User, Bot, etc.)" - } - } - }, - "Activity": { - "description": "An Activity is the basic communication type for the Bot Framework 3.0 protocol.", - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/ActivityTypes", - "description": "Contains the activity type." - }, - "id": { - "description": "Contains an ID that uniquely identifies the activity on the channel.", - "type": "string" - }, - "timestamp": { - "format": "date-time", - "description": "Contains the date and time that the message was sent, in UTC, expressed in ISO-8601 format.", - "type": "string" - }, - "localTimestamp": { - "format": "date-time", - "description": "Contains the local date and time of the message, expressed in ISO-8601 format.\r\nFor example, 2016-09-23T13:07:49.4714686-07:00.", - "type": "string" - }, - "localTimezone": { - "description": "Contains the name of the local timezone of the message, expressed in IANA Time Zone database format.\r\nFor example, America/Los_Angeles.", - "type": "string" - }, - "callerId": { - "description": "A string containing an IRI identifying the caller of a bot. This field is not intended to be transmitted\r\nover the wire, but is instead populated by bots and clients based on cryptographically verifiable data\r\nthat asserts the identity of the callers (e.g. tokens).", - "type": "string" - }, - "serviceUrl": { - "description": "Contains the URL that specifies the channel's service endpoint. Set by the channel.", - "type": "string" - }, - "channelId": { - "description": "Contains an ID that uniquely identifies the channel. Set by the channel.", - "type": "string" - }, - "from": { - "$ref": "#/definitions/ChannelAccount", - "description": "Identifies the sender of the message." - }, - "conversation": { - "$ref": "#/definitions/ConversationAccount", - "description": "Identifies the conversation to which the activity belongs." - }, - "recipient": { - "$ref": "#/definitions/ChannelAccount", - "description": "Identifies the recipient of the message." - }, - "textFormat": { - "$ref": "#/definitions/TextFormatTypes", - "description": "Format of text fields Default:markdown" - }, - "attachmentLayout": { - "$ref": "#/definitions/AttachmentLayoutTypes", - "description": "The layout hint for multiple attachments. Default: list." - }, - "membersAdded": { - "description": "The collection of members added to the conversation.", - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" - } - }, - "membersRemoved": { - "description": "The collection of members removed from the conversation.", - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" - } - }, - "reactionsAdded": { - "description": "The collection of reactions added to the conversation.", - "type": "array", - "items": { - "$ref": "#/definitions/MessageReaction" - } - }, - "reactionsRemoved": { - "description": "The collection of reactions removed from the conversation.", - "type": "array", - "items": { - "$ref": "#/definitions/MessageReaction" - } - }, - "topicName": { - "description": "The updated topic name of the conversation.", - "type": "string" - }, - "historyDisclosed": { - "description": "Indicates whether the prior history of the channel is disclosed.", - "type": "boolean" - }, - "locale": { - "description": "A locale name for the contents of the text field.\r\nThe locale name is a combination of an ISO 639 two- or three-letter culture code associated with a language\r\nand an ISO 3166 two-letter subculture code associated with a country or region.\r\nThe locale name can also correspond to a valid BCP-47 language tag.", - "type": "string" - }, - "text": { - "description": "The text content of the message.", - "type": "string" - }, - "speak": { - "description": "The text to speak.", - "type": "string" - }, - "inputHint": { - "$ref": "#/definitions/InputHints", - "description": "Indicates whether your bot is accepting,\r\nexpecting, or ignoring user input after the message is delivered to the client." - }, - "summary": { - "description": "The text to display if the channel cannot render cards.", - "type": "string" - }, - "suggestedActions": { - "$ref": "#/definitions/SuggestedActions", - "description": "The suggested actions for the activity." - }, - "attachments": { - "description": "Attachments", - "type": "array", - "items": { - "$ref": "#/definitions/Attachment" + }, + "MessageReaction": { + "description": "Message reaction object", + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/MessageReactionTypes", + "description": "Message reaction type" } - }, - "entities": { - "description": "Represents the entities that were mentioned in the message.", - "type": "array", - "items": { - "$ref": "#/definitions/Entity" + } + }, + "SuggestedActions": { + "description": "SuggestedActions that can be performed", + "type": "object", + "properties": { + "to": { + "description": "Ids of the recipients that the actions should be shown to. These Ids are relative to the channelId and a subset of all recipients of the activity", + "type": "array", + "items": { + "type": "string" + } + }, + "actions": { + "description": "Actions that can be shown to the user", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } } - }, - "channelData": { - "description": "Contains channel-specific content.", - "type": "object" - }, - "action": { - "description": "Indicates whether the recipient of a contactRelationUpdate was added or removed from the sender's contact list.", - "type": "string" - }, - "replyToId": { - "description": "Contains the ID of the message to which this message is a reply.", - "type": "string" - }, - "label": { - "description": "A descriptive label for the activity.", - "type": "string" - }, - "valueType": { - "description": "The type of the activity's value object.", - "type": "string" - }, - "value": { - "description": "A value that is associated with the activity.", - "type": "object" - }, - "name": { - "description": "The name of the operation associated with an invoke or event activity.", - "type": "string" - }, - "relatesTo": { - "$ref": "#/definitions/ConversationReference", - "description": "A reference to another conversation or activity." - }, - "code": { - "$ref": "#/definitions/EndOfConversationCodes", - "description": "The a code for endOfConversation activities that indicates why the conversation ended." - }, - "expiration": { - "format": "date-time", - "description": "The time at which the activity should be considered to be \"expired\" and should not be presented to the recipient.", - "type": "string" - }, - "importance": { - "$ref": "#/definitions/ActivityImportance", - "description": "The importance of the activity." - }, - "deliveryMode": { - "$ref": "#/definitions/DeliveryModes", - "description": "A delivery hint to signal to the recipient alternate delivery paths for the activity.\r\nThe default delivery mode is \"default\"." - }, - "listenFor": { - "description": "List of phrases and references that speech and language priming systems should listen for", - "type": "array", - "items": { + } + }, + "Attachment": { + "description": "An attachment within an activity", + "type": "object", + "properties": { + "contentType": { + "description": "mimetype/Contenttype for the file", + "type": "string" + }, + "contentUrl": { + "description": "Content Url", + "type": "string" + }, + "content": { + "description": "Embedded content", + "type": "object" + }, + "name": { + "description": "(OPTIONAL) The name of the attachment", + "type": "string" + }, + "thumbnailUrl": { + "description": "(OPTIONAL) Thumbnail associated with attachment", "type": "string" } - }, - "textHighlights": { - "description": "The collection of text fragments to highlight when the activity contains a ReplyToId value.", - "type": "array", - "items": { - "$ref": "#/definitions/TextHighlight" - } - }, - "semanticAction": { - "$ref": "#/definitions/SemanticAction", - "description": "An optional programmatic action accompanying this request" - } - } - }, - "ConversationAccount": { - "description": "Conversation account represents the identity of the conversation within a channel", - "type": "object", - "properties": { - "isGroup": { - "description": "Indicates whether the conversation contains more than two participants at the time the activity was generated", - "type": "boolean" - }, - "conversationType": { - "description": "Indicates the type of the conversation in channels that distinguish between conversation types", - "type": "string" - }, - "tenantId": { - "description": "This conversation's tenant ID", - "type": "string" - }, - "id": { - "description": "Channel id for the user or bot on this channel (Example: joe@smith.com, or @joesmith or 123456)", - "type": "string" - }, - "name": { - "description": "Display friendly name", - "type": "string" - }, - "aadObjectId": { - "description": "This account's object ID within Azure Active Directory (AAD)", - "type": "string" - }, - "role": { - "$ref": "#/definitions/RoleTypes", - "description": "Role of the entity behind the account (Example: User, Bot, etc.)" } - } - }, - "MessageReaction": { - "description": "Message reaction object", - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/MessageReactionTypes", - "description": "Message reaction type" - } - } - }, - "SuggestedActions": { - "description": "SuggestedActions that can be performed", - "type": "object", - "properties": { - "to": { - "description": "Ids of the recipients that the actions should be shown to. These Ids are relative to the channelId and a subset of all recipients of the activity", - "type": "array", - "items": { + }, + "Entity": { + "description": "Metadata object pertaining to an activity", + "type": "object", + "properties": { + "type": { + "description": "Type of this entity (RFC 3987 IRI)", "type": "string" } - }, - "actions": { - "description": "Actions that can be shown to the user", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" - } - } - } - }, - "Attachment": { - "description": "An attachment within an activity", - "type": "object", - "properties": { - "contentType": { - "description": "mimetype/Contenttype for the file", - "type": "string" - }, - "contentUrl": { - "description": "Content Url", - "type": "string" - }, - "content": { - "description": "Embedded content", - "type": "object" - }, - "name": { - "description": "(OPTIONAL) The name of the attachment", - "type": "string" - }, - "thumbnailUrl": { - "description": "(OPTIONAL) Thumbnail associated with attachment", - "type": "string" - } - } - }, - "Entity": { - "description": "Metadata object pertaining to an activity", - "type": "object", - "properties": { - "type": { - "description": "Type of this entity (RFC 3987 IRI)", - "type": "string" } - } - }, - "ConversationReference": { - "description": "An object relating to a particular point in a conversation", - "type": "object", - "properties": { - "activityId": { - "description": "(Optional) ID of the activity to refer to", - "type": "string" - }, - "user": { - "$ref": "#/definitions/ChannelAccount", - "description": "(Optional) User participating in this conversation" - }, - "bot": { - "$ref": "#/definitions/ChannelAccount", - "description": "Bot participating in this conversation" - }, - "conversation": { - "$ref": "#/definitions/ConversationAccount", - "description": "Conversation reference" - }, - "channelId": { - "description": "Channel ID", - "type": "string" - }, - "serviceUrl": { - "description": "Service endpoint where operations concerning the referenced conversation may be performed", - "type": "string" - } - } - }, - "TextHighlight": { - "description": "Refers to a substring of content within another field", - "type": "object", - "properties": { - "text": { - "description": "Defines the snippet of text to highlight", - "type": "string" - }, - "occurrence": { - "format": "int32", - "description": "Occurrence of the text field within the referenced text, if multiple exist.", - "type": "integer" - } - } - }, - "SemanticAction": { - "description": "Represents a reference to a programmatic action", - "type": "object", - "properties": { - "state": { - "$ref": "#/definitions/SemanticActionStates", - "description": "State of this action. Allowed values: `start`, `continue`, `done`" - }, - "id": { - "description": "ID of this action", - "type": "string" - }, - "entities": { - "description": "Entities associated with this action", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Entity" + }, + "ConversationReference": { + "description": "An object relating to a particular point in a conversation", + "type": "object", + "properties": { + "activityId": { + "description": "(Optional) ID of the activity to refer to", + "type": "string" + }, + "user": { + "$ref": "#/definitions/ChannelAccount", + "description": "(Optional) User participating in this conversation" + }, + "bot": { + "$ref": "#/definitions/ChannelAccount", + "description": "Bot participating in this conversation" + }, + "conversation": { + "$ref": "#/definitions/ConversationAccount", + "description": "Conversation reference" + }, + "channelId": { + "description": "Channel ID", + "type": "string" + }, + "serviceUrl": { + "description": "Service endpoint where operations concerning the referenced conversation may be performed", + "type": "string" } } - } - }, - "CardAction": { - "description": "A clickable action", - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/ActionTypes", - "description": "The type of action implemented by this button" - }, - "title": { - "description": "Text description which appears on the button", - "type": "string" - }, - "image": { - "description": "Image URL which will appear on the button, next to text label", - "type": "string" - }, - "text": { - "description": "Text for this action", - "type": "string" - }, - "displayText": { - "description": "(Optional) text to display in the chat feed if the button is clicked", - "type": "string" - }, - "value": { - "description": "Supplementary parameter for action. Content of this property depends on the ActionType", - "type": "object" - }, - "channelData": { - "description": "Channel-specific data associated with this action", - "type": "object" + }, + "TextHighlight": { + "description": "Refers to a substring of content within another field", + "type": "object", + "properties": { + "text": { + "description": "Defines the snippet of text to highlight", + "type": "string" + }, + "occurrence": { + "format": "int32", + "description": "Occurrence of the text field within the referenced text, if multiple exist.", + "type": "integer" + } } - } - }, - "ConversationResourceResponse": { - "description": "A response containing a resource", - "type": "object", - "properties": { - "activityId": { - "description": "ID of the Activity (if sent)", - "type": "string" - }, - "serviceUrl": { - "description": "Service endpoint where operations concerning the conversation may be performed", - "type": "string" - }, - "id": { - "description": "Id of the resource", - "type": "string" + }, + "SemanticAction": { + "description": "Represents a reference to a programmatic action", + "type": "object", + "properties": { + "state": { + "$ref": "#/definitions/SemanticActionStates", + "description": "State of this action. Allowed values: `start`, `continue`, `done`" + }, + "id": { + "description": "ID of this action", + "type": "string" + }, + "entities": { + "description": "Entities associated with this action", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Entity" + } + } } - } - }, - "ConversationsResult": { - "description": "Conversations result", - "type": "object", - "properties": { - "continuationToken": { - "description": "Paging token", - "type": "string" - }, - "conversations": { - "description": "List of conversations", - "type": "array", - "items": { - "$ref": "#/definitions/ConversationMembers" + }, + "CardAction": { + "description": "A clickable action", + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/ActionTypes", + "description": "The type of action implemented by this button" + }, + "title": { + "description": "Text description which appears on the button", + "type": "string" + }, + "image": { + "description": "Image URL which will appear on the button, next to text label", + "type": "string" + }, + "text": { + "description": "Text for this action", + "type": "string" + }, + "displayText": { + "description": "(Optional) text to display in the chat feed if the button is clicked", + "type": "string" + }, + "value": { + "description": "Supplementary parameter for action. Content of this property depends on the ActionType", + "type": "object" + }, + "channelData": { + "description": "Channel-specific data associated with this action", + "type": "object" } } - } - }, - "ConversationMembers": { - "description": "Conversation and its members", - "type": "object", - "properties": { - "id": { - "description": "Conversation ID", - "type": "string" - }, - "members": { - "description": "List of members in this conversation", - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" + }, + "ConversationResourceResponse": { + "description": "A response containing a resource", + "type": "object", + "properties": { + "activityId": { + "description": "ID of the Activity (if sent)", + "type": "string" + }, + "serviceUrl": { + "description": "Service endpoint where operations concerning the conversation may be performed", + "type": "string" + }, + "id": { + "description": "Id of the resource", + "type": "string" } } - } - }, - "ResourceResponse": { - "description": "A response containing a resource ID", - "type": "object", - "properties": { - "id": { - "description": "Id of the resource", - "type": "string" + }, + "ConversationsResult": { + "description": "Conversations result", + "type": "object", + "properties": { + "continuationToken": { + "description": "Paging token", + "type": "string" + }, + "conversations": { + "description": "List of conversations", + "type": "array", + "items": { + "$ref": "#/definitions/ConversationMembers" + } + } } - } - }, - "Transcript": { - "description": "Transcript", - "type": "object", - "properties": { - "activities": { - "description": "A collection of Activities that conforms to the Transcript schema.", - "type": "array", - "items": { - "$ref": "#/definitions/Activity" + }, + "ConversationMembers": { + "description": "Conversation and its members", + "type": "object", + "properties": { + "id": { + "description": "Conversation ID", + "type": "string" + }, + "members": { + "description": "List of members in this conversation", + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" + } } } - } - }, - "PagedMembersResult": { - "description": "Page of members.", - "type": "object", - "properties": { - "continuationToken": { - "description": "Paging token", - "type": "string" - }, - "members": { - "description": "The Channel Accounts.", - "type": "array", - "items": { - "$ref": "#/definitions/ChannelAccount" + }, + "ResourceResponse": { + "description": "A response containing a resource ID", + "type": "object", + "properties": { + "id": { + "description": "Id of the resource", + "type": "string" } } - } - }, - "AttachmentData": { - "description": "Attachment data", - "type": "object", - "properties": { - "type": { - "description": "Content-Type of the attachment", - "type": "string" - }, - "name": { - "description": "Name of the attachment", - "type": "string" - }, - "originalBase64": { - "format": "byte", - "description": "Attachment content", - "type": "string" - }, - "thumbnailBase64": { - "format": "byte", - "description": "Attachment thumbnail", - "type": "string" + }, + "Transcript": { + "description": "Transcript", + "type": "object", + "properties": { + "activities": { + "description": "A collection of Activities that conforms to the Transcript schema.", + "type": "array", + "items": { + "$ref": "#/definitions/Activity" + } + } } - } - }, - "HeroCard": { - "description": "A Hero card (card with a single, large image)", - "type": "object", - "properties": { - "title": { - "description": "Title of the card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of the card", - "type": "string" - }, - "text": { - "description": "Text for the card", - "type": "string" - }, - "images": { - "description": "Array of images for the card", - "type": "array", - "items": { - "$ref": "#/definitions/CardImage" + }, + "PagedMembersResult": { + "description": "Page of members.", + "type": "object", + "properties": { + "continuationToken": { + "description": "Paging token", + "type": "string" + }, + "members": { + "description": "The Channel Accounts.", + "type": "array", + "items": { + "$ref": "#/definitions/ChannelAccount" + } } - }, - "buttons": { - "description": "Set of actions applicable to the current card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "AttachmentData": { + "description": "Attachment data", + "type": "object", + "properties": { + "type": { + "description": "Content-Type of the attachment", + "type": "string" + }, + "name": { + "description": "Name of the attachment", + "type": "string" + }, + "originalBase64": { + "format": "byte", + "description": "Attachment content", + "type": "string" + }, + "thumbnailBase64": { + "format": "byte", + "description": "Attachment thumbnail", + "type": "string" } - }, - "tap": { - "$ref": "#/definitions/CardAction", - "description": "This action will be activated when user taps on the card itself" } - } - }, - "CardImage": { - "description": "An image on a card", - "type": "object", - "properties": { - "url": { - "description": "URL thumbnail image for major content property", - "type": "string" - }, - "alt": { - "description": "Image description intended for screen readers", - "type": "string" - }, - "tap": { - "$ref": "#/definitions/CardAction", - "description": "Action assigned to specific Attachment" + }, + "HeroCard": { + "description": "A Hero card (card with a single, large image)", + "type": "object", + "properties": { + "title": { + "description": "Title of the card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of the card", + "type": "string" + }, + "text": { + "description": "Text for the card", + "type": "string" + }, + "images": { + "description": "Array of images for the card", + "type": "array", + "items": { + "$ref": "#/definitions/CardImage" + } + }, + "buttons": { + "description": "Set of actions applicable to the current card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "tap": { + "$ref": "#/definitions/CardAction", + "description": "This action will be activated when user taps on the card itself" + } } - } - }, - "AnimationCard": { - "description": "An animation card (Ex: gif or short video clip)", - "type": "object", - "properties": { - "title": { - "description": "Title of this card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of this card", - "type": "string" - }, - "text": { - "description": "Text of this card", - "type": "string" - }, - "image": { - "$ref": "#/definitions/ThumbnailUrl", - "description": "Thumbnail placeholder" - }, - "media": { - "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", - "type": "array", - "items": { - "$ref": "#/definitions/MediaUrl" + }, + "CardImage": { + "description": "An image on a card", + "type": "object", + "properties": { + "url": { + "description": "URL thumbnail image for major content property", + "type": "string" + }, + "alt": { + "description": "Image description intended for screen readers", + "type": "string" + }, + "tap": { + "$ref": "#/definitions/CardAction", + "description": "Action assigned to specific Attachment" } - }, - "buttons": { - "description": "Actions on this card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "AnimationCard": { + "description": "An animation card (Ex: gif or short video clip)", + "type": "object", + "properties": { + "title": { + "description": "Title of this card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of this card", + "type": "string" + }, + "text": { + "description": "Text of this card", + "type": "string" + }, + "image": { + "$ref": "#/definitions/ThumbnailUrl", + "description": "Thumbnail placeholder" + }, + "media": { + "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", + "type": "array", + "items": { + "$ref": "#/definitions/MediaUrl" + } + }, + "buttons": { + "description": "Actions on this card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "shareable": { + "description": "This content may be shared with others (default:true)", + "type": "boolean" + }, + "autoloop": { + "description": "Should the client loop playback at end of content (default:true)", + "type": "boolean" + }, + "autostart": { + "description": "Should the client automatically start playback of media in this card (default:true)", + "type": "boolean" + }, + "aspect": { + "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", + "type": "string" + }, + "duration": { + "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", + "type": "string" + }, + "value": { + "description": "Supplementary parameter for this card", + "type": "object" } - }, - "shareable": { - "description": "This content may be shared with others (default:true)", - "type": "boolean" - }, - "autoloop": { - "description": "Should the client loop playback at end of content (default:true)", - "type": "boolean" - }, - "autostart": { - "description": "Should the client automatically start playback of media in this card (default:true)", - "type": "boolean" - }, - "aspect": { - "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", - "type": "string" - }, - "duration": { - "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", - "type": "string" - }, - "value": { - "description": "Supplementary parameter for this card", - "type": "object" } - } - }, - "ThumbnailUrl": { - "description": "Thumbnail URL", - "type": "object", - "properties": { - "url": { - "description": "URL pointing to the thumbnail to use for media content", - "type": "string" - }, - "alt": { - "description": "HTML alt text to include on this thumbnail image", - "type": "string" + }, + "ThumbnailUrl": { + "description": "Thumbnail URL", + "type": "object", + "properties": { + "url": { + "description": "URL pointing to the thumbnail to use for media content", + "type": "string" + }, + "alt": { + "description": "HTML alt text to include on this thumbnail image", + "type": "string" + } } - } - }, - "MediaUrl": { - "description": "Media URL", - "type": "object", - "properties": { - "url": { - "description": "Url for the media", - "type": "string" - }, - "profile": { - "description": "Optional profile hint to the client to differentiate multiple MediaUrl objects from each other", - "type": "string" + }, + "MediaUrl": { + "description": "Media URL", + "type": "object", + "properties": { + "url": { + "description": "Url for the media", + "type": "string" + }, + "profile": { + "description": "Optional profile hint to the client to differentiate multiple MediaUrl objects from each other", + "type": "string" + } } - } - }, - "AudioCard": { - "description": "Audio card", - "type": "object", - "properties": { - "title": { - "description": "Title of this card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of this card", - "type": "string" - }, - "text": { - "description": "Text of this card", - "type": "string" - }, - "image": { - "$ref": "#/definitions/ThumbnailUrl", - "description": "Thumbnail placeholder" - }, - "media": { - "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", - "type": "array", - "items": { - "$ref": "#/definitions/MediaUrl" + }, + "AudioCard": { + "description": "Audio card", + "type": "object", + "properties": { + "title": { + "description": "Title of this card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of this card", + "type": "string" + }, + "text": { + "description": "Text of this card", + "type": "string" + }, + "image": { + "$ref": "#/definitions/ThumbnailUrl", + "description": "Thumbnail placeholder" + }, + "media": { + "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", + "type": "array", + "items": { + "$ref": "#/definitions/MediaUrl" + } + }, + "buttons": { + "description": "Actions on this card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "shareable": { + "description": "This content may be shared with others (default:true)", + "type": "boolean" + }, + "autoloop": { + "description": "Should the client loop playback at end of content (default:true)", + "type": "boolean" + }, + "autostart": { + "description": "Should the client automatically start playback of media in this card (default:true)", + "type": "boolean" + }, + "aspect": { + "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", + "type": "string" + }, + "duration": { + "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", + "type": "string" + }, + "value": { + "description": "Supplementary parameter for this card", + "type": "object" } - }, - "buttons": { - "description": "Actions on this card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "BasicCard": { + "description": "A basic card", + "type": "object", + "properties": { + "title": { + "description": "Title of the card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of the card", + "type": "string" + }, + "text": { + "description": "Text for the card", + "type": "string" + }, + "images": { + "description": "Array of images for the card", + "type": "array", + "items": { + "$ref": "#/definitions/CardImage" + } + }, + "buttons": { + "description": "Set of actions applicable to the current card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "tap": { + "$ref": "#/definitions/CardAction", + "description": "This action will be activated when user taps on the card itself" } - }, - "shareable": { - "description": "This content may be shared with others (default:true)", - "type": "boolean" - }, - "autoloop": { - "description": "Should the client loop playback at end of content (default:true)", - "type": "boolean" - }, - "autostart": { - "description": "Should the client automatically start playback of media in this card (default:true)", - "type": "boolean" - }, - "aspect": { - "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", - "type": "string" - }, - "duration": { - "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", - "type": "string" - }, - "value": { - "description": "Supplementary parameter for this card", - "type": "object" } - } - }, - "BasicCard": { - "description": "A basic card", - "type": "object", - "properties": { - "title": { - "description": "Title of the card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of the card", - "type": "string" - }, - "text": { - "description": "Text for the card", - "type": "string" - }, - "images": { - "description": "Array of images for the card", - "type": "array", - "items": { - "$ref": "#/definitions/CardImage" + }, + "MediaCard": { + "description": "Media card", + "type": "object", + "properties": { + "title": { + "description": "Title of this card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of this card", + "type": "string" + }, + "text": { + "description": "Text of this card", + "type": "string" + }, + "image": { + "$ref": "#/definitions/ThumbnailUrl", + "description": "Thumbnail placeholder" + }, + "media": { + "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", + "type": "array", + "items": { + "$ref": "#/definitions/MediaUrl" + } + }, + "buttons": { + "description": "Actions on this card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "shareable": { + "description": "This content may be shared with others (default:true)", + "type": "boolean" + }, + "autoloop": { + "description": "Should the client loop playback at end of content (default:true)", + "type": "boolean" + }, + "autostart": { + "description": "Should the client automatically start playback of media in this card (default:true)", + "type": "boolean" + }, + "aspect": { + "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", + "type": "string" + }, + "duration": { + "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", + "type": "string" + }, + "value": { + "description": "Supplementary parameter for this card", + "type": "object" } - }, - "buttons": { - "description": "Set of actions applicable to the current card", - "type": "array", + } + }, + "ReceiptCard": { + "description": "A receipt card", + "type": "object", + "properties": { + "title": { + "description": "Title of the card", + "type": "string" + }, + "facts": { + "description": "Array of Fact objects", + "type": "array", + "items": { + "$ref": "#/definitions/Fact" + } + }, "items": { - "$ref": "#/definitions/CardAction" + "description": "Array of Receipt Items", + "type": "array", + "items": { + "$ref": "#/definitions/ReceiptItem" + } + }, + "tap": { + "$ref": "#/definitions/CardAction", + "description": "This action will be activated when user taps on the card" + }, + "total": { + "description": "Total amount of money paid (or to be paid)", + "type": "string" + }, + "tax": { + "description": "Total amount of tax paid (or to be paid)", + "type": "string" + }, + "vat": { + "description": "Total amount of VAT paid (or to be paid)", + "type": "string" + }, + "buttons": { + "description": "Set of actions applicable to the current card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } } - }, - "tap": { - "$ref": "#/definitions/CardAction", - "description": "This action will be activated when user taps on the card itself" } - } - }, - "MediaCard": { - "description": "Media card", - "type": "object", - "properties": { - "title": { - "description": "Title of this card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of this card", - "type": "string" - }, - "text": { - "description": "Text of this card", - "type": "string" - }, - "image": { - "$ref": "#/definitions/ThumbnailUrl", - "description": "Thumbnail placeholder" - }, - "media": { - "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", - "type": "array", - "items": { - "$ref": "#/definitions/MediaUrl" + }, + "Fact": { + "description": "Set of key-value pairs. Advantage of this section is that key and value properties will be \r\nrendered with default style information with some delimiter between them. So there is no need for developer to specify style information.", + "type": "object", + "properties": { + "key": { + "description": "The key for this Fact", + "type": "string" + }, + "value": { + "description": "The value for this Fact", + "type": "string" } - }, - "buttons": { - "description": "Actions on this card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "ReceiptItem": { + "description": "An item on a receipt card", + "type": "object", + "properties": { + "title": { + "description": "Title of the Card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle appears just below Title field, differs from Title in font styling only", + "type": "string" + }, + "text": { + "description": "Text field appears just below subtitle, differs from Subtitle in font styling only", + "type": "string" + }, + "image": { + "$ref": "#/definitions/CardImage", + "description": "Image" + }, + "price": { + "description": "Amount with currency", + "type": "string" + }, + "quantity": { + "description": "Number of items of given kind", + "type": "string" + }, + "tap": { + "$ref": "#/definitions/CardAction", + "description": "This action will be activated when user taps on the Item bubble." } - }, - "shareable": { - "description": "This content may be shared with others (default:true)", - "type": "boolean" - }, - "autoloop": { - "description": "Should the client loop playback at end of content (default:true)", - "type": "boolean" - }, - "autostart": { - "description": "Should the client automatically start playback of media in this card (default:true)", - "type": "boolean" - }, - "aspect": { - "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", - "type": "string" - }, - "duration": { - "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", - "type": "string" - }, - "value": { - "description": "Supplementary parameter for this card", - "type": "object" } - } - }, - "ReceiptCard": { - "description": "A receipt card", - "type": "object", - "properties": { - "title": { - "description": "Title of the card", - "type": "string" - }, - "facts": { - "description": "Array of Fact objects", - "type": "array", - "items": { - "$ref": "#/definitions/Fact" + }, + "SigninCard": { + "description": "A card representing a request to sign in", + "type": "object", + "properties": { + "text": { + "description": "Text for signin request", + "type": "string" + }, + "buttons": { + "description": "Action to use to perform signin", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } } - }, - "items": { - "description": "Array of Receipt Items", - "type": "array", - "items": { - "$ref": "#/definitions/ReceiptItem" + } + }, + "OAuthCard": { + "description": "A card representing a request to perform a sign in via OAuth", + "type": "object", + "properties": { + "text": { + "description": "Text for signin request", + "type": "string" + }, + "connectionName": { + "description": "The name of the registered connection", + "type": "string" + }, + "buttons": { + "description": "Action to use to perform signin", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } } - }, - "tap": { - "$ref": "#/definitions/CardAction", - "description": "This action will be activated when user taps on the card" - }, - "total": { - "description": "Total amount of money paid (or to be paid)", - "type": "string" - }, - "tax": { - "description": "Total amount of tax paid (or to be paid)", - "type": "string" - }, - "vat": { - "description": "Total amount of VAT paid (or to be paid)", - "type": "string" - }, - "buttons": { - "description": "Set of actions applicable to the current card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "ThumbnailCard": { + "description": "A thumbnail card (card with a single, small thumbnail image)", + "type": "object", + "properties": { + "title": { + "description": "Title of the card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of the card", + "type": "string" + }, + "text": { + "description": "Text for the card", + "type": "string" + }, + "images": { + "description": "Array of images for the card", + "type": "array", + "items": { + "$ref": "#/definitions/CardImage" + } + }, + "buttons": { + "description": "Set of actions applicable to the current card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "tap": { + "$ref": "#/definitions/CardAction", + "description": "This action will be activated when user taps on the card itself" } } - } - }, - "Fact": { - "description": "Set of key-value pairs. Advantage of this section is that key and value properties will be \r\nrendered with default style information with some delimiter between them. So there is no need for developer to specify style information.", - "type": "object", - "properties": { - "key": { - "description": "The key for this Fact", - "type": "string" - }, - "value": { - "description": "The value for this Fact", - "type": "string" + }, + "VideoCard": { + "description": "Video card", + "type": "object", + "properties": { + "title": { + "description": "Title of this card", + "type": "string" + }, + "subtitle": { + "description": "Subtitle of this card", + "type": "string" + }, + "text": { + "description": "Text of this card", + "type": "string" + }, + "image": { + "$ref": "#/definitions/ThumbnailUrl", + "description": "Thumbnail placeholder" + }, + "media": { + "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", + "type": "array", + "items": { + "$ref": "#/definitions/MediaUrl" + } + }, + "buttons": { + "description": "Actions on this card", + "type": "array", + "items": { + "$ref": "#/definitions/CardAction" + } + }, + "shareable": { + "description": "This content may be shared with others (default:true)", + "type": "boolean" + }, + "autoloop": { + "description": "Should the client loop playback at end of content (default:true)", + "type": "boolean" + }, + "autostart": { + "description": "Should the client automatically start playback of media in this card (default:true)", + "type": "boolean" + }, + "aspect": { + "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", + "type": "string" + }, + "duration": { + "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", + "type": "string" + }, + "value": { + "description": "Supplementary parameter for this card", + "type": "object" + } } - } - }, - "ReceiptItem": { - "description": "An item on a receipt card", - "type": "object", - "properties": { - "title": { - "description": "Title of the Card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle appears just below Title field, differs from Title in font styling only", - "type": "string" - }, - "text": { - "description": "Text field appears just below subtitle, differs from Subtitle in font styling only", - "type": "string" - }, - "image": { - "$ref": "#/definitions/CardImage", - "description": "Image" - }, - "price": { - "description": "Amount with currency", - "type": "string" - }, - "quantity": { - "description": "Number of items of given kind", - "type": "string" - }, - "tap": { - "$ref": "#/definitions/CardAction", - "description": "This action will be activated when user taps on the Item bubble." + }, + "GeoCoordinates": { + "description": "GeoCoordinates (entity type: \"https://schema.org/GeoCoordinates\")", + "type": "object", + "properties": { + "elevation": { + "format": "double", + "description": "Elevation of the location [WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)", + "type": "number" + }, + "latitude": { + "format": "double", + "description": "Latitude of the location [WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)", + "type": "number" + }, + "longitude": { + "format": "double", + "description": "Longitude of the location [WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)", + "type": "number" + }, + "type": { + "description": "The type of the thing", + "type": "string" + }, + "name": { + "description": "The name of the thing", + "type": "string" + } } - } - }, - "SigninCard": { - "description": "A card representing a request to sign in", - "type": "object", - "properties": { - "text": { - "description": "Text for signin request", - "type": "string" - }, - "buttons": { - "description": "Action to use to perform signin", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + }, + "Mention": { + "description": "Mention information (entity type: \"mention\")", + "type": "object", + "properties": { + "mentioned": { + "$ref": "#/definitions/ChannelAccount", + "description": "The mentioned user" + }, + "text": { + "description": "Sub Text which represents the mention (can be null or empty)", + "type": "string" + }, + "type": { + "description": "Type of this entity (RFC 3987 IRI)", + "type": "string" } } - } - }, - "OAuthCard": { - "description": "A card representing a request to perform a sign in via OAuth", - "type": "object", - "properties": { - "text": { - "description": "Text for signin request", - "type": "string" - }, - "connectionName": { - "description": "The name of the registered connection", - "type": "string" - }, - "buttons": { - "description": "Action to use to perform signin", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + }, + "Place": { + "description": "Place (entity type: \"https://schema.org/Place\")", + "type": "object", + "properties": { + "address": { + "description": "Address of the place (may be `string` or complex object of type `PostalAddress`)", + "type": "object" + }, + "geo": { + "description": "Geo coordinates of the place (may be complex object of type `GeoCoordinates` or `GeoShape`)", + "type": "object" + }, + "hasMap": { + "description": "Map to the place (may be `string` (URL) or complex object of type `Map`)", + "type": "object" + }, + "type": { + "description": "The type of the thing", + "type": "string" + }, + "name": { + "description": "The name of the thing", + "type": "string" } } - } - }, - "ThumbnailCard": { - "description": "A thumbnail card (card with a single, small thumbnail image)", - "type": "object", - "properties": { - "title": { - "description": "Title of the card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of the card", - "type": "string" - }, - "text": { - "description": "Text for the card", - "type": "string" - }, - "images": { - "description": "Array of images for the card", - "type": "array", - "items": { - "$ref": "#/definitions/CardImage" + }, + "Thing": { + "description": "Thing (entity type: \"https://schema.org/Thing\")", + "type": "object", + "properties": { + "type": { + "description": "The type of the thing", + "type": "string" + }, + "name": { + "description": "The name of the thing", + "type": "string" } - }, - "buttons": { - "description": "Set of actions applicable to the current card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "MediaEventValue": { + "description": "Supplementary parameter for media events", + "type": "object", + "properties": { + "cardValue": { + "description": "Callback parameter specified in the Value field of the MediaCard that originated this event", + "type": "object" } - }, - "tap": { - "$ref": "#/definitions/CardAction", - "description": "This action will be activated when user taps on the card itself" } - } - }, - "VideoCard": { - "description": "Video card", - "type": "object", - "properties": { - "title": { - "description": "Title of this card", - "type": "string" - }, - "subtitle": { - "description": "Subtitle of this card", - "type": "string" - }, - "text": { - "description": "Text of this card", - "type": "string" - }, - "image": { - "$ref": "#/definitions/ThumbnailUrl", - "description": "Thumbnail placeholder" - }, - "media": { - "description": "Media URLs for this card. When this field contains more than one URL, each URL is an alternative format of the same content.", - "type": "array", - "items": { - "$ref": "#/definitions/MediaUrl" + }, + "TokenRequest": { + "description": "A request to receive a user token", + "type": "object", + "properties": { + "provider": { + "description": "The provider to request a user token from", + "type": "string" + }, + "settings": { + "description": "A collection of settings for the specific provider for this request", + "type": "object", + "additionalProperties": { + "type": "object" + } } - }, - "buttons": { - "description": "Actions on this card", - "type": "array", - "items": { - "$ref": "#/definitions/CardAction" + } + }, + "TokenResponse": { + "description": "A response that includes a user token", + "type": "object", + "properties": { + "channelId": { + "description": "The channelId of the TokenResponse", + "type": "string" + }, + "connectionName": { + "description": "The connection name", + "type": "string" + }, + "token": { + "description": "The user token", + "type": "string" + }, + "expiration": { + "description": "Expiration for the token, in ISO 8601 format (e.g. \"2007-04-05T14:30Z\")", + "type": "string" } - }, - "shareable": { - "description": "This content may be shared with others (default:true)", - "type": "boolean" - }, - "autoloop": { - "description": "Should the client loop playback at end of content (default:true)", - "type": "boolean" - }, - "autostart": { - "description": "Should the client automatically start playback of media in this card (default:true)", - "type": "boolean" - }, - "aspect": { - "description": "Aspect ratio of thumbnail/media placeholder. Allowed values are \"16:9\" and \"4:3\"", - "type": "string" - }, - "duration": { - "description": "Describes the length of the media content without requiring a receiver to open the content. Formatted as an ISO 8601 Duration field.", - "type": "string" - }, - "value": { - "description": "Supplementary parameter for this card", - "type": "object" } - } - }, - "GeoCoordinates": { - "description": "GeoCoordinates (entity type: \"https://schema.org/GeoCoordinates\")", - "type": "object", - "properties": { - "elevation": { - "format": "double", - "description": "Elevation of the location [WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)", - "type": "number" - }, - "latitude": { - "format": "double", - "description": "Latitude of the location [WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)", - "type": "number" - }, - "longitude": { - "format": "double", - "description": "Longitude of the location [WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)", - "type": "number" - }, - "type": { - "description": "The type of the thing", - "type": "string" - }, - "name": { - "description": "The name of the thing", - "type": "string" + }, + "ActivityTypes": { + "description": "Types of Activities", + "enum": [ + "message", + "contactRelationUpdate", + "conversationUpdate", + "typing", + "endOfConversation", + "event", + "invoke", + "deleteUserData", + "messageUpdate", + "messageDelete", + "installationUpdate", + "messageReaction", + "suggestion", + "trace", + "handoff" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "ActivityTypes", + "modelAsString": true } - } - }, - "Mention": { - "description": "Mention information (entity type: \"mention\")", - "type": "object", - "properties": { - "mentioned": { - "$ref": "#/definitions/ChannelAccount", - "description": "The mentioned user" - }, - "text": { - "description": "Sub Text which represents the mention (can be null or empty)", - "type": "string" - }, - "type": { - "description": "Type of this entity (RFC 3987 IRI)", - "type": "string" + }, + "AttachmentLayoutTypes": { + "description": "Attachment layout types", + "enum": [ + "list", + "carousel" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "AttachmentLayoutTypes", + "modelAsString": true + } + }, + "SemanticActionStates": { + "description": "Indicates whether the semantic action is starting, continuing, or done", + "enum": [ + "start", + "continue", + "done" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "SemanticActionStates", + "modelAsString": true + } + }, + "ActionTypes": { + "description": "Defines action types for clickable buttons.", + "enum": [ + "openUrl", + "imBack", + "postBack", + "playAudio", + "playVideo", + "showImage", + "downloadFile", + "signin", + "call", + "payment", + "messageBack" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "ActionTypes", + "modelAsString": true + } + }, + "ContactRelationUpdateActionTypes": { + "description": "Action types valid for ContactRelationUpdate activities", + "enum": [ + "add", + "remove" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "ContactRelationUpdateActionTypes", + "modelAsString": true + } + }, + "InstallationUpdateActionTypes": { + "description": "Action types valid for InstallationUpdate activities", + "enum": [ + "add", + "remove" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "InstallationUpdateActionTypes", + "modelAsString": true + } + }, + "MessageReactionTypes": { + "description": "Message reaction types", + "enum": [ + "like", + "plusOne" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "MessageReactionTypes", + "modelAsString": true } - } - }, - "Place": { - "description": "Place (entity type: \"https://schema.org/Place\")", - "type": "object", - "properties": { - "address": { - "description": "Address of the place (may be `string` or complex object of type `PostalAddress`)", - "type": "object" - }, - "geo": { - "description": "Geo coordinates of the place (may be complex object of type `GeoCoordinates` or `GeoShape`)", - "type": "object" - }, - "hasMap": { - "description": "Map to the place (may be `string` (URL) or complex object of type `Map`)", - "type": "object" - }, - "type": { - "description": "The type of the thing", - "type": "string" - }, - "name": { - "description": "The name of the thing", - "type": "string" + }, + "TextFormatTypes": { + "description": "Text format types", + "enum": [ + "markdown", + "plain", + "xml" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "TextFormatTypes", + "modelAsString": true } - } - }, - "Thing": { - "description": "Thing (entity type: \"https://schema.org/Thing\")", - "type": "object", - "properties": { - "type": { - "description": "The type of the thing", - "type": "string" - }, - "name": { - "description": "The name of the thing", - "type": "string" + }, + "InputHints": { + "description": "Indicates whether the bot is accepting, expecting, or ignoring input", + "enum": [ + "acceptingInput", + "ignoringInput", + "expectingInput" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "InputHints", + "modelAsString": true } - } - }, - "MediaEventValue": { - "description": "Supplementary parameter for media events", - "type": "object", - "properties": { - "cardValue": { - "description": "Callback parameter specified in the Value field of the MediaCard that originated this event", - "type": "object" + }, + "EndOfConversationCodes": { + "description": "Codes indicating why a conversation has ended", + "enum": [ + "unknown", + "completedSuccessfully", + "userCancelled", + "botTimedOut", + "botIssuedInvalidMessage", + "channelFailed" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "EndOfConversationCodes", + "modelAsString": true } - } - }, - "TokenRequest": { - "description": "A request to receive a user token", - "type": "object", - "properties": { - "provider": { - "description": "The provider to request a user token from", - "type": "string" - }, - "settings": { - "description": "A collection of settings for the specific provider for this request", - "type": "object", - "additionalProperties": { - "type": "object" - } + }, + "ActivityImportance": { + "description": "Defines the importance of an Activity", + "enum": [ + "low", + "normal", + "high" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "ActivityImportance", + "modelAsString": true } - } - }, - "TokenResponse": { - "description": "A response that includes a user token", - "type": "object", - "properties": { - "channelId": { - "description": "The channelId of the TokenResponse", - "type": "string" - }, - "connectionName": { - "description": "The connection name", - "type": "string" - }, - "token": { - "description": "The user token", - "type": "string" - }, - "expiration": { - "description": "Expiration for the token, in ISO 8601 format (e.g. \"2007-04-05T14:30Z\")", - "type": "string" + }, + "RoleTypes": { + "description": "Role of the entity behind the account (Example: User, Bot, etc.)", + "enum": [ + "user", + "bot" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "RoleTypes", + "modelAsString": true } - } - }, - "ActivityTypes": { - "description": "Types of Activities", - "enum": [ - "message", - "contactRelationUpdate", - "conversationUpdate", - "typing", - "endOfConversation", - "event", - "invoke", - "deleteUserData", - "messageUpdate", - "messageDelete", - "installationUpdate", - "messageReaction", - "suggestion", - "trace", - "handoff" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "ActivityTypes", - "modelAsString": true - } - }, - "AttachmentLayoutTypes": { - "description": "Attachment layout types", - "enum": [ - "list", - "carousel" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "AttachmentLayoutTypes", - "modelAsString": true - } - }, - "SemanticActionStates": { - "description": "Indicates whether the semantic action is starting, continuing, or done", - "enum": [ - "start", - "continue", - "done" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "SemanticActionStates", - "modelAsString": true - } - }, - "ActionTypes": { - "description": "Defines action types for clickable buttons.", - "enum": [ - "openUrl", - "imBack", - "postBack", - "playAudio", - "playVideo", - "showImage", - "downloadFile", - "signin", - "call", - "payment", - "messageBack" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "ActionTypes", - "modelAsString": true - } - }, - "ContactRelationUpdateActionTypes": { - "description": "Action types valid for ContactRelationUpdate activities", - "enum": [ - "add", - "remove" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "ContactRelationUpdateActionTypes", - "modelAsString": true - } - }, - "InstallationUpdateActionTypes": { - "description": "Action types valid for InstallationUpdate activities", - "enum": [ - "add", - "remove" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "InstallationUpdateActionTypes", - "modelAsString": true - } - }, - "MessageReactionTypes": { - "description": "Message reaction types", - "enum": [ - "like", - "plusOne" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "MessageReactionTypes", - "modelAsString": true - } - }, - "TextFormatTypes": { - "description": "Text format types", - "enum": [ - "markdown", - "plain", - "xml" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "TextFormatTypes", - "modelAsString": true - } - }, - "InputHints": { - "description": "Indicates whether the bot is accepting, expecting, or ignoring input", - "enum": [ - "acceptingInput", - "ignoringInput", - "expectingInput" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "InputHints", - "modelAsString": true - } - }, - "EndOfConversationCodes": { - "description": "Codes indicating why a conversation has ended", - "enum": [ - "unknown", - "completedSuccessfully", - "userCancelled", - "botTimedOut", - "botIssuedInvalidMessage", - "channelFailed" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "EndOfConversationCodes", - "modelAsString": true - } - }, - "ActivityImportance": { - "description": "Defines the importance of an Activity", - "enum": [ - "low", - "normal", - "high" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "ActivityImportance", - "modelAsString": true - } - }, - "RoleTypes": { - "description": "Role of the entity behind the account (Example: User, Bot, etc.)", - "enum": [ - "user", - "bot" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "RoleTypes", - "modelAsString": true - } - }, - "DeliveryModes": { - "description": "Values for deliveryMode field", - "enum": [ - "normal", - "notification" - ], - "type": "string", - "properties": {}, - "x-ms-enum": { - "name": "DeliveryModes", - "modelAsString": true - } - }, - "MicrosoftPayMethodData": { - "description": "W3C Payment Method Data for Microsoft Pay", - "type": "object", - "properties": { - "merchantId": { - "description": "Microsoft Pay Merchant ID", - "type": "string" - }, - "supportedNetworks": { - "description": "Supported payment networks (e.g., \"visa\" and \"mastercard\")", - "type": "array", - "items": { + }, + "DeliveryModes": { + "description": "Values for deliveryMode field", + "enum": [ + "normal", + "notification" + ], + "type": "string", + "properties": {}, + "x-ms-enum": { + "name": "DeliveryModes", + "modelAsString": true + } + }, + "MicrosoftPayMethodData": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "merchantId": { + "description": "Microsoft Pay Merchant ID", "type": "string" + }, + "supportedNetworks": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "array", + "items": { + "type": "string" + } + }, + "supportedTypes": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "array", + "items": { + "type": "string" + } } - }, - "supportedTypes": { - "description": "Supported payment types (e.g., \"credit\")", - "type": "array", - "items": { + } + }, + "PaymentAddress": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "country": { + "description": "This is the CLDR (Common Locale Data Repository) region code. For example, US, GB, CN, or JP", + "type": "string" + }, + "addressLine": { + "description": "This is the most specific part of the address. It can include, for example, a street name, a house number, apartment number, a rural delivery route, descriptive instructions, or a post office box number.", + "type": "array", + "items": { + "type": "string" + } + }, + "region": { + "description": "This is the top level administrative subdivision of the country. For example, this can be a state, a province, an oblast, or a prefecture.", + "type": "string" + }, + "city": { + "description": "This is the city/town portion of the address.", + "type": "string" + }, + "dependentLocality": { + "description": "This is the dependent locality or sublocality within a city. For example, used for neighborhoods, boroughs, districts, or UK dependent localities.", + "type": "string" + }, + "postalCode": { + "description": "This is the postal code or ZIP code, also known as PIN code in India.", + "type": "string" + }, + "sortingCode": { + "description": "This is the sorting code as used in, for example, France.", + "type": "string" + }, + "languageCode": { + "description": "This is the BCP-47 language code for the address. It's used to determine the field separators and the order of fields when formatting the address for display.", + "type": "string" + }, + "organization": { + "description": "This is the organization, firm, company, or institution at this address.", + "type": "string" + }, + "recipient": { + "description": "This is the name of the recipient or contact person.", + "type": "string" + }, + "phone": { + "description": "This is the phone number of the recipient or contact person.", "type": "string" } } - } - }, - "PaymentAddress": { - "description": "Address within a Payment Request", - "type": "object", - "properties": { - "country": { - "description": "This is the CLDR (Common Locale Data Repository) region code. For example, US, GB, CN, or JP", - "type": "string" - }, - "addressLine": { - "description": "This is the most specific part of the address. It can include, for example, a street name, a house number, apartment number, a rural delivery route, descriptive instructions, or a post office box number.", - "type": "array", - "items": { + }, + "PaymentCurrencyAmount": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "currency": { + "description": "A currency identifier", + "type": "string" + }, + "value": { + "description": "Decimal monetary value", + "type": "string" + }, + "currencySystem": { + "description": "Currency system", "type": "string" } - }, - "region": { - "description": "This is the top level administrative subdivision of the country. For example, this can be a state, a province, an oblast, or a prefecture.", - "type": "string" - }, - "city": { - "description": "This is the city/town portion of the address.", - "type": "string" - }, - "dependentLocality": { - "description": "This is the dependent locality or sublocality within a city. For example, used for neighborhoods, boroughs, districts, or UK dependent localities.", - "type": "string" - }, - "postalCode": { - "description": "This is the postal code or ZIP code, also known as PIN code in India.", - "type": "string" - }, - "sortingCode": { - "description": "This is the sorting code as used in, for example, France.", - "type": "string" - }, - "languageCode": { - "description": "This is the BCP-47 language code for the address. It's used to determine the field separators and the order of fields when formatting the address for display.", - "type": "string" - }, - "organization": { - "description": "This is the organization, firm, company, or institution at this address.", - "type": "string" - }, - "recipient": { - "description": "This is the name of the recipient or contact person.", - "type": "string" - }, - "phone": { - "description": "This is the phone number of the recipient or contact person.", - "type": "string" - } - } - }, - "PaymentCurrencyAmount": { - "description": "Supplies monetary amounts", - "type": "object", - "properties": { - "currency": { - "description": "A currency identifier", - "type": "string" - }, - "value": { - "description": "Decimal monetary value", - "type": "string" - }, - "currencySystem": { - "description": "Currency system", - "type": "string" } - } - }, - "PaymentDetails": { - "description": "Provides information about the requested transaction", - "type": "object", - "properties": { - "total": { - "$ref": "#/definitions/PaymentItem", - "description": "Contains the total amount of the payment request" - }, - "displayItems": { - "description": "Contains line items for the payment request that the user agent may display", - "type": "array", - "items": { - "$ref": "#/definitions/PaymentItem" + }, + "PaymentDetails": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "total": { + "$ref": "#/definitions/PaymentItem", + "description": "Contains the total amount of the payment request" + }, + "displayItems": { + "description": "Contains line items for the payment request that the user agent may display", + "type": "array", + "items": { + "$ref": "#/definitions/PaymentItem" + } + }, + "shippingOptions": { + "description": "A sequence containing the different shipping options for the user to choose from", + "type": "array", + "items": { + "$ref": "#/definitions/PaymentShippingOption" + } + }, + "modifiers": { + "description": "Contains modifiers for particular payment method identifiers", + "type": "array", + "items": { + "$ref": "#/definitions/PaymentDetailsModifier" + } + }, + "error": { + "description": "Error description", + "type": "string" } - }, - "shippingOptions": { - "description": "A sequence containing the different shipping options for the user to choose from", - "type": "array", - "items": { - "$ref": "#/definitions/PaymentShippingOption" + } + }, + "PaymentItem": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "label": { + "description": "Human-readable description of the item", + "type": "string" + }, + "amount": { + "$ref": "#/definitions/PaymentCurrencyAmount", + "description": "Monetary amount for the item" + }, + "pending": { + "description": "When set to true this flag means that the amount field is not final.", + "type": "boolean" } - }, - "modifiers": { - "description": "Contains modifiers for particular payment method identifiers", - "type": "array", - "items": { - "$ref": "#/definitions/PaymentDetailsModifier" + } + }, + "PaymentShippingOption": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "id": { + "description": "String identifier used to reference this PaymentShippingOption", + "type": "string" + }, + "label": { + "description": "Human-readable description of the item", + "type": "string" + }, + "amount": { + "$ref": "#/definitions/PaymentCurrencyAmount", + "description": "Contains the monetary amount for the item" + }, + "selected": { + "description": "Indicates whether this is the default selected PaymentShippingOption", + "type": "boolean" } - }, - "error": { - "description": "Error description", - "type": "string" } - } - }, - "PaymentItem": { - "description": "Indicates what the payment request is for and the value asked for", - "type": "object", - "properties": { - "label": { - "description": "Human-readable description of the item", - "type": "string" - }, - "amount": { - "$ref": "#/definitions/PaymentCurrencyAmount", - "description": "Monetary amount for the item" - }, - "pending": { - "description": "When set to true this flag means that the amount field is not final.", - "type": "boolean" + }, + "PaymentDetailsModifier": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "supportedMethods": { + "description": "Contains a sequence of payment method identifiers", + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "$ref": "#/definitions/PaymentItem", + "description": "This value overrides the total field in the PaymentDetails dictionary for the payment method identifiers in the supportedMethods field" + }, + "additionalDisplayItems": { + "description": "Provides additional display items that are appended to the displayItems field in the PaymentDetails dictionary for the payment method identifiers in the supportedMethods field", + "type": "array", + "items": { + "$ref": "#/definitions/PaymentItem" + } + }, + "data": { + "description": "A JSON-serializable object that provides optional information that might be needed by the supported payment methods", + "type": "object" + } } - } - }, - "PaymentShippingOption": { - "description": "Describes a shipping option", - "type": "object", - "properties": { - "id": { - "description": "String identifier used to reference this PaymentShippingOption", - "type": "string" - }, - "label": { - "description": "Human-readable description of the item", - "type": "string" - }, - "amount": { - "$ref": "#/definitions/PaymentCurrencyAmount", - "description": "Contains the monetary amount for the item" - }, - "selected": { - "description": "Indicates whether this is the default selected PaymentShippingOption", - "type": "boolean" + }, + "PaymentMethodData": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "supportedMethods": { + "description": "Required sequence of strings containing payment method identifiers for payment methods that the merchant web site accepts", + "type": "array", + "items": { + "type": "string" + } + }, + "data": { + "description": "A JSON-serializable object that provides optional information that might be needed by the supported payment methods", + "type": "object" + } } - } - }, - "PaymentDetailsModifier": { - "description": "Provides details that modify the PaymentDetails based on payment method identifier", - "type": "object", - "properties": { - "supportedMethods": { - "description": "Contains a sequence of payment method identifiers", - "type": "array", - "items": { + }, + "PaymentOptions": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "requestPayerName": { + "description": "Indicates whether the user agent should collect and return the payer's name as part of the payment request", + "type": "boolean" + }, + "requestPayerEmail": { + "description": "Indicates whether the user agent should collect and return the payer's email address as part of the payment request", + "type": "boolean" + }, + "requestPayerPhone": { + "description": "Indicates whether the user agent should collect and return the payer's phone number as part of the payment request", + "type": "boolean" + }, + "requestShipping": { + "description": "Indicates whether the user agent should collect and return a shipping address as part of the payment request", + "type": "boolean" + }, + "shippingType": { + "description": "If requestShipping is set to true, then the shippingType field may be used to influence the way the user agent presents the user interface for gathering the shipping address", "type": "string" } - }, - "total": { - "$ref": "#/definitions/PaymentItem", - "description": "This value overrides the total field in the PaymentDetails dictionary for the payment method identifiers in the supportedMethods field" - }, - "additionalDisplayItems": { - "description": "Provides additional display items that are appended to the displayItems field in the PaymentDetails dictionary for the payment method identifiers in the supportedMethods field", - "type": "array", - "items": { - "$ref": "#/definitions/PaymentItem" - } - }, - "data": { - "description": "A JSON-serializable object that provides optional information that might be needed by the supported payment methods", - "type": "object" } - } - }, - "PaymentMethodData": { - "description": "Indicates a set of supported payment methods and any associated payment method specific data for those methods", - "type": "object", - "properties": { - "supportedMethods": { - "description": "Required sequence of strings containing payment method identifiers for payment methods that the merchant web site accepts", - "type": "array", - "items": { + }, + "PaymentRequest": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "id": { + "description": "ID of this payment request", + "type": "string" + }, + "methodData": { + "description": "Allowed payment methods for this request", + "type": "array", + "items": { + "$ref": "#/definitions/PaymentMethodData" + } + }, + "details": { + "$ref": "#/definitions/PaymentDetails", + "description": "Details for this request" + }, + "options": { + "$ref": "#/definitions/PaymentOptions", + "description": "Provides information about the options desired for the payment request" + }, + "expires": { + "description": "Expiration for this request, in ISO 8601 duration format (e.g., 'P1D')", "type": "string" } - }, - "data": { - "description": "A JSON-serializable object that provides optional information that might be needed by the supported payment methods", - "type": "object" - } - } - }, - "PaymentOptions": { - "description": "Provides information about the options desired for the payment request", - "type": "object", - "properties": { - "requestPayerName": { - "description": "Indicates whether the user agent should collect and return the payer's name as part of the payment request", - "type": "boolean" - }, - "requestPayerEmail": { - "description": "Indicates whether the user agent should collect and return the payer's email address as part of the payment request", - "type": "boolean" - }, - "requestPayerPhone": { - "description": "Indicates whether the user agent should collect and return the payer's phone number as part of the payment request", - "type": "boolean" - }, - "requestShipping": { - "description": "Indicates whether the user agent should collect and return a shipping address as part of the payment request", - "type": "boolean" - }, - "shippingType": { - "description": "If requestShipping is set to true, then the shippingType field may be used to influence the way the user agent presents the user interface for gathering the shipping address", - "type": "string" } - } - }, - "PaymentRequest": { - "description": "A request to make a payment", - "type": "object", - "properties": { - "id": { - "description": "ID of this payment request", - "type": "string" - }, - "methodData": { - "description": "Allowed payment methods for this request", - "type": "array", - "items": { - "$ref": "#/definitions/PaymentMethodData" + }, + "PaymentRequestComplete": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "id": { + "description": "Payment request ID", + "type": "string" + }, + "paymentRequest": { + "$ref": "#/definitions/PaymentRequest", + "description": "Initial payment request" + }, + "paymentResponse": { + "$ref": "#/definitions/PaymentResponse", + "description": "Corresponding payment response" } - }, - "details": { - "$ref": "#/definitions/PaymentDetails", - "description": "Details for this request" - }, - "options": { - "$ref": "#/definitions/PaymentOptions", - "description": "Provides information about the options desired for the payment request" - }, - "expires": { - "description": "Expiration for this request, in ISO 8601 duration format (e.g., 'P1D')", - "type": "string" } - } - }, - "PaymentRequestComplete": { - "description": "Payload delivered when completing a payment request", - "type": "object", - "properties": { - "id": { - "description": "Payment request ID", - "type": "string" - }, - "paymentRequest": { - "$ref": "#/definitions/PaymentRequest", - "description": "Initial payment request" - }, - "paymentResponse": { - "$ref": "#/definitions/PaymentResponse", - "description": "Corresponding payment response" + }, + "PaymentResponse": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "methodName": { + "description": "The payment method identifier for the payment method that the user selected to fulfil the transaction", + "type": "string" + }, + "details": { + "description": "A JSON-serializable object that provides a payment method specific message used by the merchant to process the transaction and determine successful fund transfer", + "type": "object" + }, + "shippingAddress": { + "$ref": "#/definitions/PaymentAddress", + "description": "If the requestShipping flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then shippingAddress will be the full and final shipping address chosen by the user" + }, + "shippingOption": { + "description": "If the requestShipping flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then shippingOption will be the id attribute of the selected shipping option", + "type": "string" + }, + "payerEmail": { + "description": "If the requestPayerEmail flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then payerEmail will be the email address chosen by the user", + "type": "string" + }, + "payerPhone": { + "description": "If the requestPayerPhone flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then payerPhone will be the phone number chosen by the user", + "type": "string" + } } - } - }, - "PaymentResponse": { - "description": "A PaymentResponse is returned when a user has selected a payment method and approved a payment request", - "type": "object", - "properties": { - "methodName": { - "description": "The payment method identifier for the payment method that the user selected to fulfil the transaction", - "type": "string" - }, - "details": { - "description": "A JSON-serializable object that provides a payment method specific message used by the merchant to process the transaction and determine successful fund transfer", - "type": "object" - }, - "shippingAddress": { - "$ref": "#/definitions/PaymentAddress", - "description": "If the requestShipping flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then shippingAddress will be the full and final shipping address chosen by the user" - }, - "shippingOption": { - "description": "If the requestShipping flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then shippingOption will be the id attribute of the selected shipping option", - "type": "string" - }, - "payerEmail": { - "description": "If the requestPayerEmail flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then payerEmail will be the email address chosen by the user", - "type": "string" - }, - "payerPhone": { - "description": "If the requestPayerPhone flag was set to true in the PaymentOptions passed to the PaymentRequest constructor, then payerPhone will be the phone number chosen by the user", - "type": "string" + }, + "PaymentRequestCompleteResult": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "result": { + "description": "Result of the payment request completion", + "type": "string" + } } - } - }, - "PaymentRequestCompleteResult": { - "description": "Result from a completed payment request", - "type": "object", - "properties": { - "result": { - "description": "Result of the payment request completion", - "type": "string" + }, + "PaymentRequestUpdate": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "id": { + "description": "ID for the payment request to update", + "type": "string" + }, + "details": { + "$ref": "#/definitions/PaymentDetails", + "description": "Update payment details" + }, + "shippingAddress": { + "$ref": "#/definitions/PaymentAddress", + "description": "Updated shipping address" + }, + "shippingOption": { + "description": "Updated shipping options", + "type": "string" + } } - } - }, - "PaymentRequestUpdate": { - "description": "An update to a payment request", - "type": "object", - "properties": { - "id": { - "description": "ID for the payment request to update", - "type": "string" - }, - "details": { - "$ref": "#/definitions/PaymentDetails", - "description": "Update payment details" - }, - "shippingAddress": { - "$ref": "#/definitions/PaymentAddress", - "description": "Updated shipping address" - }, - "shippingOption": { - "description": "Updated shipping options", - "type": "string" + }, + "PaymentRequestUpdateResult": { + "deprecated": true, + "description": "Deprecated. Bot Framework no longer supports payments.", + "type": "object", + "properties": { + "details": { + "$ref": "#/definitions/PaymentDetails", + "description": "Update payment details" + } } } }, - "PaymentRequestUpdateResult": { - "description": "A result object from a Payment Request Update invoke operation", - "type": "object", - "properties": { - "details": { - "$ref": "#/definitions/PaymentDetails", - "description": "Update payment details" - } + "securityDefinitions": { + "bearer_auth": { + "type": "apiKey", + "description": "Access token to authenticate calls to the Bot Connector Service.", + "name": "Authorization", + "in": "header" } } - }, - "securityDefinitions": { - "bearer_auth": { - "type": "apiKey", - "description": "Access token to authenticate calls to the Bot Connector Service.", - "name": "Authorization", - "in": "header" - } - } -} \ No newline at end of file + } \ No newline at end of file From 6be6508b5056a0bcd5aa884d3b3a8e9ba0492f46 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 9 Dec 2019 14:07:34 -0800 Subject: [PATCH 085/616] fix async await issues in teams_info (#485) --- .../botbuilder/core/teams/teams_info.py | 38 +++++++++---------- scenarios/mentions/app.py | 2 +- scenarios/roster/bots/roster_bot.py | 4 +- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index b547180d0..b70fe256f 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -15,7 +15,9 @@ class TeamsInfo: @staticmethod - def get_team_details(turn_context: TurnContext, team_id: str = "") -> TeamDetails: + async def get_team_details( + turn_context: TurnContext, team_id: str = "" + ) -> TeamDetails: if not team_id: team_id = TeamsInfo.get_team_id(turn_context) @@ -24,12 +26,11 @@ def get_team_details(turn_context: TurnContext, team_id: str = "") -> TeamDetail "TeamsInfo.get_team_details: method is only valid within the scope of MS Teams Team." ) - return TeamsInfo.get_teams_connector_client( - turn_context - ).teams.get_team_details(team_id) + teams_connector = await TeamsInfo.get_teams_connector_client(turn_context) + return teams_connector.teams.get_team_details(team_id) @staticmethod - def get_team_channels( + async def get_team_channels( turn_context: TurnContext, team_id: str = "" ) -> List[ChannelInfo]: if not team_id: @@ -40,11 +41,8 @@ def get_team_channels( "TeamsInfo.get_team_channels: method is only valid within the scope of MS Teams Team." ) - return ( - TeamsInfo.get_teams_connector_client(turn_context) - .teams.get_teams_channels(team_id) - .conversations - ) + teams_connector = await TeamsInfo.get_teams_connector_client(turn_context) + return teams_connector.teams.get_teams_channels(team_id).conversations @staticmethod async def get_team_members(turn_context: TurnContext, team_id: str = ""): @@ -56,9 +54,9 @@ async def get_team_members(turn_context: TurnContext, team_id: str = ""): "TeamsInfo.get_team_members: method is only valid within the scope of MS Teams Team." ) + connector_client = await TeamsInfo._get_connector_client(turn_context) return await TeamsInfo._get_members( - TeamsInfo._get_connector_client(turn_context), - turn_context.activity.conversation.id, + connector_client, turn_context.activity.conversation.id, ) @staticmethod @@ -66,15 +64,16 @@ async def get_members(turn_context: TurnContext) -> List[TeamsChannelAccount]: team_id = TeamsInfo.get_team_id(turn_context) if not team_id: conversation_id = turn_context.activity.conversation.id - return await TeamsInfo._get_members( - TeamsInfo._get_connector_client(turn_context), conversation_id - ) + connector_client = await TeamsInfo._get_connector_client(turn_context) + return await TeamsInfo._get_members(connector_client, conversation_id) return await TeamsInfo.get_team_members(turn_context, team_id) @staticmethod - def get_teams_connector_client(turn_context: TurnContext) -> TeamsConnectorClient: - connector_client = TeamsInfo._get_connector_client(turn_context) + async def get_teams_connector_client( + turn_context: TurnContext, + ) -> TeamsConnectorClient: + connector_client = await TeamsInfo._get_connector_client(turn_context) return TeamsConnectorClient( connector_client.config.credentials, turn_context.activity.service_url ) @@ -86,13 +85,12 @@ def get_teams_connector_client(turn_context: TurnContext) -> TeamsConnectorClien def get_team_id(turn_context: TurnContext): channel_data = TeamsChannelData(**turn_context.activity.channel_data) if channel_data.team: - # urllib.parse.quote_plus( return channel_data.team["id"] return "" @staticmethod - def _get_connector_client(turn_context: TurnContext) -> ConnectorClient: - return turn_context.adapter.create_connector_client( + async def _get_connector_client(turn_context: TurnContext) -> ConnectorClient: + return await turn_context.adapter.create_connector_client( turn_context.activity.service_url ) diff --git a/scenarios/mentions/app.py b/scenarios/mentions/app.py index bf7aa3de9..1db89f6ec 100644 --- a/scenarios/mentions/app.py +++ b/scenarios/mentions/app.py @@ -29,7 +29,7 @@ # Catch-all for errors. async def on_error( # pylint: disable=unused-argument - context: TurnContext, error: Exception + self, 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 diff --git a/scenarios/roster/bots/roster_bot.py b/scenarios/roster/bots/roster_bot.py index 0b5661f64..eab7b69e5 100644 --- a/scenarios/roster/bots/roster_bot.py +++ b/scenarios/roster/bots/roster_bot.py @@ -43,14 +43,14 @@ async def _show_members( async def _show_channels( self, turn_context: TurnContext ): - channels = TeamsInfo.get_team_channels(turn_context) + channels = await TeamsInfo.get_team_channels(turn_context) reply = MessageFactory.text(f"Total of {len(channels)} channels are currently in team") await turn_context.send_activity(reply) messages = list(map(lambda c: (f'{c.id} --> {c.name}'), channels)) await self._send_in_batches(turn_context, messages) async def _show_details(self, turn_context: TurnContext): - team_details = TeamsInfo.get_team_details(turn_context) + team_details = await TeamsInfo.get_team_details(turn_context) reply = MessageFactory.text(f"The team name is {team_details.name}. The team ID is {team_details.id}. The AADGroupID is {team_details.aad_group_id}.") await turn_context.send_activity(reply) From 3d53599d206a91f2be8a701d8f2fbc42f6799ccc Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 10 Dec 2019 08:56:26 -0600 Subject: [PATCH 086/616] Corrected type hint on AttachmentPrompt __init__ --- .../botbuilder/dialogs/prompts/attachment_prompt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py index efac79c82..dc2ce8894 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py @@ -6,7 +6,7 @@ from botbuilder.schema import ActivityTypes, Attachment, InputHints from botbuilder.core import TurnContext -from .prompt import Prompt +from .prompt import Prompt, PromptValidatorContext from .prompt_options import PromptOptions from .prompt_recognizer_result import PromptRecognizerResult @@ -18,7 +18,7 @@ class AttachmentPrompt(Prompt): By default the prompt will return to the calling dialog an `[Attachment]` """ - def __init__(self, dialog_id: str, validator: Callable[[Attachment], bool] = None): + def __init__(self, dialog_id: str, validator: Callable[[PromptValidatorContext], bool] = None): super().__init__(dialog_id, validator) async def on_prompt( From a45f7cdbb6bb954cd46b3066412902fd94a7179a Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 10 Dec 2019 09:01:49 -0800 Subject: [PATCH 087/616] Add keysuffix and compat mode to cosmosdbpartitionedstorage (#487) * add keysuffix and compat mode to cosmosdbpartitionedstorage * fix line too long * fix line too long, take 2 --- .../azure/cosmosdb_partitioned_storage.py | 116 ++++++++++++------ .../botbuilder/azure/cosmosdb_storage.py | 20 ++- 2 files changed, 96 insertions(+), 40 deletions(-) diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py index 00c3bb137..e5c1393ac 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py @@ -7,7 +7,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. from typing import Dict, List -from threading import Semaphore +from threading import Lock import json from azure.cosmos import documents, http_constants @@ -29,7 +29,9 @@ def __init__( database_id: str = None, container_id: str = None, cosmos_client_options: dict = None, - container_throughput: int = None, + container_throughput: int = 400, + key_suffix: str = "", + compatibility_mode: bool = False, **kwargs, ): """Create the Config object. @@ -41,6 +43,10 @@ def __init__( :param cosmos_client_options: The options for the CosmosClient. Currently only supports connection_policy and consistency_level :param container_throughput: The throughput set when creating the Container. Defaults to 400. + :param key_suffix: The suffix to be added to every key. The keySuffix must contain only valid ComosDb + key characters. (e.g. not: '\\', '?', '/', '#', '*') + :param compatibility_mode: True if keys should be truncated in order to support previous CosmosDb + max key length of 255. :return CosmosDbPartitionedConfig: """ self.__config_file = kwargs.get("filename") @@ -56,6 +62,8 @@ def __init__( self.container_throughput = container_throughput or kwargs.get( "container_throughput" ) + self.key_suffix = key_suffix or kwargs.get("key_suffix") + self.compatibility_mode = compatibility_mode or kwargs.get("compatibility_mode") class CosmosDbPartitionedStorage(Storage): @@ -71,7 +79,21 @@ def __init__(self, config: CosmosDbPartitionedConfig): self.client = None self.database = None self.container = None - self.__semaphore = Semaphore() + self.compatability_mode_partition_key = False + # Lock used for synchronizing container creation + self.__lock = Lock() + if config.key_suffix is None: + config.key_suffix = "" + if not config.key_suffix.__eq__(""): + if config.compatibility_mode: + raise Exception( + "compatibilityMode cannot be true while using a keySuffix." + ) + suffix_escaped = CosmosDbKeyEscape.sanitize_key(config.key_suffix) + if not suffix_escaped.__eq__(config.key_suffix): + raise Exception( + f"Cannot use invalid Row Key characters: {config.key_suffix} in keySuffix." + ) async def read(self, keys: List[str]) -> Dict[str, object]: """Read storeitems from storage. @@ -88,10 +110,12 @@ async def read(self, keys: List[str]) -> Dict[str, object]: for key in keys: try: - escaped_key = CosmosDbKeyEscape.sanitize_key(key) + escaped_key = CosmosDbKeyEscape.sanitize_key( + key, self.config.key_suffix, self.config.compatibility_mode + ) read_item_response = self.client.ReadItem( - self.__item_link(escaped_key), {"partitionKey": escaped_key} + self.__item_link(escaped_key), self.__get_partition_key(escaped_key) ) document_store_item = read_item_response if document_store_item: @@ -128,7 +152,9 @@ async def write(self, changes: Dict[str, object]): for (key, change) in changes.items(): e_tag = change.get("e_tag", None) doc = { - "id": CosmosDbKeyEscape.sanitize_key(key), + "id": CosmosDbKeyEscape.sanitize_key( + key, self.config.key_suffix, self.config.compatibility_mode + ), "realId": key, "document": self.__create_dict(change), } @@ -161,11 +187,13 @@ async def delete(self, keys: List[str]): await self.initialize() for key in keys: - escaped_key = CosmosDbKeyEscape.sanitize_key(key) + escaped_key = CosmosDbKeyEscape.sanitize_key( + key, self.config.key_suffix, self.config.compatibility_mode + ) try: self.client.DeleteItem( document_link=self.__item_link(escaped_key), - options={"partitionKey": escaped_key}, + options=self.__get_partition_key(escaped_key), ) except cosmos_errors.HTTPFailure as err: if ( @@ -188,41 +216,57 @@ async def initialize(self): ) if not self.database: - with self.__semaphore: + with self.__lock: try: - self.database = self.client.CreateDatabase( - {"id": self.config.database_id} - ) + if not self.database: + self.database = self.client.CreateDatabase( + {"id": self.config.database_id} + ) except cosmos_errors.HTTPFailure: self.database = self.client.ReadDatabase( "dbs/" + self.config.database_id ) - if not self.container: - with self.__semaphore: - container_def = { - "id": self.config.container_id, - "partitionKey": { - "paths": ["/id"], - "kind": documents.PartitionKind.Hash, - }, - } - try: - self.container = self.client.CreateContainer( - "dbs/" + self.database["id"], - container_def, - {"offerThroughput": 400}, - ) - except cosmos_errors.HTTPFailure as err: - if err.status_code == http_constants.StatusCodes.CONFLICT: - self.container = self.client.ReadContainer( - "dbs/" - + self.database["id"] - + "/colls/" - + container_def["id"] + self.__get_or_create_container() + + def __get_or_create_container(self): + with self.__lock: + container_def = { + "id": self.config.container_id, + "partitionKey": { + "paths": ["/id"], + "kind": documents.PartitionKind.Hash, + }, + } + try: + if not self.container: + self.container = self.client.CreateContainer( + "dbs/" + self.database["id"], + container_def, + {"offerThroughput": self.config.container_throughput}, + ) + except cosmos_errors.HTTPFailure as err: + if err.status_code == http_constants.StatusCodes.CONFLICT: + self.container = self.client.ReadContainer( + "dbs/" + self.database["id"] + "/colls/" + container_def["id"] + ) + if "partitionKey" not in self.container: + self.compatability_mode_partition_key = True + else: + paths = self.container["partitionKey"]["paths"] + if "/partitionKey" in paths: + self.compatability_mode_partition_key = True + elif "/id" not in paths: + raise Exception( + f"Custom Partition Key Paths are not supported. {self.config.container_id} " + "has a custom Partition Key Path of {paths[0]}." ) - else: - raise err + + else: + raise err + + def __get_partition_key(self, key: str) -> str: + return None if self.compatability_mode_partition_key else {"partitionKey": key} @staticmethod def __create_si(result) -> object: diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py index 3d588a864..7e405ca88 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py @@ -58,12 +58,18 @@ def __init__( class CosmosDbKeyEscape: @staticmethod - def sanitize_key(key) -> str: + def sanitize_key( + key: str, key_suffix: str = "", compatibility_mode: bool = True + ) -> str: """Return the sanitized key. Replace characters that are not allowed in keys in Cosmos. - :param key: + :param key: The provided key to be escaped. + :param key_suffix: The string to add a the end of all RowKeys. + :param compatibility_mode: True if keys should be truncated in order to support previous CosmosDb + max key length of 255. This behavior can be overridden by setting + cosmosdb_partitioned_config.compatibility_mode to False. :return str: """ # forbidden characters @@ -72,12 +78,18 @@ def sanitize_key(key) -> str: # Unicode code point of the character and return the new string key = "".join(map(lambda x: "*" + str(ord(x)) if x in bad_chars else x, key)) - return CosmosDbKeyEscape.truncate_key(key) + if key_suffix is None: + key_suffix = "" + + return CosmosDbKeyEscape.truncate_key(f"{key}{key_suffix}", compatibility_mode) @staticmethod - def truncate_key(key: str) -> str: + def truncate_key(key: str, compatibility_mode: bool = True) -> str: max_key_len = 255 + if not compatibility_mode: + return key + if len(key) > max_key_len: aux_hash = sha256(key.encode("utf-8")) aux_hex = aux_hash.hexdigest() From 8a9d72d90c0736ebe7864b9684dd4e822f24e6a3 Mon Sep 17 00:00:00 2001 From: Michael Richardson <40401643+mdrichardson@users.noreply.github.com> Date: Tue, 10 Dec 2019 14:37:07 -0800 Subject: [PATCH 088/616] Storage attr fix (#491) * typo blob -> memory * fixed hasattr in MemoryStorage * fixed hasattr in BlobStorage * fixed hasattr in CosmosDbPartitionedStorage * fixed hasattr in CosmosDbStorage --- .../botbuilder/azure/blob_storage.py | 6 +++++- .../azure/cosmosdb_partitioned_storage.py | 6 +++++- .../botbuilder/azure/cosmosdb_storage.py | 10 +++++----- .../botbuilder/core/memory_storage.py | 16 ++++++++-------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py b/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py index fada3fe53..b69217680 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/blob_storage.py @@ -73,7 +73,11 @@ async def write(self, changes: Dict[str, object]): ) for (name, item) in changes.items(): - e_tag = item.e_tag if hasattr(item, "e_tag") else item.get("e_tag", None) + e_tag = None + if isinstance(item, dict): + e_tag = item.get("e_tag", None) + elif hasattr(item, "e_tag"): + e_tag = item.e_tag e_tag = None if e_tag == "*" else e_tag if e_tag == "": raise Exception("blob_storage.write(): etag missing") diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py index e5c1393ac..93657bbed 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py @@ -150,7 +150,11 @@ async def write(self, changes: Dict[str, object]): await self.initialize() for (key, change) in changes.items(): - e_tag = change.get("e_tag", None) + e_tag = None + if isinstance(change, dict): + e_tag = change.get("e_tag", None) + elif hasattr(change, "e_tag"): + e_tag = change.e_tag doc = { "id": CosmosDbKeyEscape.sanitize_key( key, self.config.key_suffix, self.config.compatibility_mode diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py index 7e405ca88..a5d01eea5 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py @@ -183,11 +183,11 @@ async def write(self, changes: Dict[str, object]): # iterate over the changes for (key, change) in changes.items(): # store the e_tag - e_tag = ( - change.e_tag - if hasattr(change, "e_tag") - else change.get("e_tag", None) - ) + e_tag = None + if isinstance(change, dict): + e_tag = change.get("e_tag", None) + elif hasattr(change, "e_tag"): + e_tag = change.e_tag # create the new document doc = { "id": CosmosDbKeyEscape.sanitize_key(key), diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index b85b3d368..d60ecfde5 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -48,21 +48,21 @@ async def write(self, changes: Dict[str, StoreItem]): # If it exists then we want to cache its original value from memory if key in self.memory: old_state = self.memory[key] - if not isinstance(old_state, StoreItem): + if isinstance(old_state, dict): old_state_etag = old_state.get("e_tag", None) - elif old_state.e_tag: + elif hasattr(old_state, "e_tag"): old_state_etag = old_state.e_tag new_state = new_value # Set ETag if applicable - new_value_etag = ( - new_value.e_tag - if hasattr(new_value, "e_tag") - else new_value.get("e_tag", None) - ) + new_value_etag = None + if isinstance(new_value, dict): + new_value_etag = new_value.get("e_tag", None) + elif hasattr(new_value, "e_tag"): + new_value_etag = new_value.e_tag if new_value_etag == "": - raise Exception("blob_storage.write(): etag missing") + raise Exception("memory_storage.write(): etag missing") if ( old_state_etag is not None and new_value_etag is not None From c3d555d62c057eeb2881494ee840e24275ede075 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Tue, 10 Dec 2019 14:43:50 -0800 Subject: [PATCH 089/616] updating conversation params (#490) --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 8424517e7..db16a9a10 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -172,7 +172,9 @@ async def create_conversation( ) # Create conversation - parameters = ConversationParameters(bot=reference.bot) + parameters = ConversationParameters( + bot=reference.bot, members=[reference.user], is_group=False + ) client = await self.create_connector_client(reference.service_url) # Mix in the tenant ID if specified. This is required for MS Teams. From 0fb415f64d226dbc77f78c5c362ca28416c8d0d4 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Tue, 10 Dec 2019 17:17:09 -0800 Subject: [PATCH 090/616] Fixing mention stripping and tests (#488) * updating turn context mention * updating tests based on new mention * black updates --- .../botbuilder-core/botbuilder/core/turn_context.py | 6 ++++-- .../tests/test_inspection_middleware.py | 12 +++++++----- libraries/botbuilder-core/tests/test_turn_context.py | 11 +++++++---- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index a16eed975..5b3299d74 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -359,9 +359,11 @@ def remove_recipient_mention(activity: Activity) -> str: def remove_mention_text(activity: Activity, identifier: str) -> str: mentions = TurnContext.get_mentions(activity) for mention in mentions: - if mention.mentioned.id == identifier: + if mention.additional_properties["mentioned"]["id"] == identifier: mention_name_match = re.match( - r"(.*?)<\/at>", mention.text, re.IGNORECASE + r"(.*?)<\/at>", + mention.additional_properties["text"], + re.IGNORECASE, ) if mention_name_match: activity.text = re.sub( diff --git a/libraries/botbuilder-core/tests/test_inspection_middleware.py b/libraries/botbuilder-core/tests/test_inspection_middleware.py index 30c8ce7bf..68259a1b4 100644 --- a/libraries/botbuilder-core/tests/test_inspection_middleware.py +++ b/libraries/botbuilder-core/tests/test_inspection_middleware.py @@ -14,7 +14,7 @@ ) from botbuilder.core.adapters import TestAdapter from botbuilder.core.inspection import InspectionMiddleware, InspectionState -from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, Mention +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, Entity, Mention class TestConversationState(aiounittest.AsyncTestCase): @@ -249,10 +249,12 @@ async def exec_test2(turn_context): text=attach_command, recipient=ChannelAccount(id=recipient_id), entities=[ - Mention( - type="mention", - text=f"{recipient_id}", - mentioned=ChannelAccount(name="Bot", id=recipient_id), + Entity().deserialize( + Mention( + type="mention", + text=f"{recipient_id}", + mentioned=ChannelAccount(name="Bot", id=recipient_id), + ).serialize() ) ], ) diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 017b5383e..381287d3d 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -9,6 +9,7 @@ ActivityTypes, ChannelAccount, ConversationAccount, + Entity, Mention, ResourceResponse, ) @@ -309,10 +310,12 @@ def test_should_remove_at_mention_from_activity(self): text="TestOAuth619 test activity", recipient=ChannelAccount(id="TestOAuth619"), entities=[ - Mention( - type="mention", - text="TestOAuth619", - mentioned=ChannelAccount(name="Bot", id="TestOAuth619"), + Entity().deserialize( + Mention( + type="mention", + text="TestOAuth619", + mentioned=ChannelAccount(name="Bot", id="TestOAuth619"), + ).serialize() ) ], ) From cce7afee4711f688ee2de05740c7bf528f873f6a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 11 Dec 2019 08:29:38 -0600 Subject: [PATCH 091/616] Corrected black complaints --- .../botbuilder/dialogs/prompts/attachment_prompt.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py index dc2ce8894..ab2cf1736 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/attachment_prompt.py @@ -3,7 +3,7 @@ from typing import Callable, Dict -from botbuilder.schema import ActivityTypes, Attachment, InputHints +from botbuilder.schema import ActivityTypes, InputHints from botbuilder.core import TurnContext from .prompt import Prompt, PromptValidatorContext @@ -18,7 +18,9 @@ class AttachmentPrompt(Prompt): By default the prompt will return to the calling dialog an `[Attachment]` """ - def __init__(self, dialog_id: str, validator: Callable[[PromptValidatorContext], bool] = None): + def __init__( + self, dialog_id: str, validator: Callable[[PromptValidatorContext], bool] = None + ): super().__init__(dialog_id, validator) async def on_prompt( From 92f7058e403dbc7ba5e47704eb8d19461e7a3979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 11 Dec 2019 14:18:41 -0800 Subject: [PATCH 092/616] Axsuarez/skills layer (#492) * Skill layer working, oauth prompt and testing pending * pylint: Skill layer working, oauth prompt and testing pending * Updating minor skills PRs to match C# * Removing accidental changes in samples 1. and 13. * Adding custom exception for channel service handler * Skills error handler * Skills error handler * pylint: Solved conflicts w/master * pylint: Solved conflicts w/master --- .pylintrc | 2 +- .../botbuilder/core/__init__.py | 2 + .../botbuilder/core/activity_handler.py | 11 +- .../botbuilder/core/adapters/test_adapter.py | 14 +- .../botbuilder-core/botbuilder/core/bot.py | 21 ++ .../botbuilder/core/bot_adapter.py | 10 +- .../botbuilder/core/bot_framework_adapter.py | 43 +++- .../botbuilder/core/integration/__init__.py | 5 +- .../integration/aiohttp_channel_service.py | 1 + ...tp_channel_service_exception_middleware.py | 24 +++ .../integration/bot_framework_http_client.py | 5 +- .../integration/channel_service_handler.py | 28 +-- .../botbuilder/core/memory_storage.py | 6 +- .../botbuilder/core/skills/__init__.py | 18 ++ .../core/skills/bot_framework_skill.py | 14 ++ .../core/skills/conversation_id_factory.py | 22 ++ .../skills/skill_conversation_id_factory.py | 54 +++++ .../botbuilder/core/skills/skill_handler.py | 195 ++++++++++++++++++ libraries/botbuilder-core/setup.py | 1 + .../botbuilder-core/tests/test_bot_adapter.py | 2 +- .../connector/auth/emulator_validation.py | 4 +- .../auth/microsoft_app_credentials.py | 2 +- samples/16.proactive-messages/app.py | 2 +- .../simple-child-bot/README.md | 30 +++ .../simple-bot-to-bot/simple-child-bot/app.py | 85 ++++++++ .../simple-child-bot/bots/__init__.py | 6 + .../simple-child-bot/bots/echo_bot.py | 27 +++ .../simple-child-bot/config.py | 15 ++ .../simple-child-bot/requirements.txt | 2 + .../simple-bot-to-bot/simple-root-bot/app.py | 113 ++++++++++ .../simple-root-bot/bots/__init__.py | 4 + .../simple-root-bot/bots/root_bot.py | 108 ++++++++++ .../simple-root-bot/config.py | 32 +++ .../simple-root-bot/middleware/__init__.py | 4 + .../middleware/dummy_middleware.py | 32 +++ 35 files changed, 904 insertions(+), 40 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/bot.py create mode 100644 libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/__init__.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py diff --git a/.pylintrc b/.pylintrc index 4f7803931..a134068ff 100644 --- a/.pylintrc +++ b/.pylintrc @@ -7,7 +7,7 @@ extension-pkg-whitelist= # Add files or directories to the blacklist. They should be base names, not # paths. -ignore=CVS,build,botbuilder-schema,samples,django_tests,Generator,operations,operations_async +ignore=CVS,build,botbuilder-schema,samples,django_tests,Generator,operations,operations_async,schema # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index d6977c927..6ced95ae5 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -9,6 +9,7 @@ from .about import __version__ from .activity_handler import ActivityHandler from .auto_save_state_middleware import AutoSaveStateMiddleware +from .bot import Bot from .bot_assert import BotAssert from .bot_adapter import BotAdapter from .bot_framework_adapter import BotFrameworkAdapter, BotFrameworkAdapterSettings @@ -42,6 +43,7 @@ "ActivityHandler", "AnonymousReceiveMiddleware", "AutoSaveStateMiddleware", + "Bot", "BotAdapter", "BotAssert", "BotFrameworkAdapter", diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index adc51ba5c..fed53bb45 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -32,6 +32,8 @@ async def on_turn(self, turn_context: TurnContext): await self.on_message_reaction_activity(turn_context) elif turn_context.activity.type == ActivityTypes.event: await self.on_event_activity(turn_context) + elif turn_context.activity.type == ActivityTypes.end_of_conversation: + await self.on_end_of_conversation(turn_context) else: await self.on_unrecognized_activity_type(turn_context) @@ -58,12 +60,12 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): return async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext + self, members_added: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument return async def on_members_removed_activity( - self, members_removed: ChannelAccount, turn_context: TurnContext + self, members_removed: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument return @@ -104,6 +106,11 @@ async def on_event( # pylint: disable=unused-argument ): return + async def on_end_of_conversation( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + return + async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 54095b1eb..0ff9f16b6 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -20,6 +20,7 @@ ResourceResponse, TokenResponse, ) +from botframework.connector.auth import ClaimsIdentity from ..bot_adapter import BotAdapter from ..turn_context import TurnContext from ..user_token_provider import UserTokenProvider @@ -157,16 +158,23 @@ async def update_activity(self, context, activity: Activity): self.updated_activities.append(activity) async def continue_conversation( - self, bot_id: str, reference: ConversationReference, callback: Callable + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): """ The `TestAdapter` just calls parent implementation. - :param bot_id :param reference: :param callback: + :param bot_id: + :param claims_identity: :return: """ - await super().continue_conversation(bot_id, reference, callback) + await super().continue_conversation( + reference, callback, bot_id, claims_identity + ) async def receive_activity(self, activity): """ diff --git a/libraries/botbuilder-core/botbuilder/core/bot.py b/libraries/botbuilder-core/botbuilder/core/bot.py new file mode 100644 index 000000000..afbaa3293 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/bot.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +from .turn_context import TurnContext + + +class Bot(ABC): + """ + Represents a bot that can operate on incoming activities. + """ + + @abstractmethod + async def on_turn(self, context: TurnContext): + """ + When implemented in a bot, handles an incoming activity. + :param context: The context object for this turn. + :return: + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index af893d3ed..f97030879 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from typing import List, Callable, Awaitable from botbuilder.schema import Activity, ConversationReference, ResourceResponse +from botframework.connector.auth import ClaimsIdentity from . import conversation_reference_extension from .bot_assert import BotAssert @@ -62,8 +63,12 @@ def use(self, middleware): return self async def continue_conversation( - self, bot_id: str, reference: ConversationReference, callback: Callable - ): # pylint: disable=unused-argument + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ): """ Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. Most _channels require a user to initiate a conversation with a bot before the bot can send activities @@ -73,6 +78,7 @@ async def continue_conversation( which is multi-tenant aware. :param reference: A reference to the conversation to continue. :param callback: The method to call for the resulting bot turn. + :param claims_identity: """ context = TurnContext( self, conversation_reference_extension.get_continuation_activity(reference) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index db16a9a10..9facd0f61 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -37,6 +37,7 @@ from .bot_adapter import BotAdapter from .turn_context import TurnContext from .user_token_provider import UserTokenProvider +from .conversation_reference_extension import get_continuation_activity USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" OAUTH_ENDPOINT = "https://api.botframework.com" @@ -128,8 +129,14 @@ def __init__(self, settings: BotFrameworkAdapterSettings): GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE ) + self._connector_client_cache: Dict[str, ConnectorClient] = {} + async def continue_conversation( - self, bot_id: str, reference: ConversationReference, callback: Callable + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): """ Continues a conversation with a user. This is often referred to as the bots "Proactive Messaging" @@ -139,18 +146,26 @@ async def continue_conversation( :param bot_id: :param reference: :param callback: + :param claims_identity: :return: """ # TODO: proactive messages + if not claims_identity: + if not bot_id: + raise TypeError("Expected bot_id: str but got None instead") + + claims_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: bot_id, + AuthenticationConstants.APP_ID_CLAIM: bot_id, + }, + is_authenticated=True, + ) - if not bot_id: - raise TypeError("Expected bot_id: str but got None instead") - - request = TurnContext.apply_conversation_reference( - Activity(), reference, is_incoming=True - ) - context = self.create_context(request) + context = TurnContext(self, get_continuation_activity(reference)) + context.turn_state[BOT_IDENTITY_KEY] = claims_identity + context.turn_state["BotCallbackHandler"] = callback return await self.run_pipeline(context, callback) async def create_conversation( @@ -660,8 +675,16 @@ async def create_connector_client( else: credentials = self._credentials - client = ConnectorClient(credentials, base_url=service_url) - client.config.add_user_agent(USER_AGENT) + client_key = ( + f"{service_url}{credentials.microsoft_app_id if credentials else ''}" + ) + client = self._connector_client_cache.get(client_key) + + if not client: + client = ConnectorClient(credentials, base_url=service_url) + client.config.add_user_agent(USER_AGENT) + self._connector_client_cache[client_key] = client + return client def create_token_api_client(self, service_url: str) -> TokenApiClient: diff --git a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py index 3a579402b..a971ce6f6 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py @@ -7,10 +7,13 @@ from .aiohttp_channel_service import aiohttp_channel_service_routes from .bot_framework_http_client import BotFrameworkHttpClient -from .channel_service_handler import ChannelServiceHandler +from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler +from .aiohttp_channel_service_exception_middleware import aiohttp_error_middleware __all__ = [ "aiohttp_channel_service_routes", "BotFrameworkHttpClient", + "BotActionNotImplementedError", "ChannelServiceHandler", + "aiohttp_error_middleware", ] diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py index d61c0f0eb..9c7284ad3 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -5,6 +5,7 @@ from aiohttp.web import RouteTableDef, Request, Response from msrest.serialization import Model + from botbuilder.schema import ( Activity, AttachmentData, diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py new file mode 100644 index 000000000..7b2949894 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py @@ -0,0 +1,24 @@ +from aiohttp.web import ( + middleware, + HTTPNotImplemented, + HTTPUnauthorized, + HTTPNotFound, + HTTPInternalServerError, +) + +from .channel_service_handler import BotActionNotImplementedError + + +@middleware +async def aiohttp_error_middleware(request, handler): + try: + response = await handler(request) + return response + except BotActionNotImplementedError: + raise HTTPNotImplemented() + except PermissionError: + raise HTTPUnauthorized() + except KeyError: + raise HTTPNotFound() + except Exception: + raise HTTPInternalServerError() diff --git a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py index 52a13230b..81bd20139 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py @@ -53,7 +53,7 @@ async def post_activity( app_credentials = await self._get_app_credentials(from_bot_id, to_bot_id) if not app_credentials: - raise RuntimeError("Unable to get appCredentials to connect to the skill") + raise KeyError("Unable to get appCredentials to connect to the skill") # Get token for the skill call token = ( @@ -66,10 +66,12 @@ async def post_activity( # TODO: DO we need to set the activity ID? (events that are created manually don't have it). original_conversation_id = activity.conversation.id original_service_url = activity.service_url + original_caller_id = activity.caller_id try: activity.conversation.id = conversation_id activity.service_url = service_url + activity.caller_id = from_bot_id headers_dict = { "Content-type": "application/json; charset=utf-8", @@ -94,6 +96,7 @@ async def post_activity( # Restore activity properties. activity.conversation.id = original_conversation_id activity.service_url = original_service_url + activity.caller_id = original_caller_id async def _get_app_credentials( self, app_id: str, oauth_scope: str diff --git a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py index 4b9222de7..9d9fce6df 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py @@ -24,6 +24,10 @@ ) +class BotActionNotImplementedError(Exception): + """Raised when an action is not implemented""" + + class ChannelServiceHandler: """ Initializes a new instance of the class, @@ -159,7 +163,7 @@ async def on_get_conversations( :param continuation_token: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_create_conversation( self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, @@ -193,7 +197,7 @@ async def on_create_conversation( :param parameters: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_send_to_conversation( self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, @@ -220,7 +224,7 @@ async def on_send_to_conversation( :param activity: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_send_conversation_history( self, @@ -244,7 +248,7 @@ async def on_send_conversation_history( :param transcript: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_update_activity( self, @@ -270,7 +274,7 @@ async def on_update_activity( :param activity: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_reply_to_activity( self, @@ -302,7 +306,7 @@ async def on_reply_to_activity( :param activity: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_delete_activity( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, @@ -320,7 +324,7 @@ async def on_delete_activity( :param activity_id: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_get_conversation_members( self, claims_identity: ClaimsIdentity, conversation_id: str, @@ -337,7 +341,7 @@ async def on_get_conversation_members( :param conversation_id: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_get_conversation_paged_members( self, @@ -373,7 +377,7 @@ async def on_get_conversation_paged_members( :param continuation_token: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_delete_conversation_member( self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, @@ -393,7 +397,7 @@ async def on_delete_conversation_member( :param member_id: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_get_activity_members( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, @@ -412,7 +416,7 @@ async def on_get_activity_members( :param activity_id: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def on_upload_attachment( self, @@ -436,7 +440,7 @@ async def on_upload_attachment( :param attachment_upload: :return: """ - raise NotImplementedError() + raise BotActionNotImplementedError() async def _authenticate(self, auth_header: str) -> ClaimsIdentity: if not auth_header: diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index d60ecfde5..482527853 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -73,10 +73,10 @@ async def write(self, changes: Dict[str, StoreItem]): "Etag conflict.\nOriginal: %s\r\nCurrent: %s" % (new_value_etag, old_state_etag) ) - if hasattr(new_state, "e_tag"): - new_state.e_tag = str(self._e_tag) - else: + if isinstance(new_state, dict): new_state["e_tag"] = str(self._e_tag) + else: + new_state.e_tag = str(self._e_tag) self._e_tag += 1 self.memory[key] = deepcopy(new_state) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py new file mode 100644 index 000000000..6bd5a66b8 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py @@ -0,0 +1,18 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .bot_framework_skill import BotFrameworkSkill +from .conversation_id_factory import ConversationIdFactoryBase +from .skill_conversation_id_factory import SkillConversationIdFactory +from .skill_handler import SkillHandler + +__all__ = [ + "BotFrameworkSkill", + "ConversationIdFactoryBase", + "SkillConversationIdFactory", + "SkillHandler", +] diff --git a/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py b/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py new file mode 100644 index 000000000..8819d6674 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class BotFrameworkSkill: + """ + Registration for a BotFrameworkHttpProtocol based Skill endpoint. + """ + + # pylint: disable=invalid-name + def __init__(self, id: str = None, app_id: str = None, skill_endpoint: str = None): + self.id = id + self.app_id = app_id + self.skill_endpoint = skill_endpoint diff --git a/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py new file mode 100644 index 000000000..7c015de08 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from abc import ABC, abstractmethod +from botbuilder.schema import ConversationReference + + +class ConversationIdFactoryBase(ABC): + @abstractmethod + async def create_skill_conversation_id( + self, conversation_reference: ConversationReference + ) -> str: + raise NotImplementedError() + + @abstractmethod + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + raise NotImplementedError() + + @abstractmethod + async def delete_conversation_reference(self, skill_conversation_id: str): + raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py new file mode 100644 index 000000000..6b01865fc --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import hashlib +from typing import Dict, Tuple + +from botbuilder.core import Storage +from botbuilder.schema import ConversationReference + +from .conversation_id_factory import ConversationIdFactoryBase + + +class SkillConversationIdFactory(ConversationIdFactoryBase): + def __init__(self, storage: Storage): + if not storage: + raise TypeError("storage can't be None") + + self._storage = storage + self._forward_x_ref: Dict[str, str] = {} + self._backward_x_ref: Dict[str, Tuple[str, str]] = {} + + async def create_skill_conversation_id( + self, conversation_reference: ConversationReference + ) -> str: + if not conversation_reference: + raise TypeError("conversation_reference can't be None") + + if not conversation_reference.conversation.id: + raise TypeError("conversation id in conversation reference can't be None") + + if not conversation_reference.channel_id: + raise TypeError("channel id in conversation reference can't be None") + + storage_key = hashlib.md5( + f"{conversation_reference.conversation.id}{conversation_reference.channel_id}".encode() + ).hexdigest() + + skill_conversation_info = {storage_key: conversation_reference} + + await self._storage.write(skill_conversation_info) + + return storage_key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + if not skill_conversation_id: + raise TypeError("skill_conversation_id can't be None") + + skill_conversation_info = await self._storage.read([skill_conversation_id]) + + return skill_conversation_info.get(skill_conversation_id) + + async def delete_conversation_reference(self, skill_conversation_id: str): + await self._storage.delete([skill_conversation_id]) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py new file mode 100644 index 000000000..05ec99bb0 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -0,0 +1,195 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 + +from botbuilder.core.integration import ChannelServiceHandler +from botbuilder.core import Bot, BotAdapter, TurnContext +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + ResourceResponse, +) +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, +) + +from .skill_conversation_id_factory import SkillConversationIdFactory + + +class SkillHandler(ChannelServiceHandler): + + SKILL_CONVERSATION_REFERENCE_KEY = ( + "botbuilder.core.skills.SkillConversationReference" + ) + + def __init__( + self, + adapter: BotAdapter, + bot: Bot, + conversation_id_factory: SkillConversationIdFactory, + credential_provider: CredentialProvider, + auth_configuration: AuthenticationConfiguration, + channel_provider: ChannelProvider = None, + logger: object = None, + ): + super().__init__(credential_provider, auth_configuration, channel_provider) + + if not adapter: + raise TypeError("adapter can't be None") + if not bot: + raise TypeError("bot can't be None") + if not conversation_id_factory: + raise TypeError("conversation_id_factory can't be None") + + self._adapter = adapter + self._bot = bot + self._conversation_id_factory = conversation_id_factory + self._logger = logger + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + """ + send_to_conversation() API for Skill + + This method allows you to send an activity to the end of a conversation. + + This is slightly different from ReplyToActivity(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + :param claims_identity: + :param conversation_id: + :param activity: + :return: + """ + return await self._process_activity( + claims_identity, conversation_id, None, activity, + ) + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + """ + reply_to_activity() API for Skill. + + This method allows you to reply to an activity. + + This is slightly different from SendToConversation(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + return await self._process_activity( + claims_identity, conversation_id, activity_id, activity, + ) + + async def _process_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + reply_to_activity_id: str, + activity: Activity, + ) -> ResourceResponse: + conversation_reference = await self._conversation_id_factory.get_conversation_reference( + conversation_id + ) + + if not conversation_reference: + raise KeyError("ConversationReference not found") + + skill_conversation_reference = ConversationReference( + activity_id=activity.id, + user=activity.from_property, + bot=activity.recipient, + conversation=activity.conversation, + channel_id=activity.channel_id, + service_url=activity.service_url, + ) + + async def callback(context: TurnContext): + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference + TurnContext.apply_conversation_reference(activity, conversation_reference) + context.activity.id = reply_to_activity_id + + if activity.type == ActivityTypes.end_of_conversation: + await self._conversation_id_factory.delete_conversation_reference( + conversation_id + ) + self._apply_eoc_to_turn_context_activity(context, activity) + await self._bot.on_turn(context) + elif activity.type == ActivityTypes.event: + self._apply_event_to_turn_context_activity(context, activity) + await self._bot.on_turn(context) + else: + await context.send_activity(activity) + + await self._adapter.continue_conversation( + conversation_reference, callback, claims_identity=claims_identity + ) + return ResourceResponse(id=str(uuid4())) + + @staticmethod + def _apply_eoc_to_turn_context_activity( + context: TurnContext, end_of_conversation_activity: Activity + ): + context.activity.type = end_of_conversation_activity.type + context.activity.text = end_of_conversation_activity.text + context.activity.code = end_of_conversation_activity.code + + context.activity.reply_to_id = end_of_conversation_activity.reply_to_id + context.activity.value = end_of_conversation_activity.value + context.activity.entities = end_of_conversation_activity.entities + context.activity.local_timestamp = end_of_conversation_activity.local_timestamp + context.activity.timestamp = end_of_conversation_activity.timestamp + context.activity.channel_data = end_of_conversation_activity.channel_data + context.activity.additional_properties = ( + end_of_conversation_activity.additional_properties + ) + + @staticmethod + def _apply_event_to_turn_context_activity( + context: TurnContext, event_activity: Activity + ): + context.activity.type = event_activity.type + context.activity.name = event_activity.name + context.activity.value = event_activity.value + context.activity.relates_to = event_activity.relates_to + + context.activity.reply_to_id = event_activity.reply_to_id + context.activity.value = event_activity.value + context.activity.entities = event_activity.entities + context.activity.local_timestamp = event_activity.local_timestamp + context.activity.timestamp = event_activity.timestamp + context.activity.channel_data = event_activity.channel_data + context.activity.additional_properties = event_activity.additional_properties diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index 5b667ab06..3ecc37f53 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -36,6 +36,7 @@ "botbuilder.core.adapters", "botbuilder.core.inspection", "botbuilder.core.integration", + "botbuilder.core.skills", ], install_requires=REQUIRES, classifiers=[ diff --git a/libraries/botbuilder-core/tests/test_bot_adapter.py b/libraries/botbuilder-core/tests/test_bot_adapter.py index 30d865956..9edd36c50 100644 --- a/libraries/botbuilder-core/tests/test_bot_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_adapter.py @@ -67,7 +67,7 @@ async def continue_callback(turn_context): # pylint: disable=unused-argument nonlocal callback_invoked callback_invoked = True - await adapter.continue_conversation("MyBot", reference, continue_callback) + await adapter.continue_conversation(reference, continue_callback, "MyBot") self.assertTrue(callback_invoked) async def test_turn_error(self): diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 12738f388..1178db7bc 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -121,8 +121,8 @@ async def authenticate_emulator_token( AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, ) - identity = await asyncio.ensure_future( - token_extractor.get_identity_from_auth_header(auth_header, channel_id) + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id ) if not identity: # No valid identity. Not Authorized. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 180fda6dd..291414507 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -88,7 +88,7 @@ def __init__( self.oauth_scope = ( oauth_scope or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER ) - self.token_cache_key = app_id + "-cache" if app_id else None + self.token_cache_key = app_id + self.oauth_scope + "-cache" if app_id else None self.authentication_context = AuthenticationContext(self.oauth_endpoint) # pylint: disable=arguments-differ diff --git a/samples/16.proactive-messages/app.py b/samples/16.proactive-messages/app.py index b00709eff..62ddb40c9 100644 --- a/samples/16.proactive-messages/app.py +++ b/samples/16.proactive-messages/app.py @@ -110,9 +110,9 @@ def notify(): async def _send_proactive_message(): for conversation_reference in CONVERSATION_REFERENCES.values(): return await ADAPTER.continue_conversation( - APP_ID, conversation_reference, lambda turn_context: turn_context.send_activity("proactive hello"), + APP_ID, ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py new file mode 100644 index 000000000..cfa375aac --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import EchoBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = EchoBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py new file mode 100644 index 000000000..f95fbbbad --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py new file mode 100644 index 000000000..91c3febb0 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes + + +class EchoBot(ActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + if "end" in turn_context.activity.text or "exit" in turn_context.activity.text: + # Send End of conversation at the end. + await turn_context.send_activity( + MessageFactory.text("Ending conversation from the skill...") + ) + + end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) + end_of_conversation.code = EndOfConversationCodes.completed_successfully + await turn_context.send_activity(end_of_conversation) + else: + await turn_context.send_activity( + MessageFactory.text(f"Echo: {turn_context.activity.text}") + ) + await turn_context.send_activity( + MessageFactory.text( + f'Say "end" or "exit" and I\'ll end the conversation and back to the parent.' + ) + ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py @@ -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", "") diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py new file mode 100644 index 000000000..baba86ac1 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.core.integration import ( + BotFrameworkHttpClient, + aiohttp_channel_service_routes, + aiohttp_error_middleware, +) +from botbuilder.core.skills import SkillConversationIdFactory, SkillHandler +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ( + AuthenticationConfiguration, + SimpleCredentialProvider, +) + +from bots import RootBot +from config import DefaultConfig, SkillConfiguration + +CONFIG = DefaultConfig() +SKILL_CONFIG = SkillConfiguration() + +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = BotFrameworkHttpClient(CREDENTIAL_PROVIDER) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +STORAGE = MemoryStorage() + +CONVERSATION_STATE = ConversationState(STORAGE) +ID_FACTORY = SkillConversationIdFactory(STORAGE) + + +# 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) + traceback.print_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 = RootBot(CONVERSATION_STATE, SKILL_CONFIG, ID_FACTORY, CLIENT, CONFIG) + +SKILL_HANDLER = SkillHandler( + ADAPTER, BOT, ID_FACTORY, CREDENTIAL_PROVIDER, AuthenticationConfiguration() +) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) +APP.router.add_routes(aiohttp_channel_service_routes(SKILL_HANDLER, "/api/skills")) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py new file mode 100644 index 000000000..be7e157a7 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py @@ -0,0 +1,4 @@ +from .root_bot import RootBot + + +__all__ = ["RootBot"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py new file mode 100644 index 000000000..6ce16672c --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py @@ -0,0 +1,108 @@ +from typing import List + +from botbuilder.core import ( + ActivityHandler, + ConversationState, + MessageFactory, + TurnContext, +) +from botbuilder.core.integration import BotFrameworkHttpClient +from botbuilder.core.skills import SkillConversationIdFactory + +from botbuilder.schema import ActivityTypes, ChannelAccount + +from config import DefaultConfig, SkillConfiguration + + +class RootBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + skills_config: SkillConfiguration, + conversation_id_factory: SkillConversationIdFactory, + skill_client: BotFrameworkHttpClient, + config: DefaultConfig, + ): + self._conversation_id_factory = conversation_id_factory + self._bot_id = config.APP_ID + self._skill_client = skill_client + self._skills_config = skills_config + self._conversation_state = conversation_state + self._active_skill_property = conversation_state.create_property( + "activeSkillProperty" + ) + + async def on_turn(self, turn_context: TurnContext): + if turn_context.activity.type == ActivityTypes.end_of_conversation: + # Handle end of conversation back from the skill + # forget skill invocation + await self._active_skill_property.delete(turn_context) + await self._conversation_state.save_changes(turn_context, force=True) + + # We are back + await turn_context.send_activity( + MessageFactory.text( + 'Back in the root bot. Say "skill" and I\'ll patch you through' + ) + ) + else: + await super().on_turn(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + # If there is an active skill + active_skill_id: str = await self._active_skill_property.get(turn_context) + skill_conversation_id = await self._conversation_id_factory.create_skill_conversation_id( + TurnContext.get_conversation_reference(turn_context.activity) + ) + + if active_skill_id: + # NOTE: Always SaveChanges() before calling a skill so that any activity generated by the skill + # will have access to current accurate state. + await self._conversation_state.save_changes(turn_context, force=True) + + # route activity to the skill + await self._skill_client.post_activity( + self._bot_id, + self._skills_config.SKILLS[active_skill_id].app_id, + self._skills_config.SKILLS[active_skill_id].skill_endpoint, + self._skills_config.SKILL_HOST_ENDPOINT, + skill_conversation_id, + turn_context.activity, + ) + else: + if "skill" in turn_context.activity.text: + await turn_context.send_activity( + MessageFactory.text("Got it, connecting you to the skill...") + ) + + # save ConversationReferene for skill + await self._active_skill_property.set(turn_context, "SkillBot") + + # NOTE: Always SaveChanges() before calling a skill so that any activity generated by the + # skill will have access to current accurate state. + await self._conversation_state.save_changes(turn_context, force=True) + + await self._skill_client.post_activity( + self._bot_id, + self._skills_config.SKILLS["SkillBot"].app_id, + self._skills_config.SKILLS["SkillBot"].skill_endpoint, + self._skills_config.SKILL_HOST_ENDPOINT, + skill_conversation_id, + turn_context.activity, + ) + else: + # just respond + await turn_context.send_activity( + MessageFactory.text( + "Me no nothin'. Say \"skill\" and I'll patch you through" + ) + ) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + MessageFactory.text("Hello and welcome!") + ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py new file mode 100644 index 000000000..f2a9e1f6e --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from typing import Dict +from botbuilder.core.skills import BotFrameworkSkill + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3428 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + SKILL_HOST_ENDPOINT = "http://localhost:3428/api/skills" + SKILLS = [ + { + "id": "SkillBot", + "app_id": "", + "skill_endpoint": "http://localhost:3978/api/messages", + }, + ] + + +class SkillConfiguration: + SKILL_HOST_ENDPOINT = DefaultConfig.SKILL_HOST_ENDPOINT + SKILLS: Dict[str, BotFrameworkSkill] = { + skill["id"]: BotFrameworkSkill(**skill) for skill in DefaultConfig.SKILLS + } diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py new file mode 100644 index 000000000..c23b52ce2 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py @@ -0,0 +1,4 @@ +from .dummy_middleware import DummyMiddleware + + +__all__ = ["DummyMiddleware"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py new file mode 100644 index 000000000..4d38fe79f --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py @@ -0,0 +1,32 @@ +from typing import Awaitable, Callable, List + +from botbuilder.core import Middleware, TurnContext +from botbuilder.schema import Activity, ResourceResponse + + +class DummyMiddleware(Middleware): + def __init__(self, label: str): + self._label = label + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + message = f"{self._label} {context.activity.type} {context.activity.text}" + print(message) + + # Register outgoing handler + context.on_send_activities(self._outgoing_handler) + + await logic() + + async def _outgoing_handler( + self, + context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + logic: Callable[[TurnContext], Awaitable[List[ResourceResponse]]], + ): + for activity in activities: + message = f"{self._label} {activity.type} {activity.text}" + print(message) + + return await logic() From 578bee77a28fe4297621a25491318d84b7d59ed5 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Wed, 11 Dec 2019 15:04:05 -0800 Subject: [PATCH 093/616] updating members added to cast to teams channel account (#495) --- .../core/teams/teams_activity_handler.py | 56 +++++++++---------- .../teams/test_teams_activity_handler.py | 37 +----------- 2 files changed, 29 insertions(+), 64 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index f2f12f141..30035ae34 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -3,9 +3,10 @@ from http import HTTPStatus from botbuilder.schema import Activity, ActivityTypes, ChannelAccount +from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.core.turn_context import TurnContext +from botbuilder.core.teams.teams_info import TeamsInfo from botbuilder.core.teams.teams_helper import deserializer_helper -from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.schema.teams import ( AppBasedLinkQuery, TeamInfo, @@ -357,47 +358,43 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar team_info: TeamInfo, turn_context: TurnContext, ): - """ + team_members = {} team_members_added = [] for member in members_added: if member.additional_properties != {}: - team_members_added.append(TeamsChannelAccount(member)) + team_members_added.append( + deserializer_helper(TeamsChannelAccount, member) + ) else: if team_members == {}: - result = await TeamsInfo.get_members_async(turn_context) - team_members = { i.id : i for i in result } + result = await TeamsInfo.get_members(turn_context) + team_members = {i.id: i for i in result} if member.id in team_members: team_members_added.append(member) else: - newTeamsChannelAccount = TeamsChannelAccount( + new_teams_channel_account = TeamsChannelAccount( id=member.id, - name = member.name, - aad_object_id = member.aad_object_id, - role = member.role - ) - team_members_added.append(newTeamsChannelAccount) - - return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context) - """ - team_accounts_added = [] - for member in members_added: - # TODO: fix this - new_account_json = member.serialize() - if "additional_properties" in new_account_json: - del new_account_json["additional_properties"] - member = TeamsChannelAccount(**new_account_json) - team_accounts_added.append(member) + name=member.name, + aad_object_id=member.aad_object_id, + role=member.role, + ) + team_members_added.append(new_teams_channel_account) + return await self.on_teams_members_added_activity( - team_accounts_added, turn_context + team_members_added, team_info, turn_context ) - async def on_teams_members_added_activity( - self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext + async def on_teams_members_added_activity( # pylint: disable=unused-argument + self, + teams_members_added: [TeamsChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, ): teams_members_added = [ - ChannelAccount(**member.serialize()) for member in teams_members_added + ChannelAccount().deserialize(member.serialize()) + for member in teams_members_added ] return await super().on_members_added_activity( teams_members_added, turn_context @@ -415,7 +412,9 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- new_account_json = member.serialize() if "additional_properties" in new_account_json: del new_account_json["additional_properties"] - teams_members_removed.append(TeamsChannelAccount(**new_account_json)) + teams_members_removed.append( + TeamsChannelAccount().deserialize(new_account_json) + ) return await self.on_teams_members_removed_activity( teams_members_removed, turn_context @@ -425,7 +424,8 @@ async def on_teams_members_removed_activity( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): members_removed = [ - ChannelAccount(**member.serialize()) for member in teams_members_removed + ChannelAccount().deserialize(member.serialize()) + for member in teams_members_removed ] return await super().on_members_removed_activity(members_removed, turn_context) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 154e82345..f65a861d5 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -35,14 +35,6 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): self.record.append("on_conversation_update_activity") return await super().on_conversation_update_activity(turn_context) - async def on_teams_members_added_activity( - self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext - ): - self.record.append("on_teams_members_added_activity") - return await super().on_teams_members_added_activity( - teams_members_added, turn_context - ) - async def on_teams_members_removed_activity( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): @@ -342,33 +334,6 @@ async def test_on_teams_team_renamed_activity(self): assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_team_renamed_activity" - async def test_on_teams_members_added_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.conversation_update, - channel_data={"eventType": "teamMemberAdded"}, - members_added=[ - ChannelAccount( - id="123", - name="test_user", - aad_object_id="asdfqwerty", - role="tester", - ) - ], - channel_id=Channels.ms_teams, - ) - - turn_context = TurnContext(NotImplementedAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_members_added_activity" - async def test_on_teams_members_removed_activity(self): # arrange activity = Activity( @@ -385,7 +350,7 @@ async def test_on_teams_members_removed_activity(self): channel_id=Channels.ms_teams, ) - turn_context = TurnContext(NotImplementedAdapter(), activity) + turn_context = TurnContext(SimpleAdapter(), activity) # Act bot = TestingTeamsActivityHandler() From 1ce4c0cb134b962f3dd403e9e8b650b38f77545e Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Wed, 11 Dec 2019 16:47:44 -0800 Subject: [PATCH 094/616] TeamChannelAccount serialization (#497) * updating teams seralization * black updates --- .../botbuilder-core/botbuilder/core/teams/teams_info.py | 7 +++++-- .../botbuilder/schema/teams/_models_py3.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index b70fe256f..ca1e71a43 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -110,7 +110,10 @@ async def _get_members( ) for member in members: - new_account_json = member.serialize() - teams_members.append(TeamsChannelAccount(**new_account_json)) + teams_members.append( + TeamsChannelAccount().deserialize( + dict(member.serialize(), **member.additional_properties) + ) + ) return teams_members diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 62d1e4a6f..529ab6851 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1798,6 +1798,7 @@ class TeamsChannelAccount(ChannelAccount): "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "aad_object_id": {"key": "objectId", "type": "str"}, } def __init__( @@ -1815,7 +1816,6 @@ def __init__( self.given_name = given_name self.surname = surname self.email = email - # changing to camel case due to how data comes in off the wire self.user_principal_name = user_principal_name From 8562148fdc010e99c4af7b97cb904c8685cb5d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 12 Dec 2019 10:49:35 -0800 Subject: [PATCH 095/616] Axsuarez/oauth prompt skills (#498) * Skill layer working, oauth prompt and testing pending * pylint: Skill layer working, oauth prompt and testing pending * Updating minor skills PRs to match C# * Removing accidental changes in samples 1. and 13. * Adding custom exception for channel service handler * Skills error handler * Skills error handler * pylint: Solved conflicts w/master * pylint: Solved conflicts w/master * OAuthPrompt working as expected in skill child --- .../botbuilder/core/bot_framework_adapter.py | 1 + .../dialogs/prompts/oauth_prompt.py | 47 +++++++-- .../authentication-bot/README.md | 30 ++++++ .../authentication-bot/app.py | 98 +++++++++++++++++++ .../authentication-bot/bots/__init__.py | 6 ++ .../authentication-bot/bots/auth_bot.py | 42 ++++++++ .../authentication-bot/bots/dialog_bot.py | 29 ++++++ .../authentication-bot/config.py | 16 +++ .../authentication-bot/dialogs/__init__.py | 7 ++ .../dialogs/logout_dialog.py | 47 +++++++++ .../authentication-bot/dialogs/main_dialog.py | 72 ++++++++++++++ .../authentication-bot/helpers/__init__.py | 6 ++ .../helpers/dialog_helper.py | 19 ++++ .../authentication-bot/requirements.txt | 2 + 14 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/README.md create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py create mode 100644 samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 9facd0f61..ceb8a36e5 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -247,6 +247,7 @@ async def process_activity(self, req, auth_header: str, logic: Callable): Channels.ms_teams == context.activity.channel_id and context.activity.conversation is not None and not context.activity.conversation.tenant_id + and context.activity.channel_data ): teams_channel_data = context.activity.channel_data if teams_channel_data.get("tenant", {}).get("id", None): diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index da709136b..843fe9e3a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -24,6 +24,7 @@ TokenResponse, ) from botframework.connector import Channels +from botframework.connector.auth import ClaimsIdentity, SkillValidation from .prompt_options import PromptOptions from .oauth_prompt_settings import OAuthPromptSettings from .prompt_validator_context import PromptValidatorContext @@ -115,7 +116,7 @@ async def begin_dialog( if output is not None: return await dialog_context.end_dialog(output) - await self.send_oauth_card(dialog_context.context, options.prompt) + await self._send_oauth_card(dialog_context.context, options.prompt) return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: @@ -132,6 +133,8 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu if state["state"].get("attemptCount") is None: state["state"]["attemptCount"] = 1 + else: + state["state"]["attemptCount"] += 1 # Validate the return value is_valid = False @@ -142,7 +145,6 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu recognized, state["state"], state["options"], - state["state"]["attemptCount"], ) ) elif recognized.succeeded: @@ -188,7 +190,7 @@ async def sign_out_user(self, context: TurnContext): return await adapter.sign_out_user(context, self._settings.connection_name) - async def send_oauth_card( + async def _send_oauth_card( self, context: TurnContext, prompt: Union[Activity, str] = None ): if not isinstance(prompt, Activity): @@ -198,11 +200,32 @@ async def send_oauth_card( prompt.attachments = prompt.attachments or [] - if self._channel_suppports_oauth_card(context.activity.channel_id): + if OAuthPrompt._channel_suppports_oauth_card(context.activity.channel_id): if not any( att.content_type == CardFactory.content_types.oauth_card for att in prompt.attachments ): + link = None + card_action_type = ActionTypes.signin + bot_identity: ClaimsIdentity = context.turn_state.get("BotIdentity") + + # check if it's from streaming connection + if not context.activity.service_url.startswith("http"): + if not hasattr(context.adapter, "get_oauth_sign_in_link"): + raise Exception( + "OAuthPrompt: get_oauth_sign_in_link() not supported by the current adapter" + ) + link = await context.adapter.get_oauth_sign_in_link( + context, self._settings.connection_name + ) + elif bot_identity and SkillValidation.is_skill_claim( + bot_identity.claims + ): + link = await context.adapter.get_oauth_sign_in_link( + context, self._settings.connection_name + ) + card_action_type = ActionTypes.open_url + prompt.attachments.append( CardFactory.oauth_card( OAuthCard( @@ -212,7 +235,8 @@ async def send_oauth_card( CardAction( title=self._settings.title, text=self._settings.text, - type=ActionTypes.signin, + type=card_action_type, + value=link, ) ], ) @@ -251,9 +275,9 @@ async def send_oauth_card( async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult: token = None - if self._is_token_response_event(context): + if OAuthPrompt._is_token_response_event(context): token = context.activity.value - elif self._is_teams_verification_invoke(context): + elif OAuthPrompt._is_teams_verification_invoke(context): code = context.activity.value.state try: token = await self.get_user_token(context, code) @@ -280,14 +304,16 @@ async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult else PromptRecognizerResult() ) - def _is_token_response_event(self, context: TurnContext) -> bool: + @staticmethod + def _is_token_response_event(context: TurnContext) -> bool: activity = context.activity return ( activity.type == ActivityTypes.event and activity.name == "tokens/response" ) - def _is_teams_verification_invoke(self, context: TurnContext) -> bool: + @staticmethod + def _is_teams_verification_invoke(context: TurnContext) -> bool: activity = context.activity return ( @@ -295,7 +321,8 @@ def _is_teams_verification_invoke(self, context: TurnContext) -> bool: and activity.name == "signin/verifyState" ) - def _channel_suppports_oauth_card(self, channel_id: str) -> bool: + @staticmethod + def _channel_suppports_oauth_card(channel_id: str) -> bool: if channel_id in [ Channels.ms_teams, Channels.cortana, diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/README.md b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py new file mode 100644 index 000000000..95fd89577 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import AuthBot +from dialogs import MainDialog +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +STORAGE = MemoryStorage() + +CONVERSATION_STATE = ConversationState(STORAGE) +USER_STATE = UserState(STORAGE) + + +# 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) + traceback.print_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 + +DIALOG = MainDialog(CONFIG) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Create the Bot + bot = AuthBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, bot.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py new file mode 100644 index 000000000..9fae5bf38 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from .dialog_bot import DialogBot +from .auth_bot import AuthBot + +__all__ = ["DialogBot", "AuthBot"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py new file mode 100644 index 000000000..ec0325fda --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import ActivityTypes, ChannelAccount + +from helpers.dialog_helper import DialogHelper +from bots import DialogBot + + +class AuthBot(DialogBot): + async def on_turn(self, turn_context: TurnContext): + if turn_context.activity.type == ActivityTypes.invoke: + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState") + ) + else: + await super().on_turn(turn_context) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + MessageFactory.text("Hello and welcome!") + ) + + async def on_token_response_event( + self, turn_context: TurnContext + ): + print("on token: Running dialog with Message Activity.") + + return await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState") + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py new file mode 100644 index 000000000..12576303e --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog + +from helpers.dialog_helper import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + self.conversation_state = conversation_state + self._user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + await self.conversation_state.save_changes(turn_context, False) + await self._user_state.save_changes(turn_context, False) + + async def on_message_activity(self, turn_context: TurnContext): + print("on message: Running dialog with Message Activity.") + + return await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState") + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py new file mode 100644 index 000000000..97a5625bf --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py @@ -0,0 +1,16 @@ +#!/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", "") + CONNECTION_NAME = "" diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py new file mode 100644 index 000000000..f8117421c --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .logout_dialog import LogoutDialog +from .main_dialog import MainDialog + +__all__ = [ + "LogoutDialog", + "MainDialog" +] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py new file mode 100644 index 000000000..2e4a6c653 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogTurnResult, +) +from botbuilder.dialogs import DialogContext +from botbuilder.core import BotFrameworkAdapter, MessageFactory +from botbuilder.schema import ActivityTypes + + +class LogoutDialog(ComponentDialog): + def __init__( + self, dialog_id: str, connection_name: str, + ): + super().__init__(dialog_id) + + self.connection_name = connection_name + + async def on_begin_dialog( + self, inner_dc: DialogContext, options: object + ) -> DialogTurnResult: + result = await self._interrupt(inner_dc) + if result: + return result + + return await super().on_begin_dialog(inner_dc, options) + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + result = await self._interrupt(inner_dc) + if result: + return result + + return await super().on_continue_dialog(inner_dc) + + async def _interrupt(self, inner_dc: DialogContext): + if inner_dc.context.activity.type == ActivityTypes.message: + text = inner_dc.context.activity.text.lower() + + if text == "logout": + bot_adapter: BotFrameworkAdapter = inner_dc.context.adapter + await bot_adapter.sign_out_user(inner_dc.context, self.connection_name) + await inner_dc.context.send_activity(MessageFactory.text("You have been signed out.")) + return await inner_dc.cancel_all_dialogs() + + return None diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py new file mode 100644 index 000000000..e851bbe38 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.dialogs.prompts import ConfirmPrompt, PromptOptions, OAuthPrompt, OAuthPromptSettings +from botbuilder.core import MessageFactory +from dialogs import LogoutDialog + + +class MainDialog(LogoutDialog): + def __init__( + self, configuration, + ): + super().__init__(MainDialog.__name__, configuration.CONNECTION_NAME) + + self.add_dialog( + OAuthPrompt( + OAuthPrompt.__name__, + OAuthPromptSettings( + connection_name=self.connection_name, + text="Please Sign In", + title="Sign In", + timeout=30000, + ) + ) + ) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + self.add_dialog( + WaterfallDialog( + "WFDialog", + [self.prompt_step, self.login_step, self.display_token_phase_one, self.display_token_phase_two] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + return await step_context.begin_dialog( + OAuthPrompt.__name__ + ) + + async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + token_response = step_context.result + if token_response: + await step_context.context.send_activity(MessageFactory.text("You are now logged in.")) + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions(prompt=MessageFactory.text("Would you like to view your token?")) + ) + + await step_context.context.send_activity(MessageFactory.text("Login was not successful please try again.")) + return await step_context.end_dialog() + + async def display_token_phase_one(self, step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity(MessageFactory.text("Thank you")) + + result = step_context.result + if result: + return await step_context.begin_dialog(OAuthPrompt.__name__) + + return await step_context.end_dialog() + + async def display_token_phase_two(self, step_context: WaterfallStepContext) -> DialogTurnResult: + token_response = step_context.result + if token_response: + await step_context.context.send_activity(MessageFactory.text(f"Here is your token {token_response.token}")) + + return await step_context.end_dialog() diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py new file mode 100644 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 From 4a2ecef0d19d79a2df80ff74f14d00b56b889175 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Thu, 12 Dec 2019 11:14:27 -0800 Subject: [PATCH 096/616] updating create conversation to take conversation params (#496) * updating create conversation to take conversation params * updating attributes on activity --- .../botbuilder/core/bot_framework_adapter.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index ceb8a36e5..582704828 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -9,6 +9,7 @@ from msrest.serialization import Model from botbuilder.schema import ( Activity, + ActivityTypes, ConversationAccount, ConversationParameters, ConversationReference, @@ -172,6 +173,7 @@ async def create_conversation( self, reference: ConversationReference, logic: Callable[[TurnContext], Awaitable] = None, + conversation_parameters: ConversationParameters = None, ): """ Starts a new conversation with a user. This is typically used to Direct Message (DM) a member @@ -187,8 +189,12 @@ async def create_conversation( ) # Create conversation - parameters = ConversationParameters( - bot=reference.bot, members=[reference.user], is_group=False + parameters = ( + conversation_parameters + if conversation_parameters + else ConversationParameters( + bot=reference.bot, members=[reference.user], is_group=False + ) ) client = await self.create_connector_client(reference.service_url) @@ -206,7 +212,9 @@ async def create_conversation( parameters ) request = TurnContext.apply_conversation_reference( - Activity(), reference, is_incoming=True + Activity(type=ActivityTypes.event, name="CreateConversation"), + reference, + is_incoming=True, ) request.conversation = ConversationAccount( id=resource_response.id, tenant_id=parameters.tenant_id From 9a29fbdfd5d05a45856017d25f0bb8bf9357f1a6 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Thu, 12 Dec 2019 12:29:56 -0800 Subject: [PATCH 097/616] adding link unfurling bot --- scenarios/link-unfurling/README.md | 30 ++++++ scenarios/link-unfurling/app.py | 92 ++++++++++++++++++ scenarios/link-unfurling/bots/__init__.py | 6 ++ .../link-unfurling/bots/link_unfurling_bot.py | 57 +++++++++++ scenarios/link-unfurling/config.py | 13 +++ scenarios/link-unfurling/requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 67 +++++++++++++ .../teams_app_manifest/manifest.zip | Bin 0 -> 2461 bytes .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes 10 files changed, 267 insertions(+) create mode 100644 scenarios/link-unfurling/README.md create mode 100644 scenarios/link-unfurling/app.py create mode 100644 scenarios/link-unfurling/bots/__init__.py create mode 100644 scenarios/link-unfurling/bots/link_unfurling_bot.py create mode 100644 scenarios/link-unfurling/config.py create mode 100644 scenarios/link-unfurling/requirements.txt create mode 100644 scenarios/link-unfurling/teams_app_manifest/color.png create mode 100644 scenarios/link-unfurling/teams_app_manifest/manifest.json create mode 100644 scenarios/link-unfurling/teams_app_manifest/manifest.zip create mode 100644 scenarios/link-unfurling/teams_app_manifest/outline.png diff --git a/scenarios/link-unfurling/README.md b/scenarios/link-unfurling/README.md new file mode 100644 index 000000000..39f77916c --- /dev/null +++ b/scenarios/link-unfurling/README.md @@ -0,0 +1,30 @@ +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/link-unfurling/app.py b/scenarios/link-unfurling/app.py new file mode 100644 index 000000000..608452d8f --- /dev/null +++ b/scenarios/link-unfurling/app.py @@ -0,0 +1,92 @@ +# 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 LinkUnfurlingBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = LinkUnfurlingBot() + +# 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/link-unfurling/bots/__init__.py b/scenarios/link-unfurling/bots/__init__.py new file mode 100644 index 000000000..7dc2c44a9 --- /dev/null +++ b/scenarios/link-unfurling/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .link_unfurling_bot import LinkUnfurlingBot + +__all__ = ["LinkUnfurlingBot"] diff --git a/scenarios/link-unfurling/bots/link_unfurling_bot.py b/scenarios/link-unfurling/bots/link_unfurling_bot.py new file mode 100644 index 000000000..1c1888375 --- /dev/null +++ b/scenarios/link-unfurling/bots/link_unfurling_bot.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment +from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +class LinkUnfurlingBot(TeamsActivityHandler): + async def on_teams_app_based_link_query(self, turn_context: TurnContext, query: AppBasedLinkQuery): + hero_card = ThumbnailCard( + title="Thumnnail card", + text=query.url, + images=[ + CardImage( + url="https://raw.githubusercontent.com/microsoft/botframework-sdk/master/icon.png" + ) + ] + ) + attachments = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card) + result = MessagingExtensionResult( + attachment_layout="list", + type="result", + attachments=[attachments] + ) + return MessagingExtensionResponse(compose_extension=result) + + async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): + if query.command_id == "searchQuery": + card = HeroCard( + title="This is a Link Unfurling Sample", + subtitle="It will unfurl links from *.botframework.com", + text="This sample demonstrates how to handle link unfurling in Teams. Please review the readme for more information." + ) + attachment = Attachment( + content_type=CardFactory.content_types.hero_card, + content=card + ) + msg_ext_atc = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=attachment + ) + msg_ext_res = MessagingExtensionResult( + attachment_layout="list", + type="result", + attachments=[msg_ext_atc] + ) + response = MessagingExtensionResponse( + compose_extension=msg_ext_res + ) + + return response + + raise NotImplementedError(f"Invalid command: {query.command_id}") \ No newline at end of file diff --git a/scenarios/link-unfurling/config.py b/scenarios/link-unfurling/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/link-unfurling/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/link-unfurling/requirements.txt b/scenarios/link-unfurling/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/link-unfurling/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/link-unfurling/teams_app_manifest/color.png b/scenarios/link-unfurling/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.teams.sample.linkunfurling", + "developer": { + "name": "Link Unfurling", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Link Unfurling", + "full": "Link Unfurling" + }, + "description": { + "short": "Link Unfurling", + "full": "Link Unfurling" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ "personal", "team" ] + } + ], + "composeExtensions": [ + { + "botId": "<>", + "commands": [ + { + "id": "searchQuery", + "context": [ "compose", "commandBox" ], + "description": "Test command to run query", + "title": "Search", + "type": "query", + "parameters": [ + { + "name": "searchQuery", + "title": "Search Query", + "description": "Your search query", + "inputType": "text" + } + ] + } + ], + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "microsoft.com", + "github.com", + "linkedin.com", + "bing.com" + ] + } + } + ] + } + ] +} diff --git a/scenarios/link-unfurling/teams_app_manifest/manifest.zip b/scenarios/link-unfurling/teams_app_manifest/manifest.zip new file mode 100644 index 0000000000000000000000000000000000000000..aaedf42c4f052fa16794c7dd224681d5c9c3a4e6 GIT binary patch literal 2461 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 literal 0 HcmV?d00001 diff --git a/scenarios/link-unfurling/teams_app_manifest/outline.png b/scenarios/link-unfurling/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Thu, 12 Dec 2019 13:17:35 -0800 Subject: [PATCH 098/616] Moved BFHtppClient and ChannelServiceHandler out of skills directly into core (#500) --- libraries/botbuilder-core/botbuilder/core/__init__.py | 5 +++++ .../core/{integration => }/bot_framework_http_client.py | 3 ++- .../core/{integration => }/channel_service_handler.py | 0 .../botbuilder-core/botbuilder/core/integration/__init__.py | 5 ----- .../botbuilder/core/integration/aiohttp_channel_service.py | 2 +- .../aiohttp_channel_service_exception_middleware.py | 2 +- .../botbuilder-core/botbuilder/core/skills/skill_handler.py | 3 +-- .../simple-bot-to-bot/simple-root-bot/app.py | 2 +- .../simple-bot-to-bot/simple-root-bot/bots/root_bot.py | 2 +- samples/experimental/test-protocol/app.py | 3 ++- samples/experimental/test-protocol/routing_handler.py | 2 +- 11 files changed, 15 insertions(+), 14 deletions(-) rename libraries/botbuilder-core/botbuilder/core/{integration => }/bot_framework_http_client.py (98%) rename libraries/botbuilder-core/botbuilder/core/{integration => }/channel_service_handler.py (100%) diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 6ced95ae5..cdac7c42c 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -17,9 +17,11 @@ from .bot_state_set import BotStateSet from .bot_telemetry_client import BotTelemetryClient, Severity from .card_factory import CardFactory +from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler from .conversation_state import ConversationState from .intent_score import IntentScore from .invoke_response import InvokeResponse +from .bot_framework_http_client import BotFrameworkHttpClient from .memory_storage import MemoryStorage from .memory_transcript_store import MemoryTranscriptStore from .message_factory import MessageFactory @@ -44,6 +46,7 @@ "AnonymousReceiveMiddleware", "AutoSaveStateMiddleware", "Bot", + "BotActionNotImplementedError", "BotAdapter", "BotAssert", "BotFrameworkAdapter", @@ -53,10 +56,12 @@ "BotTelemetryClient", "calculate_change_hash", "CardFactory", + "ChannelServiceHandler", "ConversationState", "conversation_reference_extension", "IntentScore", "InvokeResponse", + "BotFrameworkHttpClient", "MemoryStorage", "MemoryTranscriptStore", "MessageFactory", diff --git a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py similarity index 98% rename from libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py rename to libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py index 81bd20139..a72e3a8f5 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py @@ -6,7 +6,6 @@ from logging import Logger import aiohttp -from botbuilder.core import InvokeResponse from botbuilder.schema import Activity from botframework.connector.auth import ( ChannelProvider, @@ -15,6 +14,8 @@ MicrosoftAppCredentials, ) +from . import InvokeResponse + class BotFrameworkHttpClient: diff --git a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py similarity index 100% rename from libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py rename to libraries/botbuilder-core/botbuilder/core/channel_service_handler.py diff --git a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py index a971ce6f6..db24c43d3 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py @@ -6,14 +6,9 @@ # -------------------------------------------------------------------------- from .aiohttp_channel_service import aiohttp_channel_service_routes -from .bot_framework_http_client import BotFrameworkHttpClient -from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler from .aiohttp_channel_service_exception_middleware import aiohttp_error_middleware __all__ = [ "aiohttp_channel_service_routes", - "BotFrameworkHttpClient", - "BotActionNotImplementedError", - "ChannelServiceHandler", "aiohttp_error_middleware", ] diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py index 9c7284ad3..af2545d89 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -13,7 +13,7 @@ Transcript, ) -from .channel_service_handler import ChannelServiceHandler +from botbuilder.core import ChannelServiceHandler async def deserialize_from_body( diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py index 7b2949894..d1c6f77e6 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py @@ -6,7 +6,7 @@ HTTPInternalServerError, ) -from .channel_service_handler import BotActionNotImplementedError +from botbuilder.core import BotActionNotImplementedError @middleware diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 05ec99bb0..3158d35e6 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -3,8 +3,7 @@ from uuid import uuid4 -from botbuilder.core.integration import ChannelServiceHandler -from botbuilder.core import Bot, BotAdapter, TurnContext +from botbuilder.core import Bot, BotAdapter, ChannelServiceHandler, TurnContext from botbuilder.schema import ( Activity, ActivityTypes, diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py index baba86ac1..d3c0aafd1 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py @@ -9,13 +9,13 @@ from aiohttp.web import Request, Response from botbuilder.core import ( BotFrameworkAdapterSettings, + BotFrameworkHttpClient, ConversationState, MemoryStorage, TurnContext, BotFrameworkAdapter, ) from botbuilder.core.integration import ( - BotFrameworkHttpClient, aiohttp_channel_service_routes, aiohttp_error_middleware, ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py index 6ce16672c..78ca44ed4 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py @@ -2,11 +2,11 @@ from botbuilder.core import ( ActivityHandler, + BotFrameworkHttpClient, ConversationState, MessageFactory, TurnContext, ) -from botbuilder.core.integration import BotFrameworkHttpClient from botbuilder.core.skills import SkillConversationIdFactory from botbuilder.schema import ActivityTypes, ChannelAccount diff --git a/samples/experimental/test-protocol/app.py b/samples/experimental/test-protocol/app.py index e95d2f1be..ed7625cbc 100644 --- a/samples/experimental/test-protocol/app.py +++ b/samples/experimental/test-protocol/app.py @@ -5,7 +5,8 @@ from aiohttp.web import Request, Response from botframework.connector.auth import AuthenticationConfiguration, SimpleCredentialProvider -from botbuilder.core.integration import BotFrameworkHttpClient, aiohttp_channel_service_routes +from botbuilder.core import BotFrameworkHttpClient +from botbuilder.core.integration import aiohttp_channel_service_routes from botbuilder.schema import Activity from config import DefaultConfig diff --git a/samples/experimental/test-protocol/routing_handler.py b/samples/experimental/test-protocol/routing_handler.py index 0de21123b..9b9bd346e 100644 --- a/samples/experimental/test-protocol/routing_handler.py +++ b/samples/experimental/test-protocol/routing_handler.py @@ -3,7 +3,7 @@ from typing import List -from botbuilder.core.integration import ChannelServiceHandler +from botbuilder.core import ChannelServiceHandler from botbuilder.schema import ( Activity, ChannelAccount, From d98b68c737b5054823690def6c3e650812683915 Mon Sep 17 00:00:00 2001 From: Jessica Wailes Date: Thu, 12 Dec 2019 15:00:55 -0800 Subject: [PATCH 099/616] aiohttp deployment enhancement --- samples/02.echo-bot/app.py | 19 +++++++++++++++---- .../template-with-preexisting-rg.json | 2 +- samples/02.echo-bot/requirements.txt | 1 + 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/samples/02.echo-bot/app.py b/samples/02.echo-bot/app.py index 241f51e4e..38e61a682 100644 --- a/samples/02.echo-bot/app.py +++ b/samples/02.echo-bot/app.py @@ -68,12 +68,23 @@ async def messages(req: Request) -> Response: except Exception as exception: raise exception +def app(): + APP = web.Application() + APP.router.add_post("/api/messages", messages) + return APP -APP = web.Application() -APP.router.add_post("/api/messages", messages) +#this is the code needed for the deployment template startup command +def init_func(argv): + try: + APP = app() + except Exception as error: + raise error + + return APP +#this part is needed if you start your bot with 'py app.py' instead of the deployed command. if __name__ == "__main__": try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) + web.run_app(app(), host="localhost", port=CONFIG.PORT) except Exception as error: - raise error + raise error \ No newline at end of file diff --git a/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json index bff8c096d..f5e572d7c 100644 --- a/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json +++ b/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -189,7 +189,7 @@ "use32BitWorkerProcess": true, "webSocketsEnabled": false, "alwaysOn": false, - "appCommandLine": "", + "appCommandLine": "python -m aiohttp.web -H 0.0.0.0 -P 8000 app:init_func", "managedPipelineMode": "Integrated", "virtualApplications": [ { diff --git a/samples/02.echo-bot/requirements.txt b/samples/02.echo-bot/requirements.txt index 7e54b62ec..8e08b09a4 100644 --- a/samples/02.echo-bot/requirements.txt +++ b/samples/02.echo-bot/requirements.txt @@ -1,2 +1,3 @@ botbuilder-core>=4.4.0b1 flask>=1.0.3 +aiohttp>=3.6.2 \ No newline at end of file From d7671597a59c70d4bd94cbc854dda896f471dff9 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Thu, 12 Dec 2019 15:12:35 -0800 Subject: [PATCH 100/616] adding return types (#499) * adding return types * updating method name * updating tests * fixing black --- .../core/teams/teams_activity_handler.py | 37 +++++++++++-------- .../teams/test_teams_activity_handler.py | 13 +++++-- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 30035ae34..ff7b3b1b8 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -16,8 +16,11 @@ TeamsChannelAccount, MessagingExtensionAction, MessagingExtensionQuery, + MessagingExtensionActionResponse, + MessagingExtensionResponse, O365ConnectorCardActionQuery, TaskModuleRequest, + TaskModuleResponse, ) from botframework.connector import Channels @@ -49,7 +52,7 @@ async def on_turn(self, turn_context: TurnContext): await super().on_turn(turn_context) - async def on_invoke_activity(self, turn_context: TurnContext): + async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: try: if ( not turn_context.activity.name @@ -171,7 +174,9 @@ async def on_invoke_activity(self, turn_context: TurnContext): except _InvokeResponseException as err: return err.create_invoke_response() - async def on_teams_card_action_invoke_activity(self, turn_context: TurnContext): + async def on_teams_card_action_invoke_activity( + self, turn_context: TurnContext + ) -> InvokeResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_signin_verify_state(self, turn_context: TurnContext): @@ -181,7 +186,7 @@ async def on_teams_file_consent( self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, - ): + ) -> InvokeResponse: if file_consent_card_response.action == "accept": await self.on_teams_file_consent_accept_activity( turn_context, file_consent_card_response @@ -220,22 +225,22 @@ async def on_teams_o365_connector_card_action( # pylint: disable=unused-argumen async def on_teams_app_based_link_query( # pylint: disable=unused-argument self, turn_context: TurnContext, query: AppBasedLinkQuery - ): + ) -> MessagingExtensionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_query( # pylint: disable=unused-argument self, turn_context: TurnContext, query: MessagingExtensionQuery - ): + ) -> MessagingExtensionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_select_item( # pylint: disable=unused-argument self, turn_context: TurnContext, query - ): + ) -> MessagingExtensionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_submit_action_dispatch( self, turn_context: TurnContext, action: MessagingExtensionAction - ): + ) -> MessagingExtensionActionResponse: if not action.bot_message_preview_action: return await self.on_teams_messaging_extension_submit_action_activity( turn_context, action @@ -247,7 +252,7 @@ async def on_teams_messaging_extension_submit_action_dispatch( ) if action.bot_message_preview_action == "send": - return await self.on_teams_messaging_extension_bot_message_send_activity( + return await self.on_teams_messaging_extension_bot_message_preview_send_activity( turn_context, action ) @@ -258,27 +263,27 @@ async def on_teams_messaging_extension_submit_action_dispatch( async def on_teams_messaging_extension_bot_message_preview_edit_activity( # pylint: disable=unused-argument self, turn_context: TurnContext, action - ): + ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_bot_message_send_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_bot_message_preview_send_activity( # pylint: disable=unused-argument self, turn_context: TurnContext, action - ): + ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_submit_action_activity( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction - ): + ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_fetch_task( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction - ): + ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_configuration_query_settings_url( # pylint: disable=unused-argument self, turn_context: TurnContext, query: MessagingExtensionQuery - ): + ) -> MessagingExtensionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_configuration_setting( # pylint: disable=unused-argument @@ -293,12 +298,12 @@ async def on_teams_messaging_extension_card_button_clicked( # pylint: disable=u async def on_teams_task_module_fetch( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ): + ) -> TaskModuleResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_task_module_submit( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ): + ) -> TaskModuleResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_conversation_update_activity(self, turn_context: TurnContext): diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index f65a861d5..d9eabcb68 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -171,11 +171,13 @@ async def on_teams_messaging_extension_bot_message_preview_edit_activity( turn_context, action ) - async def on_teams_messaging_extension_bot_message_send_activity( + async def on_teams_messaging_extension_bot_message_preview_send_activity( self, turn_context: TurnContext, action: MessagingExtensionAction ): - self.record.append("on_teams_messaging_extension_bot_message_send_activity") - return await super().on_teams_messaging_extension_bot_message_send_activity( + self.record.append( + "on_teams_messaging_extension_bot_message_preview_send_activity" + ) + return await super().on_teams_messaging_extension_bot_message_preview_send_activity( turn_context, action ) @@ -531,7 +533,10 @@ async def test_on_teams_messaging_extension_bot_message_send_activity(self): assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_bot_message_send_activity" + assert ( + bot.record[2] + == "on_teams_messaging_extension_bot_message_preview_send_activity" + ) async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( self, From 9144763ca0caabfe076fb5e7358ca1507d9774e4 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Thu, 12 Dec 2019 23:50:31 -0800 Subject: [PATCH 101/616] serialize invoke activity body --- .../botbuilder/core/bot_framework_adapter.py | 6 +++++- .../botbuilder/core/teams/__init__.py | 3 ++- .../core/teams/teams_activity_handler.py | 4 ++-- .../botbuilder/core/teams/teams_helper.py | 19 ++++++++++++++++++- .../botbuilder/core/teams/teams_info.py | 8 ++++++-- .../botbuilder/schema/teams/_models.py | 1 + .../botbuilder/schema/teams/_models_py3.py | 1 + 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 9facd0f61..2e0f9c024 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -254,7 +254,11 @@ async def process_activity(self, req, auth_header: str, logic: Callable): teams_channel_data["tenant"]["id"] ) - return await self.run_pipeline(context, logic) + pipeline_result = await self.run_pipeline(context, logic) + + return pipeline_result or context.turn_state.get( + BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access + ) async def authenticate_request( self, request: Activity, auth_header: str diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py index 2e482ac88..9acc2a250 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py @@ -7,7 +7,7 @@ from .teams_activity_handler import TeamsActivityHandler from .teams_info import TeamsInfo -from .teams_helper import deserializer_helper +from .teams_helper import deserializer_helper, serializer_helper from .teams_activity_extensions import ( teams_get_channel_id, teams_get_team_info, @@ -21,4 +21,5 @@ "teams_get_channel_id", "teams_get_team_info", "teams_notify_user", + "serializer_helper", ] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index f2f12f141..a4d8b2eb3 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -4,7 +4,7 @@ from http import HTTPStatus from botbuilder.schema import Activity, ActivityTypes, ChannelAccount from botbuilder.core.turn_context import TurnContext -from botbuilder.core.teams.teams_helper import deserializer_helper +from botbuilder.core.teams.teams_helper import deserializer_helper, serializer_helper from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.schema.teams import ( AppBasedLinkQuery, @@ -441,7 +441,7 @@ async def on_teams_channel_renamed_activity( # pylint: disable=unused-argument @staticmethod def _create_invoke_response(body: object = None) -> InvokeResponse: - return InvokeResponse(status=int(HTTPStatus.OK), body=body) + return InvokeResponse(status=int(HTTPStatus.OK), body=serializer_helper(body)) class _InvokeResponseException(Exception): diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index 2e11f2953..c5bc77c99 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -2,7 +2,7 @@ from typing import Type from enum import Enum -from msrest.serialization import Model, Deserializer +from msrest.serialization import Model, Deserializer, Serializer import botbuilder.schema as schema import botbuilder.schema.teams as teams_schema @@ -22,3 +22,20 @@ def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> M dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} deserializer = Deserializer(dependencies_dict) return deserializer(msrest_cls.__name__, dict_to_deserialize) + +# TODO consolidate these two methods + +def serializer_helper(object_to_serialize: Model) -> dict: + dependencies = [ + schema_cls + for key, schema_cls in getmembers(schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) + ] + dependencies += [ + schema_cls + for key, schema_cls in getmembers(teams_schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) + ] + dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} + serializer = Serializer(dependencies_dict) + return serializer._serialize(object_to_serialize) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index b70fe256f..c0a020a01 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -3,6 +3,7 @@ from typing import List from botbuilder.core.turn_context import TurnContext +from botbuilder.core.teams.teams_helper import deserializer_helper from botbuilder.schema.teams import ( ChannelInfo, TeamDetails, @@ -110,7 +111,10 @@ async def _get_members( ) for member in members: - new_account_json = member.serialize() - teams_members.append(TeamsChannelAccount(**new_account_json)) + teams_members.append( + TeamsChannelAccount().deserialize( + dict(member.serialize(), **member.additional_properties) + ) + ) return teams_members diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 835846cb4..3cce195d6 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1538,6 +1538,7 @@ class TeamsChannelAccount(ChannelAccount): "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, + "aad_object_id": {"key": "objectId", "type": "str"}, } def __init__(self, **kwargs): diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 62d1e4a6f..4c5213075 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1798,6 +1798,7 @@ class TeamsChannelAccount(ChannelAccount): "surname": {"key": "surname", "type": "str"}, "email": {"key": "email", "type": "str"}, "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "aad_object_id": {"key": "objectId", "type": "str"}, } def __init__( From efed4749f280a28f34da4f7ff9506c8db5da8595 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 00:14:41 -0800 Subject: [PATCH 102/616] pylint fixes --- libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py | 2 +- libraries/botbuilder-core/botbuilder/core/teams/teams_info.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index c5bc77c99..24cc082e7 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -38,4 +38,4 @@ def serializer_helper(object_to_serialize: Model) -> dict: ] dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} serializer = Serializer(dependencies_dict) - return serializer._serialize(object_to_serialize) + return serializer._serialize(object_to_serialize) # pylint: disable=protected-access diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index c0a020a01..ca1e71a43 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -3,7 +3,6 @@ from typing import List from botbuilder.core.turn_context import TurnContext -from botbuilder.core.teams.teams_helper import deserializer_helper from botbuilder.schema.teams import ( ChannelInfo, TeamDetails, From b688c1c6d575340e6cc358a0cf16a35475f922d3 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 00:18:03 -0800 Subject: [PATCH 103/616] black fixes --- .../botbuilder/core/bot_framework_adapter.py | 4 ++-- .../botbuilder-core/botbuilder/core/teams/teams_helper.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 2e0f9c024..03b9a96d2 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -257,8 +257,8 @@ async def process_activity(self, req, auth_header: str, logic: Callable): pipeline_result = await self.run_pipeline(context, logic) return pipeline_result or context.turn_state.get( - BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access - ) + BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access + ) async def authenticate_request( self, request: Activity, auth_header: str diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index 24cc082e7..b2491df1c 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -23,8 +23,10 @@ def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> M deserializer = Deserializer(dependencies_dict) return deserializer(msrest_cls.__name__, dict_to_deserialize) + # TODO consolidate these two methods + def serializer_helper(object_to_serialize: Model) -> dict: dependencies = [ schema_cls @@ -38,4 +40,6 @@ def serializer_helper(object_to_serialize: Model) -> dict: ] dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} serializer = Serializer(dependencies_dict) - return serializer._serialize(object_to_serialize) # pylint: disable=protected-access + return serializer._serialize( + object_to_serialize + ) # pylint: disable=protected-access From b3844a0f4c48a607b9e5ba1c1d8f2489934d078f Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 00:37:05 -0800 Subject: [PATCH 104/616] fix merge issues --- .../botbuilder/core/teams/teams_activity_handler.py | 1 - .../botbuilder-core/botbuilder/core/teams/teams_helper.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index baa2ab77b..174f7e9b8 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -6,7 +6,6 @@ from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.core.turn_context import TurnContext from botbuilder.core.teams.teams_helper import deserializer_helper, serializer_helper -from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.core.teams.teams_info import TeamsInfo from botbuilder.schema.teams import ( AppBasedLinkQuery, diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index b2491df1c..1fd496e9a 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -40,6 +40,7 @@ def serializer_helper(object_to_serialize: Model) -> dict: ] dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} serializer = Serializer(dependencies_dict) + # pylint: disable=protected-access return serializer._serialize( object_to_serialize - ) # pylint: disable=protected-access + ) From 908b2c7ccef37734f74421a063d31d0bb5426e1f Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 00:41:59 -0800 Subject: [PATCH 105/616] black fix --- .../botbuilder-core/botbuilder/core/teams/teams_helper.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index 1fd496e9a..8772c6e04 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -41,6 +41,4 @@ def serializer_helper(object_to_serialize: Model) -> dict: dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} serializer = Serializer(dependencies_dict) # pylint: disable=protected-access - return serializer._serialize( - object_to_serialize - ) + return serializer._serialize(object_to_serialize) From 4eba068009a72645c9006b698719b03abd760153 Mon Sep 17 00:00:00 2001 From: Gurvinder Singh Date: Fri, 13 Dec 2019 23:09:19 +0530 Subject: [PATCH 106/616] [QnA Maker] IsTest and Ranker type support for QnAMaker.GetAnswer (#477) * [QnA Maker] IsTest and Ranker type support for QnAMaker.GetAnswer * Formatting fix * Formatting fix --- .../models/generate_answer_request_body.py | 11 ++++++ .../ai/qna/models/qnamaker_trace_info.py | 9 +++++ .../botbuilder/ai/qna/models/ranker_types.py | 15 ++++++++ .../botbuilder/ai/qna/qnamaker_options.py | 5 +++ .../ai/qna/utils/generate_answer_utils.py | 6 ++++ .../qna/test_data/QnaMaker_IsTest_true.json | 13 +++++++ .../QnaMaker_RankerType_QuestionOnly.json | 35 +++++++++++++++++++ libraries/botbuilder-ai/tests/qna/test_qna.py | 32 +++++++++++++++++ 8 files changed, 126 insertions(+) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/models/ranker_types.py create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_IsTest_true.json create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_RankerType_QuestionOnly.json diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py index 34afb4d2f..20162a08f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py @@ -7,6 +7,7 @@ from .metadata import Metadata from .qna_request_context import QnARequestContext +from .ranker_types import RankerTypes class GenerateAnswerRequestBody(Model): @@ -19,6 +20,8 @@ class GenerateAnswerRequestBody(Model): "strict_filters": {"key": "strictFilters", "type": "[Metadata]"}, "context": {"key": "context", "type": "QnARequestContext"}, "qna_id": {"key": "qnaId", "type": "int"}, + "is_test": {"key": "isTest", "type": "bool"}, + "ranker_type": {"key": "rankerType", "type": "RankerTypes"}, } def __init__( @@ -29,6 +32,8 @@ def __init__( strict_filters: List[Metadata], context: QnARequestContext = None, qna_id: int = None, + is_test: bool = False, + ranker_type: str = RankerTypes.DEFAULT, **kwargs ): """ @@ -47,6 +52,10 @@ def __init__( qna_id: Id of the current question asked. + is_test: (Optional) A value indicating whether to call test or prod environment of knowledgebase. + + ranker_types: (Optional) Ranker types. + """ super().__init__(**kwargs) @@ -57,3 +66,5 @@ def __init__( self.strict_filters = strict_filters self.context = context self.qna_id = qna_id + self.is_test = is_test + self.ranker_type = ranker_type diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qnamaker_trace_info.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qnamaker_trace_info.py index 987ea9677..f585e5c26 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qnamaker_trace_info.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qnamaker_trace_info.py @@ -7,6 +7,7 @@ from .metadata import Metadata from .query_result import QueryResult from .qna_request_context import QnARequestContext +from .ranker_types import RankerTypes class QnAMakerTraceInfo: @@ -22,6 +23,8 @@ def __init__( strict_filters: List[Metadata], context: QnARequestContext = None, qna_id: int = None, + is_test: bool = False, + ranker_type: str = RankerTypes.DEFAULT, ): """ Parameters: @@ -42,6 +45,10 @@ def __init__( context: (Optional) The context from which the QnA was extracted. qna_id: (Optional) Id of the current question asked. + + is_test: (Optional) A value indicating whether to call test or prod environment of knowledgebase. + + ranker_types: (Optional) Ranker types. """ self.message = message self.query_results = query_results @@ -51,3 +58,5 @@ def __init__( self.strict_filters = strict_filters self.context = context self.qna_id = qna_id + self.is_test = is_test + self.ranker_type = ranker_type diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/ranker_types.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/ranker_types.py new file mode 100644 index 000000000..a3f0463ca --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/ranker_types.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class RankerTypes: + + """ Default Ranker Behaviour. i.e. Ranking based on Questions and Answer. """ + + DEFAULT = "Default" + + """ Ranker based on question Only. """ + QUESTION_ONLY = "QuestionOnly" + + """ Ranker based on Autosuggest for question field only. """ + AUTO_SUGGEST_QUESTION = "AutoSuggestQuestion" diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py index a32f49fed..d93b1cd1f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from .models import Metadata, QnARequestContext +from .models.ranker_types import RankerTypes # figure out if 300 milliseconds is ok for python requests library...or 100000 class QnAMakerOptions: @@ -13,6 +14,8 @@ def __init__( strict_filters: [Metadata] = None, context: [QnARequestContext] = None, qna_id: int = None, + is_test: bool = False, + ranker_type: bool = RankerTypes.DEFAULT, ): self.score_threshold = score_threshold self.timeout = timeout @@ -20,3 +23,5 @@ def __init__( self.strict_filters = strict_filters or [] self.context = context self.qna_id = qna_id + self.is_test = is_test + self.ranker_type = ranker_type diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py index b683c50da..3852f1365 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py @@ -139,6 +139,8 @@ def _hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: hydrated_options.context = query_options.context hydrated_options.qna_id = query_options.qna_id + hydrated_options.is_test = query_options.is_test + hydrated_options.ranker_type = query_options.ranker_type return hydrated_options @@ -154,6 +156,8 @@ async def _query_qna_service( strict_filters=options.strict_filters, context=options.context, qna_id=options.qna_id, + is_test=options.is_test, + ranker_type=options.ranker_type, ) http_request_helper = HttpRequestUtils(self._http_client) @@ -178,6 +182,8 @@ async def _emit_trace_info( strict_filters=options.strict_filters, context=options.context, qna_id=options.qna_id, + is_test=options.is_test, + ranker_type=options.ranker_type, ) trace_activity = Activity( diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_IsTest_true.json b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_IsTest_true.json new file mode 100644 index 000000000..4723ee95e --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_IsTest_true.json @@ -0,0 +1,13 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [], + "answer": "No good match found in KB.", + "score": 0, + "id": -1, + "source": null, + "metadata": [] + } + ] + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_RankerType_QuestionOnly.json b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_RankerType_QuestionOnly.json new file mode 100644 index 000000000..c3df1eb40 --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_RankerType_QuestionOnly.json @@ -0,0 +1,35 @@ +{ + "activeLearningEnabled": false, + "answers": [ + { + "questions": [ + "Q1" + ], + "answer": "A1", + "score": 80, + "id": 15, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q2" + ], + "answer": "A2", + "score": 78, + "id": 16, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index fa1643612..10dbd5e89 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -702,6 +702,38 @@ async def test_should_filter_low_score_variation(self): "Should have 3 filtered answers after low score variation.", ) + async def test_should_answer_with_is_test_true(self): + options = QnAMakerOptions(top=1, is_test=True) + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "Q11" + context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "QnaMaker_IsTest_true.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context, options=options) + self.assertEqual(0, len(results), "Should have received zero answer.") + + async def test_should_answer_with_ranker_type_question_only(self): + options = QnAMakerOptions(top=1, ranker_type="QuestionOnly") + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "Q11" + context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "QnaMaker_RankerType_QuestionOnly.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context, options=options) + self.assertEqual(2, len(results), "Should have received two answers.") + async def test_should_answer_with_prompts(self): options = QnAMakerOptions(top=2) qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) From 68b7e21d946d39aef73979c163b749341f1f8269 Mon Sep 17 00:00:00 2001 From: daveta <6182197+daveta@users.noreply.github.com> Date: Fri, 13 Dec 2019 09:50:25 -0800 Subject: [PATCH 107/616] Functional Test (#439) * Cleanup * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Add deploy script with retries * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Better error checking * Update azure-pipelines.yml for Azure Pipelines * Update azure-pipelines.yml for Azure Pipelines * Don't run pytest on functional-tests * Fix black/pylint * Black fix --- .coveragerc | 3 +- azure-pipelines.yml | 67 ++--- .../functionaltestbot/Dockerfile | 48 ---- .../functionaltestbot/Dockfile | 27 -- .../functionaltestbot/README.md | 9 + .../functionaltestbot/application.py | 98 ++++++++ .../{flask_bot_app => bots}/__init__.py | 4 +- .../functionaltestbot/bots/echo_bot.py | 19 ++ .../functionaltestbot/client_driver/README.md | 5 - .../{functionaltestbot => }/config.py | 2 +- .../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/README.md | 35 --- .../functionaltestbot/about.py | 14 -- .../functionaltestbot/app.py | 86 ------- .../functionaltestbot/bot.py | 19 -- .../functionaltestbot/requirements.txt | 3 - .../functionaltestbot/init.sh | 8 - .../functionaltestbot/requirements.txt | 12 +- .../functionaltestbot/runserver.py | 16 -- .../scripts/deploy_webapp.sh | 186 ++++++++++++++ .../functionaltestbot/setup.py | 40 --- .../functionaltestbot/sshd_config | 21 -- .../template/linux/template.json | 238 ------------------ .../functionaltestbot/test.sh | 1 - .../tests/direct_line_client.py | 0 .../functionaltestbot/tests/test_py_bot.py | 48 ++++ .../functional-tests/tests/test_py_bot.py | 26 -- setup.cfg | 2 + 31 files changed, 393 insertions(+), 804 deletions(-) delete mode 100644 libraries/functional-tests/functionaltestbot/Dockerfile delete mode 100644 libraries/functional-tests/functionaltestbot/Dockfile create mode 100644 libraries/functional-tests/functionaltestbot/README.md create mode 100644 libraries/functional-tests/functionaltestbot/application.py rename libraries/functional-tests/functionaltestbot/{flask_bot_app => bots}/__init__.py (64%) create mode 100644 libraries/functional-tests/functionaltestbot/bots/echo_bot.py delete mode 100644 libraries/functional-tests/functionaltestbot/client_driver/README.md rename libraries/functional-tests/functionaltestbot/{functionaltestbot => }/config.py (94%) delete mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/app.py delete mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py delete mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/default_config.py delete mode 100644 libraries/functional-tests/functionaltestbot/flask_bot_app/my_bot.py delete mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/README.md delete mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/about.py delete mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/app.py delete mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py delete mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt delete mode 100644 libraries/functional-tests/functionaltestbot/init.sh delete mode 100644 libraries/functional-tests/functionaltestbot/runserver.py create mode 100644 libraries/functional-tests/functionaltestbot/scripts/deploy_webapp.sh delete mode 100644 libraries/functional-tests/functionaltestbot/setup.py delete mode 100644 libraries/functional-tests/functionaltestbot/sshd_config delete mode 100644 libraries/functional-tests/functionaltestbot/template/linux/template.json delete mode 100644 libraries/functional-tests/functionaltestbot/test.sh rename libraries/functional-tests/{ => functionaltestbot}/tests/direct_line_client.py (100%) create mode 100644 libraries/functional-tests/functionaltestbot/tests/test_py_bot.py delete mode 100644 libraries/functional-tests/tests/test_py_bot.py create mode 100644 setup.cfg diff --git a/.coveragerc b/.coveragerc index 4dd59303b..304e0a883 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,4 +3,5 @@ source = ./libraries/ omit = */tests/* setup.py - */botbuilder-schema/* \ No newline at end of file + */botbuilder-schema/* + */functional-tests/* diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c424c7f01..f2eb246e9 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,61 +1,32 @@ -trigger: +schedules: +- cron: "0 0 * * *" + displayName: Daily midnight build branches: include: - - daveta-python-functional - exclude: - master variables: - # Container registry service connection established during pipeline creation - dockerRegistryServiceConnection: 'NightlyE2E-Acr' - azureRmServiceConnection: 'NightlyE2E-RM' - dockerFilePath: 'libraries/functional-tests/functionaltestbot/Dockerfile' - buildIdTag: $(Build.BuildNumber) - webAppName: 'e2epython' - containerRegistry: 'nightlye2etest.azurecr.io' - imageRepository: 'functionaltestpy' - - - + resourceGroupName: 'pyfuntest' jobs: -# Build and publish container -- job: Build +- job: Doit pool: vmImage: 'Ubuntu-16.04' - displayName: Build and push bot image - continueOnError: false - steps: - - task: Docker@2 - displayName: Build and push bot image - inputs: - command: buildAndPush - repository: $(imageRepository) - dockerfile: $(dockerFilePath) - containerRegistry: $(dockerRegistryServiceConnection) - tags: $(buildIdTag) - - -- job: Deploy - displayName: Provision bot container - pool: - vmImage: 'Ubuntu-16.04' - dependsOn: - - Build steps: - - task: AzureRMWebAppDeployment@4 - displayName: Python Functional E2E test. + - task: UsePythonVersion@0 + displayName: Use Python 3.6 inputs: - ConnectionType: AzureRM - ConnectedServiceName: $(azureRmServiceConnection) - appType: webAppContainer - WebAppName: $(webAppName) - DockerNamespace: $(containerRegistry) - DockerRepository: $(imageRepository) - DockerImageTag: $(buildIdTag) - AppSettings: '-MicrosoftAppId $(botAppId) -MicrosoftAppPassword $(botAppPassword) -FLASK_APP /functionaltestbot/app.py -FLASK_DEBUG 1' - - #StartupCommand: 'flask run --host=0.0.0.0 --port=3978' - + versionSpec: '3.6' + - task: AzureCLI@2 + displayName: Provision, Deploy and run tests + inputs: + azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + cd $(Build.SourcesDirectory)/libraries/functional-tests/functionaltestbot + chmod +x ./scripts/deploy_webapp.sh + ./scripts/deploy_webapp.sh --appid $(botAppId) --password $(botAppPassword) -g $(resourceGroupName) + continueOnError: false diff --git a/libraries/functional-tests/functionaltestbot/Dockerfile b/libraries/functional-tests/functionaltestbot/Dockerfile deleted file mode 100644 index 3364fc380..000000000 --- a/libraries/functional-tests/functionaltestbot/Dockerfile +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -FROM tiangolo/uwsgi-nginx-flask:python3.6 - - -RUN mkdir /functionaltestbot - -EXPOSE 443 -# EXPOSE 2222 - -COPY ./functionaltestbot /functionaltestbot -COPY setup.py / -COPY test.sh / -# RUN ls -ltr -# RUN cat prestart.sh -# RUN cat main.py - -ENV FLASK_APP=/functionaltestbot/app.py -ENV LANG=C.UTF-8 -ENV LC_ALL=C.UTF-8 -ENV PATH ${PATH}:/home/site/wwwroot - -WORKDIR / - -# Initialize the bot -RUN pip3 install -e . - -# ssh -ENV SSH_PASSWD "root:Docker!" -RUN apt-get update \ - && apt-get install -y --no-install-recommends dialog \ - && apt-get update \ - && apt-get install -y --no-install-recommends openssh-server \ - && echo "$SSH_PASSWD" | chpasswd \ - && apt install -y --no-install-recommends vim -COPY sshd_config /etc/ssh/ -COPY init.sh /usr/local/bin/ -RUN chmod u+x /usr/local/bin/init.sh - -# For Debugging, uncomment the following: -# ENTRYPOINT ["python3.6", "-c", "import time ; time.sleep(500000)"] -ENTRYPOINT ["init.sh"] - -# For Devops, they don't like entry points. This is now in the devops -# pipeline. -# ENTRYPOINT [ "flask" ] -# CMD [ "run", "--port", "3978", "--host", "0.0.0.0" ] diff --git a/libraries/functional-tests/functionaltestbot/Dockfile b/libraries/functional-tests/functionaltestbot/Dockfile deleted file mode 100644 index 8383f9a2b..000000000 --- a/libraries/functional-tests/functionaltestbot/Dockfile +++ /dev/null @@ -1,27 +0,0 @@ -# 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/README.md b/libraries/functional-tests/functionaltestbot/README.md new file mode 100644 index 000000000..f6d8e670f --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/README.md @@ -0,0 +1,9 @@ +# Functional Test Bot +This bot is the "Echo" bot which perform E2E functional test. +- Cleans up +- Deploys the python echo bot to Azure +- Creates an Azure Bot and associates with the deployed python bot. +- Creates a DirectLine channel and associates with the newly created bot. +- Runs a client test, using the DirectLine channel and and verifies response. + +This is modeled in a Devops. \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/application.py b/libraries/functional-tests/functionaltestbot/application.py new file mode 100644 index 000000000..cf8de8edc --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/application.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +import os +from datetime import datetime + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + BotFrameworkAdapter, + TurnContext, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import EchoBot + +# Create the loop and Flask app +LOOP = asyncio.get_event_loop() +# pylint: disable=invalid-name +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( + os.environ.get("MicrosoftAppId", ""), os.environ.get("MicrosoftAppPassword", "") +) +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 = EchoBot() + +# 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 + + +@app.route("/", methods=["GET"]) +def ping(): + return "Hello World!" + + +if __name__ == "__main__": + try: + app.run(debug=False, port=3978) # nosec debug + except Exception as exception: + raise exception diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py b/libraries/functional-tests/functionaltestbot/bots/__init__.py similarity index 64% rename from libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py rename to libraries/functional-tests/functionaltestbot/bots/__init__.py index d5d099805..f95fbbbad 100644 --- a/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py +++ b/libraries/functional-tests/functionaltestbot/bots/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .app import APP +from .echo_bot import EchoBot -__all__ = ["APP"] +__all__ = ["EchoBot"] diff --git a/libraries/functional-tests/functionaltestbot/bots/echo_bot.py b/libraries/functional-tests/functionaltestbot/bots/echo_bot.py new file mode 100644 index 000000000..90a094640 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/bots/echo_bot.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount + + +class EchoBot(ActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + return await turn_context.send_activity( + MessageFactory.text(f"Echo: {turn_context.activity.text}") + ) diff --git a/libraries/functional-tests/functionaltestbot/client_driver/README.md b/libraries/functional-tests/functionaltestbot/client_driver/README.md deleted file mode 100644 index 317a457c9..000000000 --- a/libraries/functional-tests/functionaltestbot/client_driver/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Client Driver for Function E2E test - -This contains the client code that drives the bot functional test. - -It performs simple operations against the bot and validates results. \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/config.py b/libraries/functional-tests/functionaltestbot/config.py similarity index 94% rename from libraries/functional-tests/functionaltestbot/functionaltestbot/config.py rename to libraries/functional-tests/functionaltestbot/config.py index a3bd72174..6b5116fba 100644 --- a/libraries/functional-tests/functionaltestbot/functionaltestbot/config.py +++ b/libraries/functional-tests/functionaltestbot/config.py @@ -8,6 +8,6 @@ class DefaultConfig: """ Bot Configuration """ - PORT = 443 + PORT = 3978 APP_ID = os.environ.get("MicrosoftAppId", "") APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") diff --git a/libraries/functional-tests/functionaltestbot/flask_bot_app/app.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/app.py deleted file mode 100644 index 10f99452e..000000000 --- a/libraries/functional-tests/functionaltestbot/flask_bot_app/app.py +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index 5fb109576..000000000 --- a/libraries/functional-tests/functionaltestbot/flask_bot_app/bot_app.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from types import MethodType -from flask import Flask, Response, request - -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - MessageFactory, - TurnContext, -) -from botbuilder.schema import Activity, InputHints - -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 deleted file mode 100644 index 96c277e09..000000000 --- a/libraries/functional-tests/functionaltestbot/flask_bot_app/default_config.py +++ /dev/null @@ -1,12 +0,0 @@ -# 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 deleted file mode 100644 index 58f002986..000000000 --- a/libraries/functional-tests/functionaltestbot/flask_bot_app/my_bot.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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/functionaltestbot/README.md b/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md deleted file mode 100644 index 996e0909b..000000000 --- a/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Console EchoBot -Bot Framework v4 console echo sample. - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that you can talk to from the console window. - -This sample shows a simple echo bot and demonstrates the bot working as a console app using a sample console adapter. - -## To try this sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` - - -### Visual studio code -- open `botbuilder-python\samples\01.console-echo` folder -- Bring up a terminal, navigate to `botbuilder-python\samples\01.console-echo` folder -- type 'python main.py' - - -# Adapters -[Adapters](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#the-bot-adapter) provide an abstraction for your bot to work with a variety of environments. - -A bot is directed by it's adapter, which can be thought of as the conductor for your bot. The adapter is responsible for directing incoming and outgoing communication, authentication, and so on. The adapter differs based on it's environment (the adapter internally works differently locally versus on Azure) but in each instance it achieves the same goal. - -In most situations we don't work with the adapter directly, such as when creating a bot from a template, but it's good to know it's there and what it does. -The bot adapter encapsulates authentication processes and sends activities to and receives activities from the Bot Connector Service. When your bot receives an activity, the adapter wraps up everything about that activity, creates a [context object](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#turn-context), passes it to your bot's application logic, and sends responses generated by your bot back to the user's channel. - - -# Further reading - -- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) -- [Bot basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) -- [Channels and Bot Connector service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) -- [Activity processing](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py deleted file mode 100644 index 223c72f3d..000000000 --- a/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Package information.""" -import os - -__title__ = "functionaltestbot" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py deleted file mode 100644 index 071a17d2b..000000000 --- a/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from types import MethodType - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - MessageFactory, - TurnContext, -) -from botbuilder.schema import Activity, InputHints -from bot import MyBot - -# 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. -# pylint: disable=unused-argument -async def on_error(self, 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) - - -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) - -# Create the main dialog -BOT = MyBot() - -# Listen for incoming requests on GET / for Azure monitoring -@APP.route("/", methods=["GET"]) -def ping(): - return Response(status=200) - - -# 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 "" - ) - - async def aux_func(turn_context): - await BOT.on_turn(turn_context) - - try: - task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, aux_func) - ) - 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/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py deleted file mode 100644 index 128f47cf6..000000000 --- a/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt deleted file mode 100644 index 2e5ecf3fc..000000000 --- a/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -botbuilder-core>=4.5.0.b4 -flask>=1.0.3 - diff --git a/libraries/functional-tests/functionaltestbot/init.sh b/libraries/functional-tests/functionaltestbot/init.sh deleted file mode 100644 index 4a5a5be78..000000000 --- a/libraries/functional-tests/functionaltestbot/init.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -e - -echo "Starting SSH ..." -service ssh start - -# flask run --port 3978 --host 0.0.0.0 -python /functionaltestbot/app.py --host 0.0.0.0 \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index a348b59af..38ad6c528 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -1,5 +1,7 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -botbuilder-core>=4.5.0.b4 -flask==1.1.1 +click==6.7 +Flask==1.0.2 +itsdangerous==0.24 +Jinja2==2.10 +MarkupSafe==1.0 +Werkzeug==0.14.1 +botbuilder-core>=4.4.0b1 \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/runserver.py b/libraries/functional-tests/functionaltestbot/runserver.py deleted file mode 100644 index 9b0e449a7..000000000 --- a/libraries/functional-tests/functionaltestbot/runserver.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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/functionaltestbot/scripts/deploy_webapp.sh b/libraries/functional-tests/functionaltestbot/scripts/deploy_webapp.sh new file mode 100644 index 000000000..c1e7efd4d --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/scripts/deploy_webapp.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# This Script provisions and deploys a Python bot with retries. + +# Make errors stop the script (set -e) +set -e +# Uncomment to debug: print commands (set -x) +#set -x + +# Define Environment Variables +WEBAPP_NAME="pyfuntest" +WEBAPP_URL= +BOT_NAME="pyfuntest" +BOT_ID="python_functional" +AZURE_RESOURCE_GROUP= +BOT_APPID= +BOT_PASSWORD= + +usage() +{ + echo "${0##*/} [options]" + echo "" + echo "Runs Python DirectLine bot test." + echo "- Deletes and recreates given Azure Resource Group (cleanup)" + echo "- Provision and deploy python bot" + echo "- Provision DirectLine support for bot" + echo "- Run python directline client against deployed bot using DirectLine" + echo " as a test." + echo "" + echo "Note: Assumes you are logged into Azure." + echo "" + echo "options" + echo " -a, --appid Bot App ID" + echo " -p, --password Bot App Password" + echo " -g, --resource-group Azure Resource Group name" + exit 1; +} + +print_help_and_exit() +{ + echo "Run '${0##*/} --help' for more information." + exit 1 +} + +process_args() +{ + if [ "${PWD##*/}" != 'functionaltestbot' ]; then + echo "ERROR: Must run from '/functional-tests/functionaltestbot' directory." + echo "Your current directory: ${PWD##*/}" + echo "" + echo "For example:" + echo "$ ./scripts/deploy_webapp.sh --appid X --password Y -g Z" + exit 1 + fi + + save_next_arg=0 + for arg in "$@" + do + if [[ ${save_next_arg} -eq 1 ]]; then + BOT_APPID="$arg" + save_next_arg=0 + elif [[ ${save_next_arg} -eq 2 ]]; then + BOT_PASSWORD="$arg" + save_next_arg=0 + elif [[ ${save_next_arg} -eq 3 ]]; then + AZURE_RESOURCE_GROUP="$arg" + save_next_arg=0 + else + case "$arg" in + "-h" | "--help" ) usage;; + "-a" | "--appid" ) save_next_arg=1;; + "-p" | "--password" ) save_next_arg=2;; + "-g" | "--resource-group" ) save_next_arg=3;; + * ) usage;; + esac + fi + done + if [[ -z ${BOT_APPID} ]]; then + echo "Bot appid parameter invalid" + print_help_and_exit + fi + if [[ -z ${BOT_PASSWORD} ]]; then + echo "Bot password parameter invalid" + print_help_and_exit + fi + if [[ -z ${AZURE_RESOURCE_GROUP} ]]; then + echo "Azure Resource Group parameter invalid" + print_help_and_exit + fi +} + +############################################################################### +# Main Script Execution +############################################################################### +process_args "$@" + +# Recreate Resource Group + +# It's ok to fail (set +e) - script continues on error result code. +set +e +az group delete --name ${AZURE_RESOURCE_GROUP} -y + +n=0 +until [ $n -ge 3 ] +do + az group create --location westus --name ${AZURE_RESOURCE_GROUP} && break + n=$[$n+1] + sleep 25 +done +if [[ $n -ge 3 ]]; then + echo "Could not create group ${AZURE_RESOURCE_GROUP}" + exit 3 +fi + +# Push Web App +n=0 +until [ $n -ge 3 ] +do + az webapp up --sku F1 -n ${WEBAPP_NAME} -l westus --resource-group ${AZURE_RESOURCE_GROUP} && break + n=$[$n+1] + sleep 25 +done +if [[ $n -ge 3 ]]; then + echo "Could not create webapp ${WEBAPP_NAME}" + exit 4 +fi + + +n=0 +until [ $n -ge 3 ] +do + az bot create --appid ${BOT_APPID} --name ${BOT_NAME} --password ${BOT_PASSWORD} --resource-group ${AZURE_RESOURCE_GROUP} --sku F0 --kind registration --location westus --endpoint "https://${WEBAPP_NAME}.azurewebsites.net/api/messages" && break + n=$[$n+1] + sleep 25 +done +if [[ $n -ge 3 ]]; then + echo "Could not create BOT ${BOT_NAME}" + exit 5 +fi + + +# Create bot settings +n=0 +until [ $n -ge 3 ] +do + az webapp config appsettings set -g ${AZURE_RESOURCE_GROUP} -n ${AZURE_RESOURCE_GROUP} --settings MicrosoftAppId=${BOT_APPID} MicrosoftAppPassword=${BOT_PASSWORD} botId=${BOT_ID} && break + n=$[$n+1] + sleep 25 +done +if [[ $n -ge 3 ]]; then + echo "Could not create BOT configuration" + exit 6 +fi + +# Create DirectLine +cd tests +n=0 +until [ $n -ge 3 ] +do + az bot directline create --name ${BOT_NAME} --resource-group ${AZURE_RESOURCE_GROUP} > "DirectLineConfig.json" && break + n=$[$n+1] + sleep 25 +done +if [[ $n -ge 3 ]]; then + echo "Could not create Directline configuration" + exit 7 +fi + + +# Run Tests +pip install requests +n=0 +until [ $n -ge 3 ] +do + python -m unittest test_py_bot.py && break + n=$[$n+1] + sleep 25 +done +if [[ $n -ge 3 ]]; then + echo "Tests failed!" + exit 8 +fi + + diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py deleted file mode 100644 index 1378ac4b0..000000000 --- a/libraries/functional-tests/functionaltestbot/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -from setuptools import setup - -REQUIRES = [ - "botbuilder-core>=4.5.0.b4", - "flask==1.1.1", -] - -root = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(root, "functionaltestbot", "about.py")) as f: - package_info = {} - info = f.read() - exec(info, package_info) - -setup( - name=package_info["__title__"], - version=package_info["__version__"], - url=package_info["__uri__"], - author=package_info["__author__"], - description=package_info["__description__"], - keywords="botframework azure botbuilder", - long_description=package_info["__summary__"], - license=package_info["__license__"], - packages=["functionaltestbot"], - install_requires=REQUIRES, - dependency_links=["https://github.com/pytorch/pytorch"], - include_package_data=True, - classifiers=[ - "Programming Language :: Python :: 3.6", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - ], -) diff --git a/libraries/functional-tests/functionaltestbot/sshd_config b/libraries/functional-tests/functionaltestbot/sshd_config deleted file mode 100644 index 7afb7469f..000000000 --- a/libraries/functional-tests/functionaltestbot/sshd_config +++ /dev/null @@ -1,21 +0,0 @@ -# -# /etc/ssh/sshd_config -# - -Port 2222 -ListenAddress 0.0.0.0 -LoginGraceTime 180 -X11Forwarding yes -Ciphers aes128-cbc,3des-cbc,aes256-cbc -MACs hmac-sha1,hmac-sha1-96 -StrictModes yes -SyslogFacility DAEMON -PrintMotd no -IgnoreRhosts no -#deprecated option -#RhostsAuthentication no -RhostsRSAAuthentication yes -RSAAuthentication no -PasswordAuthentication yes -PermitEmptyPasswords no -PermitRootLogin yes \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/template/linux/template.json b/libraries/functional-tests/functionaltestbot/template/linux/template.json deleted file mode 100644 index dcf832eb2..000000000 --- a/libraries/functional-tests/functionaltestbot/template/linux/template.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "botName": { - "defaultValue": "nightly-build-python-linux", - "type": "string", - "minLength": 2 - }, - "sku": { - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "type": "object" - }, - "linuxFxVersion": { - "type": "string", - "defaultValue": "PYTHON|3.6" - }, - "location": { - "type": "string", - "defaultValue": "West US", - "metadata": { - "description": "Location for all resources." - } - }, - "appId": { - "defaultValue": "1234", - "type": "string" - }, - "appSecret": { - "defaultValue": "blank", - "type": "string" - } - }, - "variables": { - "siteHost": "[concat(parameters('botName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/mybot')]" - }, - "resources": [ - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2017-08-01", - "name": "[parameters('botName')]", - "kind": "linux", - "location": "[parameters('location')]", - "sku": "[parameters('sku')]", - "properties": { - "name": "[parameters('botName')]", - "reserved": true, - "perSiteScaling": false, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[parameters('botName')]", - "location": "[parameters('location')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', parameters('botName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('botName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('botName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('botName'))]", - "siteConfig": { - "linuxFxVersion": "[parameters('linuxFxVersion')]", - "appSettings": [ - { - "name": "WEBSITE_NODE_DEFAULT_VERSION", - "value": "10.14.1" - }, - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - } - ] - }, - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": true, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(parameters('botName'), '/web')]", - "location": "West US", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', parameters('botName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "[parameters('linuxFxVersion')]", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "httpLoggingEnabled": false, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "parameters('botName')", - "scmType": "LocalGit", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": true, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": true, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "siteAuthEnabled": false, - "siteAuthSettings": { - "enabled": null, - "unauthenticatedClientAction": null, - "tokenStoreEnabled": null, - "allowedExternalRedirectUrls": null, - "defaultProvider": null, - "clientId": null, - "clientSecret": null, - "clientSecretCertificateThumbprint": null, - "issuer": null, - "allowedAudiences": null, - "additionalLoginParams": null, - "isAadAutoProvisioned": false, - "googleClientId": null, - "googleClientSecret": null, - "googleOAuthScopes": null, - "facebookAppId": null, - "facebookAppSecret": null, - "facebookOAuthScopes": null, - "twitterConsumerKey": null, - "twitterConsumerSecret": null, - "microsoftAccountClientId": null, - "microsoftAccountClientSecret": null, - "microsoftAccountOAuthScopes": null - }, - "localMySqlEnabled": false, - "http20Enabled": true, - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botName')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botName')]" - }, - "properties": { - "name": "[parameters('botName')]", - "displayName": "[parameters('botName')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', parameters('botName'))]" - ] - }, - { - "type": "Microsoft.Web/sites/hostNameBindings", - "apiVersion": "2016-08-01", - "name": "[concat(parameters('botName'), '/', parameters('botName'), '.azurewebsites.net')]", - "location": "West US", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', parameters('botName'))]" - ], - "properties": { - "siteName": "parameters('botName')", - "hostNameType": "Verified" - } - } - ] -} \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/test.sh b/libraries/functional-tests/functionaltestbot/test.sh deleted file mode 100644 index 1c987232e..000000000 --- a/libraries/functional-tests/functionaltestbot/test.sh +++ /dev/null @@ -1 +0,0 @@ -curl -X POST --header 'Accept: application/json' -d '{"text": "Hi!"}' http://localhost:3979 diff --git a/libraries/functional-tests/tests/direct_line_client.py b/libraries/functional-tests/functionaltestbot/tests/direct_line_client.py similarity index 100% rename from libraries/functional-tests/tests/direct_line_client.py rename to libraries/functional-tests/functionaltestbot/tests/direct_line_client.py diff --git a/libraries/functional-tests/functionaltestbot/tests/test_py_bot.py b/libraries/functional-tests/functionaltestbot/tests/test_py_bot.py new file mode 100644 index 000000000..3a78aca08 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/tests/test_py_bot.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Unit test for testing DirectLine + +To execute: + python -m unittest test_py_bot.py + +This assumes a DirectLine configuration json file is available (DirectLineConfig.json) +that was generated when adding DirectLine to the bot's channel. + + az bot directline create --name "pyfuntest" --resource-group "pyfuntest" > "DirectLineConfig.json" + +""" + + +import os +import json +from unittest import TestCase + +from direct_line_client import DirectLineClient + + +class PyBotTest(TestCase): + def setUp(self): + direct_line_config = os.environ.get( + "DIRECT_LINE_CONFIG", "DirectLineConfig.json" + ) + with open(direct_line_config) as direct_line_file: + self.direct_line_config = json.load(direct_line_file) + self.direct_line_secret = self.direct_line_config["properties"]["properties"][ + "sites" + ][0]["key"] + self.assertIsNotNone(self.direct_line_secret) + + def test_deployed_bot_answer(self): + client = DirectLineClient(self.direct_line_secret) + user_message = "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"Echo: {user_message}", text) + print("SUCCESS!") diff --git a/libraries/functional-tests/tests/test_py_bot.py b/libraries/functional-tests/tests/test_py_bot.py deleted file mode 100644 index bdea7fd6c..000000000 --- a/libraries/functional-tests/tests/test_py_bot.py +++ /dev/null @@ -1,26 +0,0 @@ -# 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) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..dd4ed50bd --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[tool:pytest] +norecursedirs = functionaltestbot From 4c36826b2ba465aae8cd16032b746603c99aa62f Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 09:55:24 -0800 Subject: [PATCH 108/616] return None if object to serialize is None --- .../botbuilder-core/botbuilder/core/teams/teams_helper.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index 8772c6e04..f9e8c65e8 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -28,6 +28,9 @@ def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> M def serializer_helper(object_to_serialize: Model) -> dict: + if object_to_serialize is None: + return None + dependencies = [ schema_cls for key, schema_cls in getmembers(schema) From 744cd389e0b70b83fd2f9a25c9c15a8f9c3332c4 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Fri, 13 Dec 2019 09:56:39 -0800 Subject: [PATCH 109/616] removing testing method --- .../botbuilder/core/teams/teams_activity_extensions.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py index d47ab76e0..14b7546ea 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -1,11 +1,6 @@ from botbuilder.schema import Activity from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo - -def dummy(): - return 1 - - def teams_get_channel_id(activity: Activity) -> str: if not activity: return None From e94e06d260c7e694dc24b660445bdfe4e4331bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 13 Dec 2019 10:07:24 -0800 Subject: [PATCH 110/616] Revert "Functional Test (#439)" (#505) This reverts commit 68b7e21d946d39aef73979c163b749341f1f8269. --- .coveragerc | 3 +- azure-pipelines.yml | 67 +++-- .../functionaltestbot/Dockerfile | 48 ++++ .../functionaltestbot/Dockfile | 27 ++ .../functionaltestbot/README.md | 9 - .../functionaltestbot/application.py | 98 -------- .../functionaltestbot/bots/echo_bot.py | 19 -- .../functionaltestbot/client_driver/README.md | 5 + .../{bots => flask_bot_app}/__init__.py | 4 +- .../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/README.md | 35 +++ .../functionaltestbot/about.py | 14 ++ .../functionaltestbot/app.py | 86 +++++++ .../functionaltestbot/bot.py | 19 ++ .../{ => functionaltestbot}/config.py | 2 +- .../functionaltestbot/requirements.txt | 3 + .../functionaltestbot/init.sh | 8 + .../functionaltestbot/requirements.txt | 12 +- .../functionaltestbot/runserver.py | 16 ++ .../scripts/deploy_webapp.sh | 186 -------------- .../functionaltestbot/setup.py | 40 +++ .../functionaltestbot/sshd_config | 21 ++ .../template/linux/template.json | 238 ++++++++++++++++++ .../functionaltestbot/test.sh | 1 + .../functionaltestbot/tests/test_py_bot.py | 48 ---- .../tests/direct_line_client.py | 0 .../functional-tests/tests/test_py_bot.py | 26 ++ setup.cfg | 2 - 31 files changed, 804 insertions(+), 393 deletions(-) create mode 100644 libraries/functional-tests/functionaltestbot/Dockerfile create mode 100644 libraries/functional-tests/functionaltestbot/Dockfile delete mode 100644 libraries/functional-tests/functionaltestbot/README.md delete mode 100644 libraries/functional-tests/functionaltestbot/application.py delete mode 100644 libraries/functional-tests/functionaltestbot/bots/echo_bot.py create mode 100644 libraries/functional-tests/functionaltestbot/client_driver/README.md rename libraries/functional-tests/functionaltestbot/{bots => flask_bot_app}/__init__.py (64%) 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/functionaltestbot/README.md create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/about.py create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/app.py create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py rename libraries/functional-tests/functionaltestbot/{ => functionaltestbot}/config.py (94%) create mode 100644 libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt create mode 100644 libraries/functional-tests/functionaltestbot/init.sh create mode 100644 libraries/functional-tests/functionaltestbot/runserver.py delete mode 100644 libraries/functional-tests/functionaltestbot/scripts/deploy_webapp.sh create mode 100644 libraries/functional-tests/functionaltestbot/setup.py create mode 100644 libraries/functional-tests/functionaltestbot/sshd_config create mode 100644 libraries/functional-tests/functionaltestbot/template/linux/template.json create mode 100644 libraries/functional-tests/functionaltestbot/test.sh delete mode 100644 libraries/functional-tests/functionaltestbot/tests/test_py_bot.py rename libraries/functional-tests/{functionaltestbot => }/tests/direct_line_client.py (100%) create mode 100644 libraries/functional-tests/tests/test_py_bot.py delete mode 100644 setup.cfg diff --git a/.coveragerc b/.coveragerc index 304e0a883..4dd59303b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,5 +3,4 @@ source = ./libraries/ omit = */tests/* setup.py - */botbuilder-schema/* - */functional-tests/* + */botbuilder-schema/* \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index f2eb246e9..c424c7f01 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,32 +1,61 @@ -schedules: -- cron: "0 0 * * *" - displayName: Daily midnight build +trigger: branches: include: + - daveta-python-functional + exclude: - master variables: - resourceGroupName: 'pyfuntest' + # Container registry service connection established during pipeline creation + dockerRegistryServiceConnection: 'NightlyE2E-Acr' + azureRmServiceConnection: 'NightlyE2E-RM' + dockerFilePath: 'libraries/functional-tests/functionaltestbot/Dockerfile' + buildIdTag: $(Build.BuildNumber) + webAppName: 'e2epython' + containerRegistry: 'nightlye2etest.azurecr.io' + imageRepository: 'functionaltestpy' + + + jobs: -- job: Doit +# Build and publish container +- job: Build pool: vmImage: 'Ubuntu-16.04' - + displayName: Build and push bot image + continueOnError: false steps: - - task: UsePythonVersion@0 - displayName: Use Python 3.6 + - task: Docker@2 + displayName: Build and push bot image inputs: - versionSpec: '3.6' + command: buildAndPush + repository: $(imageRepository) + dockerfile: $(dockerFilePath) + containerRegistry: $(dockerRegistryServiceConnection) + tags: $(buildIdTag) + + - - task: AzureCLI@2 - displayName: Provision, Deploy and run tests +- job: Deploy + displayName: Provision bot container + pool: + vmImage: 'Ubuntu-16.04' + dependsOn: + - Build + steps: + - task: AzureRMWebAppDeployment@4 + displayName: Python Functional E2E test. inputs: - azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' - scriptType: 'bash' - scriptLocation: 'inlineScript' - inlineScript: | - cd $(Build.SourcesDirectory)/libraries/functional-tests/functionaltestbot - chmod +x ./scripts/deploy_webapp.sh - ./scripts/deploy_webapp.sh --appid $(botAppId) --password $(botAppPassword) -g $(resourceGroupName) - continueOnError: false + ConnectionType: AzureRM + ConnectedServiceName: $(azureRmServiceConnection) + appType: webAppContainer + WebAppName: $(webAppName) + DockerNamespace: $(containerRegistry) + DockerRepository: $(imageRepository) + DockerImageTag: $(buildIdTag) + AppSettings: '-MicrosoftAppId $(botAppId) -MicrosoftAppPassword $(botAppPassword) -FLASK_APP /functionaltestbot/app.py -FLASK_DEBUG 1' + + #StartupCommand: 'flask run --host=0.0.0.0 --port=3978' + + diff --git a/libraries/functional-tests/functionaltestbot/Dockerfile b/libraries/functional-tests/functionaltestbot/Dockerfile new file mode 100644 index 000000000..3364fc380 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/Dockerfile @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +FROM tiangolo/uwsgi-nginx-flask:python3.6 + + +RUN mkdir /functionaltestbot + +EXPOSE 443 +# EXPOSE 2222 + +COPY ./functionaltestbot /functionaltestbot +COPY setup.py / +COPY test.sh / +# RUN ls -ltr +# RUN cat prestart.sh +# RUN cat main.py + +ENV FLASK_APP=/functionaltestbot/app.py +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PATH ${PATH}:/home/site/wwwroot + +WORKDIR / + +# Initialize the bot +RUN pip3 install -e . + +# ssh +ENV SSH_PASSWD "root:Docker!" +RUN apt-get update \ + && apt-get install -y --no-install-recommends dialog \ + && apt-get update \ + && apt-get install -y --no-install-recommends openssh-server \ + && echo "$SSH_PASSWD" | chpasswd \ + && apt install -y --no-install-recommends vim +COPY sshd_config /etc/ssh/ +COPY init.sh /usr/local/bin/ +RUN chmod u+x /usr/local/bin/init.sh + +# For Debugging, uncomment the following: +# ENTRYPOINT ["python3.6", "-c", "import time ; time.sleep(500000)"] +ENTRYPOINT ["init.sh"] + +# For Devops, they don't like entry points. This is now in the devops +# pipeline. +# ENTRYPOINT [ "flask" ] +# CMD [ "run", "--port", "3978", "--host", "0.0.0.0" ] 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/README.md b/libraries/functional-tests/functionaltestbot/README.md deleted file mode 100644 index f6d8e670f..000000000 --- a/libraries/functional-tests/functionaltestbot/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Functional Test Bot -This bot is the "Echo" bot which perform E2E functional test. -- Cleans up -- Deploys the python echo bot to Azure -- Creates an Azure Bot and associates with the deployed python bot. -- Creates a DirectLine channel and associates with the newly created bot. -- Runs a client test, using the DirectLine channel and and verifies response. - -This is modeled in a Devops. \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/application.py b/libraries/functional-tests/functionaltestbot/application.py deleted file mode 100644 index cf8de8edc..000000000 --- a/libraries/functional-tests/functionaltestbot/application.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -import os -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - BotFrameworkAdapter, - TurnContext, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import EchoBot - -# Create the loop and Flask app -LOOP = asyncio.get_event_loop() -# pylint: disable=invalid-name -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( - os.environ.get("MicrosoftAppId", ""), os.environ.get("MicrosoftAppPassword", "") -) -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 = EchoBot() - -# 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 - - -@app.route("/", methods=["GET"]) -def ping(): - return "Hello World!" - - -if __name__ == "__main__": - try: - app.run(debug=False, port=3978) # nosec debug - except Exception as exception: - raise exception diff --git a/libraries/functional-tests/functionaltestbot/bots/echo_bot.py b/libraries/functional-tests/functionaltestbot/bots/echo_bot.py deleted file mode 100644 index 90a094640..000000000 --- a/libraries/functional-tests/functionaltestbot/bots/echo_bot.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount - - -class EchoBot(ActivityHandler): - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity("Hello and welcome!") - - async def on_message_activity(self, turn_context: TurnContext): - return await turn_context.send_activity( - MessageFactory.text(f"Echo: {turn_context.activity.text}") - ) diff --git a/libraries/functional-tests/functionaltestbot/client_driver/README.md b/libraries/functional-tests/functionaltestbot/client_driver/README.md new file mode 100644 index 000000000..317a457c9 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/client_driver/README.md @@ -0,0 +1,5 @@ +# Client Driver for Function E2E test + +This contains the client code that drives the bot functional test. + +It performs simple operations against the bot and validates results. \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/bots/__init__.py b/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py similarity index 64% rename from libraries/functional-tests/functionaltestbot/bots/__init__.py rename to libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py index f95fbbbad..d5d099805 100644 --- a/libraries/functional-tests/functionaltestbot/bots/__init__.py +++ b/libraries/functional-tests/functionaltestbot/flask_bot_app/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .echo_bot import EchoBot +from .app import APP -__all__ = ["EchoBot"] +__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..5fb109576 --- /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 flask import Flask, Response, request + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MessageFactory, + TurnContext, +) +from botbuilder.schema import Activity, InputHints + +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/functionaltestbot/README.md b/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md new file mode 100644 index 000000000..996e0909b --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/README.md @@ -0,0 +1,35 @@ +# Console EchoBot +Bot Framework v4 console echo sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that you can talk to from the console window. + +This sample shows a simple echo bot and demonstrates the bot working as a console app using a sample console adapter. + +## To try this sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` + + +### Visual studio code +- open `botbuilder-python\samples\01.console-echo` folder +- Bring up a terminal, navigate to `botbuilder-python\samples\01.console-echo` folder +- type 'python main.py' + + +# Adapters +[Adapters](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#the-bot-adapter) provide an abstraction for your bot to work with a variety of environments. + +A bot is directed by it's adapter, which can be thought of as the conductor for your bot. The adapter is responsible for directing incoming and outgoing communication, authentication, and so on. The adapter differs based on it's environment (the adapter internally works differently locally versus on Azure) but in each instance it achieves the same goal. + +In most situations we don't work with the adapter directly, such as when creating a bot from a template, but it's good to know it's there and what it does. +The bot adapter encapsulates authentication processes and sends activities to and receives activities from the Bot Connector Service. When your bot receives an activity, the adapter wraps up everything about that activity, creates a [context object](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#turn-context), passes it to your bot's application logic, and sends responses generated by your bot back to the user's channel. + + +# Further reading + +- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Bot basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) +- [Channels and Bot Connector service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) +- [Activity processing](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py new file mode 100644 index 000000000..223c72f3d --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/about.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Package information.""" +import os + +__title__ = "functionaltestbot" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py new file mode 100644 index 000000000..071a17d2b --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/app.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import sys +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MessageFactory, + TurnContext, +) +from botbuilder.schema import Activity, InputHints +from bot import MyBot + +# 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. +# pylint: disable=unused-argument +async def on_error(self, 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) + + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +# Create the main dialog +BOT = MyBot() + +# Listen for incoming requests on GET / for Azure monitoring +@APP.route("/", methods=["GET"]) +def ping(): + return Response(status=200) + + +# 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 "" + ) + + async def aux_func(turn_context): + await BOT.on_turn(turn_context) + + try: + task = LOOP.create_task( + ADAPTER.process_activity(activity, auth_header, aux_func) + ) + 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/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/bot.py new file mode 100644 index 000000000..128f47cf6 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/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/config.py b/libraries/functional-tests/functionaltestbot/functionaltestbot/config.py similarity index 94% rename from libraries/functional-tests/functionaltestbot/config.py rename to libraries/functional-tests/functionaltestbot/functionaltestbot/config.py index 6b5116fba..a3bd72174 100644 --- a/libraries/functional-tests/functionaltestbot/config.py +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/config.py @@ -8,6 +8,6 @@ class DefaultConfig: """ Bot Configuration """ - PORT = 3978 + PORT = 443 APP_ID = os.environ.get("MicrosoftAppId", "") APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") diff --git a/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt new file mode 100644 index 000000000..2e5ecf3fc --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/functionaltestbot/requirements.txt @@ -0,0 +1,3 @@ +botbuilder-core>=4.5.0.b4 +flask>=1.0.3 + diff --git a/libraries/functional-tests/functionaltestbot/init.sh b/libraries/functional-tests/functionaltestbot/init.sh new file mode 100644 index 000000000..4a5a5be78 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/init.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Starting SSH ..." +service ssh start + +# flask run --port 3978 --host 0.0.0.0 +python /functionaltestbot/app.py --host 0.0.0.0 \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index 38ad6c528..a348b59af 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -1,7 +1,5 @@ -click==6.7 -Flask==1.0.2 -itsdangerous==0.24 -Jinja2==2.10 -MarkupSafe==1.0 -Werkzeug==0.14.1 -botbuilder-core>=4.4.0b1 \ No newline at end of file +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +botbuilder-core>=4.5.0.b4 +flask==1.1.1 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/functionaltestbot/scripts/deploy_webapp.sh b/libraries/functional-tests/functionaltestbot/scripts/deploy_webapp.sh deleted file mode 100644 index c1e7efd4d..000000000 --- a/libraries/functional-tests/functionaltestbot/scripts/deploy_webapp.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/bin/bash -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# This Script provisions and deploys a Python bot with retries. - -# Make errors stop the script (set -e) -set -e -# Uncomment to debug: print commands (set -x) -#set -x - -# Define Environment Variables -WEBAPP_NAME="pyfuntest" -WEBAPP_URL= -BOT_NAME="pyfuntest" -BOT_ID="python_functional" -AZURE_RESOURCE_GROUP= -BOT_APPID= -BOT_PASSWORD= - -usage() -{ - echo "${0##*/} [options]" - echo "" - echo "Runs Python DirectLine bot test." - echo "- Deletes and recreates given Azure Resource Group (cleanup)" - echo "- Provision and deploy python bot" - echo "- Provision DirectLine support for bot" - echo "- Run python directline client against deployed bot using DirectLine" - echo " as a test." - echo "" - echo "Note: Assumes you are logged into Azure." - echo "" - echo "options" - echo " -a, --appid Bot App ID" - echo " -p, --password Bot App Password" - echo " -g, --resource-group Azure Resource Group name" - exit 1; -} - -print_help_and_exit() -{ - echo "Run '${0##*/} --help' for more information." - exit 1 -} - -process_args() -{ - if [ "${PWD##*/}" != 'functionaltestbot' ]; then - echo "ERROR: Must run from '/functional-tests/functionaltestbot' directory." - echo "Your current directory: ${PWD##*/}" - echo "" - echo "For example:" - echo "$ ./scripts/deploy_webapp.sh --appid X --password Y -g Z" - exit 1 - fi - - save_next_arg=0 - for arg in "$@" - do - if [[ ${save_next_arg} -eq 1 ]]; then - BOT_APPID="$arg" - save_next_arg=0 - elif [[ ${save_next_arg} -eq 2 ]]; then - BOT_PASSWORD="$arg" - save_next_arg=0 - elif [[ ${save_next_arg} -eq 3 ]]; then - AZURE_RESOURCE_GROUP="$arg" - save_next_arg=0 - else - case "$arg" in - "-h" | "--help" ) usage;; - "-a" | "--appid" ) save_next_arg=1;; - "-p" | "--password" ) save_next_arg=2;; - "-g" | "--resource-group" ) save_next_arg=3;; - * ) usage;; - esac - fi - done - if [[ -z ${BOT_APPID} ]]; then - echo "Bot appid parameter invalid" - print_help_and_exit - fi - if [[ -z ${BOT_PASSWORD} ]]; then - echo "Bot password parameter invalid" - print_help_and_exit - fi - if [[ -z ${AZURE_RESOURCE_GROUP} ]]; then - echo "Azure Resource Group parameter invalid" - print_help_and_exit - fi -} - -############################################################################### -# Main Script Execution -############################################################################### -process_args "$@" - -# Recreate Resource Group - -# It's ok to fail (set +e) - script continues on error result code. -set +e -az group delete --name ${AZURE_RESOURCE_GROUP} -y - -n=0 -until [ $n -ge 3 ] -do - az group create --location westus --name ${AZURE_RESOURCE_GROUP} && break - n=$[$n+1] - sleep 25 -done -if [[ $n -ge 3 ]]; then - echo "Could not create group ${AZURE_RESOURCE_GROUP}" - exit 3 -fi - -# Push Web App -n=0 -until [ $n -ge 3 ] -do - az webapp up --sku F1 -n ${WEBAPP_NAME} -l westus --resource-group ${AZURE_RESOURCE_GROUP} && break - n=$[$n+1] - sleep 25 -done -if [[ $n -ge 3 ]]; then - echo "Could not create webapp ${WEBAPP_NAME}" - exit 4 -fi - - -n=0 -until [ $n -ge 3 ] -do - az bot create --appid ${BOT_APPID} --name ${BOT_NAME} --password ${BOT_PASSWORD} --resource-group ${AZURE_RESOURCE_GROUP} --sku F0 --kind registration --location westus --endpoint "https://${WEBAPP_NAME}.azurewebsites.net/api/messages" && break - n=$[$n+1] - sleep 25 -done -if [[ $n -ge 3 ]]; then - echo "Could not create BOT ${BOT_NAME}" - exit 5 -fi - - -# Create bot settings -n=0 -until [ $n -ge 3 ] -do - az webapp config appsettings set -g ${AZURE_RESOURCE_GROUP} -n ${AZURE_RESOURCE_GROUP} --settings MicrosoftAppId=${BOT_APPID} MicrosoftAppPassword=${BOT_PASSWORD} botId=${BOT_ID} && break - n=$[$n+1] - sleep 25 -done -if [[ $n -ge 3 ]]; then - echo "Could not create BOT configuration" - exit 6 -fi - -# Create DirectLine -cd tests -n=0 -until [ $n -ge 3 ] -do - az bot directline create --name ${BOT_NAME} --resource-group ${AZURE_RESOURCE_GROUP} > "DirectLineConfig.json" && break - n=$[$n+1] - sleep 25 -done -if [[ $n -ge 3 ]]; then - echo "Could not create Directline configuration" - exit 7 -fi - - -# Run Tests -pip install requests -n=0 -until [ $n -ge 3 ] -do - python -m unittest test_py_bot.py && break - n=$[$n+1] - sleep 25 -done -if [[ $n -ge 3 ]]; then - echo "Tests failed!" - exit 8 -fi - - diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py new file mode 100644 index 000000000..1378ac4b0 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "botbuilder-core>=4.5.0.b4", + "flask==1.1.1", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "functionaltestbot", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords="botframework azure botbuilder", + long_description=package_info["__summary__"], + license=package_info["__license__"], + packages=["functionaltestbot"], + install_requires=REQUIRES, + dependency_links=["https://github.com/pytorch/pytorch"], + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/libraries/functional-tests/functionaltestbot/sshd_config b/libraries/functional-tests/functionaltestbot/sshd_config new file mode 100644 index 000000000..7afb7469f --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/sshd_config @@ -0,0 +1,21 @@ +# +# /etc/ssh/sshd_config +# + +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PrintMotd no +IgnoreRhosts no +#deprecated option +#RhostsAuthentication no +RhostsRSAAuthentication yes +RSAAuthentication no +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/template/linux/template.json b/libraries/functional-tests/functionaltestbot/template/linux/template.json new file mode 100644 index 000000000..dcf832eb2 --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/template/linux/template.json @@ -0,0 +1,238 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "botName": { + "defaultValue": "nightly-build-python-linux", + "type": "string", + "minLength": 2 + }, + "sku": { + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "type": "object" + }, + "linuxFxVersion": { + "type": "string", + "defaultValue": "PYTHON|3.6" + }, + "location": { + "type": "string", + "defaultValue": "West US", + "metadata": { + "description": "Location for all resources." + } + }, + "appId": { + "defaultValue": "1234", + "type": "string" + }, + "appSecret": { + "defaultValue": "blank", + "type": "string" + } + }, + "variables": { + "siteHost": "[concat(parameters('botName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/mybot')]" + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2017-08-01", + "name": "[parameters('botName')]", + "kind": "linux", + "location": "[parameters('location')]", + "sku": "[parameters('sku')]", + "properties": { + "name": "[parameters('botName')]", + "reserved": true, + "perSiteScaling": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[parameters('botName')]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', parameters('botName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('botName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('botName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('botName'))]", + "siteConfig": { + "linuxFxVersion": "[parameters('linuxFxVersion')]", + "appSettings": [ + { + "name": "WEBSITE_NODE_DEFAULT_VERSION", + "value": "10.14.1" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ] + }, + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(parameters('botName'), '/web')]", + "location": "West US", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('botName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "[parameters('linuxFxVersion')]", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "parameters('botName')", + "scmType": "LocalGit", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "appCommandLine": "", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "siteAuthEnabled": false, + "siteAuthSettings": { + "enabled": null, + "unauthenticatedClientAction": null, + "tokenStoreEnabled": null, + "allowedExternalRedirectUrls": null, + "defaultProvider": null, + "clientId": null, + "clientSecret": null, + "clientSecretCertificateThumbprint": null, + "issuer": null, + "allowedAudiences": null, + "additionalLoginParams": null, + "isAadAutoProvisioned": false, + "googleClientId": null, + "googleClientSecret": null, + "googleOAuthScopes": null, + "facebookAppId": null, + "facebookAppSecret": null, + "facebookOAuthScopes": null, + "twitterConsumerKey": null, + "twitterConsumerSecret": null, + "microsoftAccountClientId": null, + "microsoftAccountClientSecret": null, + "microsoftAccountOAuthScopes": null + }, + "localMySqlEnabled": false, + "http20Enabled": true, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botName')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botName')]" + }, + "properties": { + "name": "[parameters('botName')]", + "displayName": "[parameters('botName')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', parameters('botName'))]" + ] + }, + { + "type": "Microsoft.Web/sites/hostNameBindings", + "apiVersion": "2016-08-01", + "name": "[concat(parameters('botName'), '/', parameters('botName'), '.azurewebsites.net')]", + "location": "West US", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('botName'))]" + ], + "properties": { + "siteName": "parameters('botName')", + "hostNameType": "Verified" + } + } + ] +} \ No newline at end of file diff --git a/libraries/functional-tests/functionaltestbot/test.sh b/libraries/functional-tests/functionaltestbot/test.sh new file mode 100644 index 000000000..1c987232e --- /dev/null +++ b/libraries/functional-tests/functionaltestbot/test.sh @@ -0,0 +1 @@ +curl -X POST --header 'Accept: application/json' -d '{"text": "Hi!"}' http://localhost:3979 diff --git a/libraries/functional-tests/functionaltestbot/tests/test_py_bot.py b/libraries/functional-tests/functionaltestbot/tests/test_py_bot.py deleted file mode 100644 index 3a78aca08..000000000 --- a/libraries/functional-tests/functionaltestbot/tests/test_py_bot.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -""" -Unit test for testing DirectLine - -To execute: - python -m unittest test_py_bot.py - -This assumes a DirectLine configuration json file is available (DirectLineConfig.json) -that was generated when adding DirectLine to the bot's channel. - - az bot directline create --name "pyfuntest" --resource-group "pyfuntest" > "DirectLineConfig.json" - -""" - - -import os -import json -from unittest import TestCase - -from direct_line_client import DirectLineClient - - -class PyBotTest(TestCase): - def setUp(self): - direct_line_config = os.environ.get( - "DIRECT_LINE_CONFIG", "DirectLineConfig.json" - ) - with open(direct_line_config) as direct_line_file: - self.direct_line_config = json.load(direct_line_file) - self.direct_line_secret = self.direct_line_config["properties"]["properties"][ - "sites" - ][0]["key"] - self.assertIsNotNone(self.direct_line_secret) - - def test_deployed_bot_answer(self): - client = DirectLineClient(self.direct_line_secret) - user_message = "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"Echo: {user_message}", text) - print("SUCCESS!") diff --git a/libraries/functional-tests/functionaltestbot/tests/direct_line_client.py b/libraries/functional-tests/tests/direct_line_client.py similarity index 100% rename from libraries/functional-tests/functionaltestbot/tests/direct_line_client.py rename to libraries/functional-tests/tests/direct_line_client.py 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) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index dd4ed50bd..000000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[tool:pytest] -norecursedirs = functionaltestbot From 9cd693ddfd55c7ddff4073101c329f080d5435e1 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 13 Dec 2019 12:31:08 -0600 Subject: [PATCH 111/616] Removing samples and generator from Python repo --- generators/LICENSE.md | 21 - generators/README.md | 215 -------- .../app/templates/core/cookiecutter.json | 4 - .../core/{{cookiecutter.bot_name}}/.pylintrc | 498 ------------------ .../{{cookiecutter.bot_name}}/README-LUIS.md | 216 -------- .../core/{{cookiecutter.bot_name}}/README.md | 61 --- .../{{cookiecutter.bot_name}}/__init__.py | 2 - .../core/{{cookiecutter.bot_name}}/app.py | 110 ---- .../booking_details.py | 18 - .../bots/__init__.py | 7 - .../bots/dialog_and_welcome_bot.py | 42 -- .../bots/dialog_bot.py | 42 -- .../cards/welcomeCard.json | 46 -- .../cognitiveModels/FlightBooking.json | 339 ------------ .../core/{{cookiecutter.bot_name}}/config.py | 16 - .../dialogs/__init__.py | 9 - .../dialogs/booking_dialog.py | 137 ----- .../dialogs/cancel_and_help_dialog.py | 44 -- .../dialogs/date_resolver_dialog.py | 79 --- .../dialogs/main_dialog.py | 133 ----- .../flight_booking_recognizer.py | 32 -- .../helpers/__init__.py | 11 - .../helpers/dialog_helper.py | 19 - .../helpers/luis_helper.py | 104 ---- .../requirements.txt | 5 - .../app/templates/echo/cookiecutter.json | 4 - .../echo/{{cookiecutter.bot_name}}/.pylintrc | 497 ----------------- .../echo/{{cookiecutter.bot_name}}/README.md | 43 -- .../{{cookiecutter.bot_name}}/__init__.py | 0 .../echo/{{cookiecutter.bot_name}}/app.py | 86 --- .../echo/{{cookiecutter.bot_name}}/bot.py | 21 - .../echo/{{cookiecutter.bot_name}}/config.py | 12 - .../requirements.txt | 3 - .../app/templates/empty/cookiecutter.json | 4 - .../empty/{{cookiecutter.bot_name}}/.pylintrc | 497 ----------------- .../empty/{{cookiecutter.bot_name}}/README.md | 43 -- .../{{cookiecutter.bot_name}}/__init__.py | 0 .../empty/{{cookiecutter.bot_name}}/app.py | 72 --- .../empty/{{cookiecutter.bot_name}}/bot.py | 16 - .../empty/{{cookiecutter.bot_name}}/config.py | 12 - .../requirements.txt | 3 - samples/01.console-echo/README.md | 35 -- samples/01.console-echo/adapter/__init__.py | 6 - .../adapter/console_adapter.py | 180 ------- samples/01.console-echo/bot.py | 16 - samples/01.console-echo/main.py | 25 - samples/01.console-echo/requirements.txt | 4 - samples/02.echo-bot/README.md | 30 -- samples/02.echo-bot/app.py | 82 --- samples/02.echo-bot/bots/__init__.py | 6 - samples/02.echo-bot/bots/echo_bot.py | 17 - samples/02.echo-bot/config.py | 15 - .../template-with-preexisting-rg.json | 242 --------- samples/02.echo-bot/requirements.txt | 2 - samples/03.welcome-user/README.md | 36 -- samples/03.welcome-user/app.py | 95 ---- samples/03.welcome-user/bots/__init__.py | 6 - .../03.welcome-user/bots/welcome_user_bot.py | 143 ----- samples/03.welcome-user/config.py | 15 - .../03.welcome-user/data_models/__init__.py | 6 - .../data_models/welcome_user_state.py | 7 - samples/03.welcome-user/requirements.txt | 2 - samples/05.multi-turn-prompt/README.md | 50 -- samples/05.multi-turn-prompt/app.py | 106 ---- samples/05.multi-turn-prompt/bots/__init__.py | 6 - .../05.multi-turn-prompt/bots/dialog_bot.py | 51 -- samples/05.multi-turn-prompt/config.py | 15 - .../data_models/__init__.py | 6 - .../data_models/user_profile.py | 13 - .../05.multi-turn-prompt/dialogs/__init__.py | 6 - .../dialogs/user_profile_dialog.py | 167 ------ .../05.multi-turn-prompt/helpers/__init__.py | 6 - .../helpers/dialog_helper.py | 19 - samples/05.multi-turn-prompt/requirements.txt | 4 - samples/06.using-cards/README.md | 50 -- samples/06.using-cards/app.py | 108 ---- samples/06.using-cards/bots/__init__.py | 6 - samples/06.using-cards/bots/dialog_bot.py | 42 -- samples/06.using-cards/bots/rich_cards_bot.py | 28 - samples/06.using-cards/config.py | 15 - samples/06.using-cards/dialogs/__init__.py | 6 - samples/06.using-cards/dialogs/main_dialog.py | 298 ----------- .../dialogs/resources/__init__.py | 6 - .../resources/adaptive_card_example.py | 186 ------- samples/06.using-cards/helpers/__init__.py | 6 - .../06.using-cards/helpers/activity_helper.py | 45 -- .../06.using-cards/helpers/dialog_helper.py | 19 - samples/06.using-cards/requirements.txt | 3 - samples/08.suggested-actions/README.md | 28 - samples/08.suggested-actions/app.py | 90 ---- samples/08.suggested-actions/bots/__init__.py | 6 - .../bots/suggested_actions_bot.py | 81 --- samples/08.suggested-actions/config.py | 15 - samples/08.suggested-actions/requirements.txt | 2 - samples/11.qnamaker/README.md | 56 -- samples/11.qnamaker/app.py | 82 --- samples/11.qnamaker/bots/__init__.py | 6 - samples/11.qnamaker/bots/qna_bot.py | 37 -- .../cognitiveModels/smartLightFAQ.tsv | 15 - samples/11.qnamaker/config.py | 18 - .../template-with-preexisting-rg.json | 242 --------- samples/11.qnamaker/requirements.txt | 3 - samples/13.core-bot/README-LUIS.md | 216 -------- samples/13.core-bot/README.md | 61 --- .../13.core-bot/adapter_with_error_handler.py | 54 -- samples/13.core-bot/app.py | 79 --- samples/13.core-bot/booking_details.py | 18 - samples/13.core-bot/bots/__init__.py | 7 - .../bots/dialog_and_welcome_bot.py | 57 -- samples/13.core-bot/bots/dialog_bot.py | 41 -- samples/13.core-bot/cards/welcomeCard.json | 46 -- .../cognitiveModels/FlightBooking.json | 339 ------------ samples/13.core-bot/config.py | 19 - samples/13.core-bot/dialogs/__init__.py | 9 - samples/13.core-bot/dialogs/booking_dialog.py | 136 ----- .../dialogs/cancel_and_help_dialog.py | 47 -- .../dialogs/date_resolver_dialog.py | 80 --- samples/13.core-bot/dialogs/main_dialog.py | 132 ----- .../13.core-bot/flight_booking_recognizer.py | 32 -- samples/13.core-bot/helpers/__init__.py | 6 - .../13.core-bot/helpers/activity_helper.py | 37 -- samples/13.core-bot/helpers/dialog_helper.py | 19 - samples/13.core-bot/helpers/luis_helper.py | 102 ---- samples/13.core-bot/requirements.txt | 5 - samples/15.handling-attachments/README.md | 38 -- samples/15.handling-attachments/app.py | 89 ---- .../15.handling-attachments/bots/__init__.py | 6 - .../bots/attachments_bot.py | 218 -------- samples/15.handling-attachments/config.py | 15 - .../template-with-preexisting-rg.json | 242 --------- .../15.handling-attachments/requirements.txt | 3 - .../resources/architecture-resize.png | Bin 241516 -> 0 bytes samples/16.proactive-messages/README.md | 66 --- samples/16.proactive-messages/app.py | 123 ----- .../16.proactive-messages/bots/__init__.py | 6 - .../bots/proactive_bot.py | 45 -- samples/16.proactive-messages/config.py | 15 - .../16.proactive-messages/requirements.txt | 2 - samples/17.multilingual-bot/README.md | 58 -- samples/17.multilingual-bot/app.py | 105 ---- samples/17.multilingual-bot/bots/__init__.py | 6 - .../bots/multilingual_bot.py | 122 ----- .../cards/welcomeCard.json | 46 -- samples/17.multilingual-bot/config.py | 17 - .../template-with-preexisting-rg.json | 242 --------- samples/17.multilingual-bot/requirements.txt | 3 - .../translation/__init__.py | 7 - .../translation/microsoft_translator.py | 37 -- .../translation/translation_middleware.py | 88 ---- .../translation/translation_settings.py | 12 - samples/18.bot-authentication/README.md | 56 -- samples/18.bot-authentication/app.py | 104 ---- .../18.bot-authentication/bots/__init__.py | 7 - .../18.bot-authentication/bots/auth_bot.py | 44 -- .../18.bot-authentication/bots/dialog_bot.py | 41 -- samples/18.bot-authentication/config.py | 16 - .../template-with-preexisting-rg.json | 242 --------- .../18.bot-authentication/dialogs/__init__.py | 7 - .../dialogs/logout_dialog.py | 29 - .../dialogs/main_dialog.py | 94 ---- .../18.bot-authentication/helpers/__init__.py | 6 - .../helpers/dialog_helper.py | 19 - .../18.bot-authentication/requirements.txt | 2 - samples/19.custom-dialogs/README.md | 48 -- samples/19.custom-dialogs/app.py | 101 ---- samples/19.custom-dialogs/bots/__init__.py | 6 - samples/19.custom-dialogs/bots/dialog_bot.py | 34 -- samples/19.custom-dialogs/config.py | 15 - samples/19.custom-dialogs/dialogs/__init__.py | 7 - .../19.custom-dialogs/dialogs/root_dialog.py | 134 ----- .../19.custom-dialogs/dialogs/slot_details.py | 28 - .../dialogs/slot_filling_dialog.py | 100 ---- samples/19.custom-dialogs/helpers/__init__.py | 6 - .../helpers/dialog_helper.py | 19 - samples/19.custom-dialogs/requirements.txt | 2 - samples/21.corebot-app-insights/NOTICE.md | 8 - .../21.corebot-app-insights/README-LUIS.md | 216 -------- samples/21.corebot-app-insights/README.md | 65 --- samples/21.corebot-app-insights/app.py | 123 ----- .../booking_details.py | 14 - .../21.corebot-app-insights/bots/__init__.py | 8 - .../bots/dialog_and_welcome_bot.py | 63 --- .../bots/dialog_bot.py | 71 --- .../bots/resources/welcomeCard.json | 46 -- .../cognitiveModels/FlightBooking.json | 226 -------- samples/21.corebot-app-insights/config.py | 21 - .../dialogs/__init__.py | 9 - .../dialogs/booking_dialog.py | 132 ----- .../dialogs/cancel_and_help_dialog.py | 55 -- .../dialogs/date_resolver_dialog.py | 91 ---- .../dialogs/main_dialog.py | 115 ---- .../helpers/__init__.py | 7 - .../helpers/activity_helper.py | 38 -- .../helpers/dialog_helper.py | 22 - .../helpers/luis_helper.py | 71 --- .../21.corebot-app-insights/requirements.txt | 13 - samples/23.facebook-events/README.md | 36 -- samples/23.facebook-events/app.py | 89 ---- samples/23.facebook-events/bots/__init__.py | 6 - .../23.facebook-events/bots/facebook_bot.py | 129 ----- samples/23.facebook-events/config.py | 15 - .../template-with-preexisting-rg.json | 242 --------- samples/23.facebook-events/requirements.txt | 3 - samples/40.timex-resolution/README.md | 51 -- samples/40.timex-resolution/ambiguity.py | 78 --- samples/40.timex-resolution/constraints.py | 31 -- .../language_generation.py | 33 -- samples/40.timex-resolution/main.py | 23 - samples/40.timex-resolution/parsing.py | 45 -- samples/40.timex-resolution/ranges.py | 51 -- samples/40.timex-resolution/requirements.txt | 3 - samples/40.timex-resolution/resolution.py | 26 - samples/42.scaleout/README.md | 36 -- samples/42.scaleout/app.py | 96 ---- samples/42.scaleout/bots/__init__.py | 6 - samples/42.scaleout/bots/scaleout_bot.py | 45 -- samples/42.scaleout/config.py | 18 - .../template-with-preexisting-rg.json | 242 --------- samples/42.scaleout/dialogs/__init__.py | 6 - samples/42.scaleout/dialogs/root_dialog.py | 56 -- samples/42.scaleout/helpers/__init__.py | 6 - samples/42.scaleout/helpers/dialog_helper.py | 19 - samples/42.scaleout/host/__init__.py | 7 - samples/42.scaleout/host/dialog_host.py | 72 --- .../42.scaleout/host/dialog_host_adapter.py | 32 -- samples/42.scaleout/requirements.txt | 4 - samples/42.scaleout/store/__init__.py | 9 - samples/42.scaleout/store/blob_store.py | 51 -- samples/42.scaleout/store/memory_store.py | 29 - samples/42.scaleout/store/ref_accessor.py | 37 -- samples/42.scaleout/store/store.py | 32 -- samples/43.complex-dialog/README.md | 30 -- samples/43.complex-dialog/app.py | 106 ---- samples/43.complex-dialog/bots/__init__.py | 7 - .../bots/dialog_and_welcome_bot.py | 39 -- samples/43.complex-dialog/bots/dialog_bot.py | 41 -- samples/43.complex-dialog/config.py | 15 - .../43.complex-dialog/data_models/__init__.py | 6 - .../data_models/user_profile.py | 13 - samples/43.complex-dialog/dialogs/__init__.py | 8 - .../43.complex-dialog/dialogs/main_dialog.py | 50 -- .../dialogs/review_selection_dialog.py | 99 ---- .../dialogs/top_level_dialog.py | 95 ---- samples/43.complex-dialog/helpers/__init__.py | 6 - .../helpers/dialog_helper.py | 19 - samples/43.complex-dialog/requirements.txt | 2 - samples/44.prompt-users-for-input/README.md | 37 -- samples/44.prompt-users-for-input/app.py | 103 ---- .../bots/__init__.py | 6 - .../bots/custom_prompt_bot.py | 189 ------- samples/44.prompt-users-for-input/config.py | 15 - .../data_models/__init__.py | 7 - .../data_models/conversation_flow.py | 18 - .../data_models/user_profile.py | 9 - .../requirements.txt | 3 - samples/45.state-management/README.md | 36 -- samples/45.state-management/app.py | 100 ---- samples/45.state-management/bots/__init__.py | 6 - .../bots/state_management_bot.py | 97 ---- samples/45.state-management/config.py | 15 - .../data_models/__init__.py | 7 - .../data_models/conversation_data.py | 14 - .../data_models/user_profile.py | 7 - samples/45.state-management/requirements.txt | 2 - samples/47.inspection/README.md | 46 -- samples/47.inspection/app.py | 117 ---- samples/47.inspection/bots/__init__.py | 6 - samples/47.inspection/bots/echo_bot.py | 64 --- samples/47.inspection/config.py | 15 - samples/47.inspection/data_models/__init__.py | 6 - .../47.inspection/data_models/custom_state.py | 7 - samples/47.inspection/requirements.txt | 2 - samples/README.md | 14 - .../python_django/13.core-bot/README-LUIS.md | 216 -------- samples/python_django/13.core-bot/README.md | 61 --- .../13.core-bot/booking_details.py | 15 - .../13.core-bot/bots/__init__.py | 8 - .../python_django/13.core-bot/bots/bots.py | 54 -- .../bots/dialog_and_welcome_bot.py | 44 -- .../13.core-bot/bots/dialog_bot.py | 47 -- .../bots/resources/welcomeCard.json | 46 -- .../13.core-bot/bots/settings.py | 118 ----- .../python_django/13.core-bot/bots/urls.py | 15 - .../python_django/13.core-bot/bots/views.py | 53 -- .../python_django/13.core-bot/bots/wsgi.py | 19 - .../cognitiveModels/FlightBooking.json | 226 -------- samples/python_django/13.core-bot/config.py | 19 - samples/python_django/13.core-bot/db.sqlite3 | 0 .../13.core-bot/dialogs/__init__.py | 9 - .../13.core-bot/dialogs/booking_dialog.py | 119 ----- .../dialogs/cancel_and_help_dialog.py | 45 -- .../dialogs/date_resolver_dialog.py | 82 --- .../13.core-bot/dialogs/main_dialog.py | 83 --- .../13.core-bot/helpers/__init__.py | 7 - .../13.core-bot/helpers/activity_helper.py | 38 -- .../13.core-bot/helpers/dialog_helper.py | 22 - .../13.core-bot/helpers/luis_helper.py | 63 --- samples/python_django/13.core-bot/manage.py | 28 - .../13.core-bot/requirements.txt | 9 - 299 files changed, 16769 deletions(-) delete mode 100644 generators/LICENSE.md delete mode 100644 generators/README.md delete mode 100644 generators/app/templates/core/cookiecutter.json delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/README.md delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/app.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/config.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py delete mode 100644 generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt delete mode 100644 generators/app/templates/echo/cookiecutter.json delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/README.md delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/__init__.py delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/bot.py delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/config.py delete mode 100644 generators/app/templates/echo/{{cookiecutter.bot_name}}/requirements.txt delete mode 100644 generators/app/templates/empty/cookiecutter.json delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/README.md delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/__init__.py delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/bot.py delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/config.py delete mode 100644 generators/app/templates/empty/{{cookiecutter.bot_name}}/requirements.txt delete mode 100644 samples/01.console-echo/README.md delete mode 100644 samples/01.console-echo/adapter/__init__.py delete mode 100644 samples/01.console-echo/adapter/console_adapter.py delete mode 100644 samples/01.console-echo/bot.py delete mode 100644 samples/01.console-echo/main.py delete mode 100644 samples/01.console-echo/requirements.txt delete mode 100644 samples/02.echo-bot/README.md delete mode 100644 samples/02.echo-bot/app.py delete mode 100644 samples/02.echo-bot/bots/__init__.py delete mode 100644 samples/02.echo-bot/bots/echo_bot.py delete mode 100644 samples/02.echo-bot/config.py delete mode 100644 samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/02.echo-bot/requirements.txt delete mode 100644 samples/03.welcome-user/README.md delete mode 100644 samples/03.welcome-user/app.py delete mode 100644 samples/03.welcome-user/bots/__init__.py delete mode 100644 samples/03.welcome-user/bots/welcome_user_bot.py delete mode 100644 samples/03.welcome-user/config.py delete mode 100644 samples/03.welcome-user/data_models/__init__.py delete mode 100644 samples/03.welcome-user/data_models/welcome_user_state.py delete mode 100644 samples/03.welcome-user/requirements.txt delete mode 100644 samples/05.multi-turn-prompt/README.md delete mode 100644 samples/05.multi-turn-prompt/app.py delete mode 100644 samples/05.multi-turn-prompt/bots/__init__.py delete mode 100644 samples/05.multi-turn-prompt/bots/dialog_bot.py delete mode 100644 samples/05.multi-turn-prompt/config.py delete mode 100644 samples/05.multi-turn-prompt/data_models/__init__.py delete mode 100644 samples/05.multi-turn-prompt/data_models/user_profile.py delete mode 100644 samples/05.multi-turn-prompt/dialogs/__init__.py delete mode 100644 samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py delete mode 100644 samples/05.multi-turn-prompt/helpers/__init__.py delete mode 100644 samples/05.multi-turn-prompt/helpers/dialog_helper.py delete mode 100644 samples/05.multi-turn-prompt/requirements.txt delete mode 100644 samples/06.using-cards/README.md delete mode 100644 samples/06.using-cards/app.py delete mode 100644 samples/06.using-cards/bots/__init__.py delete mode 100644 samples/06.using-cards/bots/dialog_bot.py delete mode 100644 samples/06.using-cards/bots/rich_cards_bot.py delete mode 100644 samples/06.using-cards/config.py delete mode 100644 samples/06.using-cards/dialogs/__init__.py delete mode 100644 samples/06.using-cards/dialogs/main_dialog.py delete mode 100644 samples/06.using-cards/dialogs/resources/__init__.py delete mode 100644 samples/06.using-cards/dialogs/resources/adaptive_card_example.py delete mode 100644 samples/06.using-cards/helpers/__init__.py delete mode 100644 samples/06.using-cards/helpers/activity_helper.py delete mode 100644 samples/06.using-cards/helpers/dialog_helper.py delete mode 100644 samples/06.using-cards/requirements.txt delete mode 100644 samples/08.suggested-actions/README.md delete mode 100644 samples/08.suggested-actions/app.py delete mode 100644 samples/08.suggested-actions/bots/__init__.py delete mode 100644 samples/08.suggested-actions/bots/suggested_actions_bot.py delete mode 100644 samples/08.suggested-actions/config.py delete mode 100644 samples/08.suggested-actions/requirements.txt delete mode 100644 samples/11.qnamaker/README.md delete mode 100644 samples/11.qnamaker/app.py delete mode 100644 samples/11.qnamaker/bots/__init__.py delete mode 100644 samples/11.qnamaker/bots/qna_bot.py delete mode 100644 samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv delete mode 100644 samples/11.qnamaker/config.py delete mode 100644 samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/11.qnamaker/requirements.txt delete mode 100644 samples/13.core-bot/README-LUIS.md delete mode 100644 samples/13.core-bot/README.md delete mode 100644 samples/13.core-bot/adapter_with_error_handler.py delete mode 100644 samples/13.core-bot/app.py delete mode 100644 samples/13.core-bot/booking_details.py delete mode 100644 samples/13.core-bot/bots/__init__.py delete mode 100644 samples/13.core-bot/bots/dialog_and_welcome_bot.py delete mode 100644 samples/13.core-bot/bots/dialog_bot.py delete mode 100644 samples/13.core-bot/cards/welcomeCard.json delete mode 100644 samples/13.core-bot/cognitiveModels/FlightBooking.json delete mode 100644 samples/13.core-bot/config.py delete mode 100644 samples/13.core-bot/dialogs/__init__.py delete mode 100644 samples/13.core-bot/dialogs/booking_dialog.py delete mode 100644 samples/13.core-bot/dialogs/cancel_and_help_dialog.py delete mode 100644 samples/13.core-bot/dialogs/date_resolver_dialog.py delete mode 100644 samples/13.core-bot/dialogs/main_dialog.py delete mode 100644 samples/13.core-bot/flight_booking_recognizer.py delete mode 100644 samples/13.core-bot/helpers/__init__.py delete mode 100644 samples/13.core-bot/helpers/activity_helper.py delete mode 100644 samples/13.core-bot/helpers/dialog_helper.py delete mode 100644 samples/13.core-bot/helpers/luis_helper.py delete mode 100644 samples/13.core-bot/requirements.txt delete mode 100644 samples/15.handling-attachments/README.md delete mode 100644 samples/15.handling-attachments/app.py delete mode 100644 samples/15.handling-attachments/bots/__init__.py delete mode 100644 samples/15.handling-attachments/bots/attachments_bot.py delete mode 100644 samples/15.handling-attachments/config.py delete mode 100644 samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/15.handling-attachments/requirements.txt delete mode 100644 samples/15.handling-attachments/resources/architecture-resize.png delete mode 100644 samples/16.proactive-messages/README.md delete mode 100644 samples/16.proactive-messages/app.py delete mode 100644 samples/16.proactive-messages/bots/__init__.py delete mode 100644 samples/16.proactive-messages/bots/proactive_bot.py delete mode 100644 samples/16.proactive-messages/config.py delete mode 100644 samples/16.proactive-messages/requirements.txt delete mode 100644 samples/17.multilingual-bot/README.md delete mode 100644 samples/17.multilingual-bot/app.py delete mode 100644 samples/17.multilingual-bot/bots/__init__.py delete mode 100644 samples/17.multilingual-bot/bots/multilingual_bot.py delete mode 100644 samples/17.multilingual-bot/cards/welcomeCard.json delete mode 100644 samples/17.multilingual-bot/config.py delete mode 100644 samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/17.multilingual-bot/requirements.txt delete mode 100644 samples/17.multilingual-bot/translation/__init__.py delete mode 100644 samples/17.multilingual-bot/translation/microsoft_translator.py delete mode 100644 samples/17.multilingual-bot/translation/translation_middleware.py delete mode 100644 samples/17.multilingual-bot/translation/translation_settings.py delete mode 100644 samples/18.bot-authentication/README.md delete mode 100644 samples/18.bot-authentication/app.py delete mode 100644 samples/18.bot-authentication/bots/__init__.py delete mode 100644 samples/18.bot-authentication/bots/auth_bot.py delete mode 100644 samples/18.bot-authentication/bots/dialog_bot.py delete mode 100644 samples/18.bot-authentication/config.py delete mode 100644 samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/18.bot-authentication/dialogs/__init__.py delete mode 100644 samples/18.bot-authentication/dialogs/logout_dialog.py delete mode 100644 samples/18.bot-authentication/dialogs/main_dialog.py delete mode 100644 samples/18.bot-authentication/helpers/__init__.py delete mode 100644 samples/18.bot-authentication/helpers/dialog_helper.py delete mode 100644 samples/18.bot-authentication/requirements.txt delete mode 100644 samples/19.custom-dialogs/README.md delete mode 100644 samples/19.custom-dialogs/app.py delete mode 100644 samples/19.custom-dialogs/bots/__init__.py delete mode 100644 samples/19.custom-dialogs/bots/dialog_bot.py delete mode 100644 samples/19.custom-dialogs/config.py delete mode 100644 samples/19.custom-dialogs/dialogs/__init__.py delete mode 100644 samples/19.custom-dialogs/dialogs/root_dialog.py delete mode 100644 samples/19.custom-dialogs/dialogs/slot_details.py delete mode 100644 samples/19.custom-dialogs/dialogs/slot_filling_dialog.py delete mode 100644 samples/19.custom-dialogs/helpers/__init__.py delete mode 100644 samples/19.custom-dialogs/helpers/dialog_helper.py delete mode 100644 samples/19.custom-dialogs/requirements.txt delete mode 100644 samples/21.corebot-app-insights/NOTICE.md delete mode 100644 samples/21.corebot-app-insights/README-LUIS.md delete mode 100644 samples/21.corebot-app-insights/README.md delete mode 100644 samples/21.corebot-app-insights/app.py delete mode 100644 samples/21.corebot-app-insights/booking_details.py delete mode 100644 samples/21.corebot-app-insights/bots/__init__.py delete mode 100644 samples/21.corebot-app-insights/bots/dialog_and_welcome_bot.py delete mode 100644 samples/21.corebot-app-insights/bots/dialog_bot.py delete mode 100644 samples/21.corebot-app-insights/bots/resources/welcomeCard.json delete mode 100644 samples/21.corebot-app-insights/cognitiveModels/FlightBooking.json delete mode 100644 samples/21.corebot-app-insights/config.py delete mode 100644 samples/21.corebot-app-insights/dialogs/__init__.py delete mode 100644 samples/21.corebot-app-insights/dialogs/booking_dialog.py delete mode 100644 samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py delete mode 100644 samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py delete mode 100644 samples/21.corebot-app-insights/dialogs/main_dialog.py delete mode 100644 samples/21.corebot-app-insights/helpers/__init__.py delete mode 100644 samples/21.corebot-app-insights/helpers/activity_helper.py delete mode 100644 samples/21.corebot-app-insights/helpers/dialog_helper.py delete mode 100644 samples/21.corebot-app-insights/helpers/luis_helper.py delete mode 100644 samples/21.corebot-app-insights/requirements.txt delete mode 100644 samples/23.facebook-events/README.md delete mode 100644 samples/23.facebook-events/app.py delete mode 100644 samples/23.facebook-events/bots/__init__.py delete mode 100644 samples/23.facebook-events/bots/facebook_bot.py delete mode 100644 samples/23.facebook-events/config.py delete mode 100644 samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/23.facebook-events/requirements.txt delete mode 100644 samples/40.timex-resolution/README.md delete mode 100644 samples/40.timex-resolution/ambiguity.py delete mode 100644 samples/40.timex-resolution/constraints.py delete mode 100644 samples/40.timex-resolution/language_generation.py delete mode 100644 samples/40.timex-resolution/main.py delete mode 100644 samples/40.timex-resolution/parsing.py delete mode 100644 samples/40.timex-resolution/ranges.py delete mode 100644 samples/40.timex-resolution/requirements.txt delete mode 100644 samples/40.timex-resolution/resolution.py delete mode 100644 samples/42.scaleout/README.md delete mode 100644 samples/42.scaleout/app.py delete mode 100644 samples/42.scaleout/bots/__init__.py delete mode 100644 samples/42.scaleout/bots/scaleout_bot.py delete mode 100644 samples/42.scaleout/config.py delete mode 100644 samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json delete mode 100644 samples/42.scaleout/dialogs/__init__.py delete mode 100644 samples/42.scaleout/dialogs/root_dialog.py delete mode 100644 samples/42.scaleout/helpers/__init__.py delete mode 100644 samples/42.scaleout/helpers/dialog_helper.py delete mode 100644 samples/42.scaleout/host/__init__.py delete mode 100644 samples/42.scaleout/host/dialog_host.py delete mode 100644 samples/42.scaleout/host/dialog_host_adapter.py delete mode 100644 samples/42.scaleout/requirements.txt delete mode 100644 samples/42.scaleout/store/__init__.py delete mode 100644 samples/42.scaleout/store/blob_store.py delete mode 100644 samples/42.scaleout/store/memory_store.py delete mode 100644 samples/42.scaleout/store/ref_accessor.py delete mode 100644 samples/42.scaleout/store/store.py delete mode 100644 samples/43.complex-dialog/README.md delete mode 100644 samples/43.complex-dialog/app.py delete mode 100644 samples/43.complex-dialog/bots/__init__.py delete mode 100644 samples/43.complex-dialog/bots/dialog_and_welcome_bot.py delete mode 100644 samples/43.complex-dialog/bots/dialog_bot.py delete mode 100644 samples/43.complex-dialog/config.py delete mode 100644 samples/43.complex-dialog/data_models/__init__.py delete mode 100644 samples/43.complex-dialog/data_models/user_profile.py delete mode 100644 samples/43.complex-dialog/dialogs/__init__.py delete mode 100644 samples/43.complex-dialog/dialogs/main_dialog.py delete mode 100644 samples/43.complex-dialog/dialogs/review_selection_dialog.py delete mode 100644 samples/43.complex-dialog/dialogs/top_level_dialog.py delete mode 100644 samples/43.complex-dialog/helpers/__init__.py delete mode 100644 samples/43.complex-dialog/helpers/dialog_helper.py delete mode 100644 samples/43.complex-dialog/requirements.txt delete mode 100644 samples/44.prompt-users-for-input/README.md delete mode 100644 samples/44.prompt-users-for-input/app.py delete mode 100644 samples/44.prompt-users-for-input/bots/__init__.py delete mode 100644 samples/44.prompt-users-for-input/bots/custom_prompt_bot.py delete mode 100644 samples/44.prompt-users-for-input/config.py delete mode 100644 samples/44.prompt-users-for-input/data_models/__init__.py delete mode 100644 samples/44.prompt-users-for-input/data_models/conversation_flow.py delete mode 100644 samples/44.prompt-users-for-input/data_models/user_profile.py delete mode 100644 samples/44.prompt-users-for-input/requirements.txt delete mode 100644 samples/45.state-management/README.md delete mode 100644 samples/45.state-management/app.py delete mode 100644 samples/45.state-management/bots/__init__.py delete mode 100644 samples/45.state-management/bots/state_management_bot.py delete mode 100644 samples/45.state-management/config.py delete mode 100644 samples/45.state-management/data_models/__init__.py delete mode 100644 samples/45.state-management/data_models/conversation_data.py delete mode 100644 samples/45.state-management/data_models/user_profile.py delete mode 100644 samples/45.state-management/requirements.txt delete mode 100644 samples/47.inspection/README.md delete mode 100644 samples/47.inspection/app.py delete mode 100644 samples/47.inspection/bots/__init__.py delete mode 100644 samples/47.inspection/bots/echo_bot.py delete mode 100644 samples/47.inspection/config.py delete mode 100644 samples/47.inspection/data_models/__init__.py delete mode 100644 samples/47.inspection/data_models/custom_state.py delete mode 100644 samples/47.inspection/requirements.txt delete mode 100644 samples/README.md delete mode 100644 samples/python_django/13.core-bot/README-LUIS.md delete mode 100644 samples/python_django/13.core-bot/README.md delete mode 100644 samples/python_django/13.core-bot/booking_details.py delete mode 100644 samples/python_django/13.core-bot/bots/__init__.py delete mode 100644 samples/python_django/13.core-bot/bots/bots.py delete mode 100644 samples/python_django/13.core-bot/bots/dialog_and_welcome_bot.py delete mode 100644 samples/python_django/13.core-bot/bots/dialog_bot.py delete mode 100644 samples/python_django/13.core-bot/bots/resources/welcomeCard.json delete mode 100644 samples/python_django/13.core-bot/bots/settings.py delete mode 100644 samples/python_django/13.core-bot/bots/urls.py delete mode 100644 samples/python_django/13.core-bot/bots/views.py delete mode 100644 samples/python_django/13.core-bot/bots/wsgi.py delete mode 100644 samples/python_django/13.core-bot/cognitiveModels/FlightBooking.json delete mode 100644 samples/python_django/13.core-bot/config.py delete mode 100644 samples/python_django/13.core-bot/db.sqlite3 delete mode 100644 samples/python_django/13.core-bot/dialogs/__init__.py delete mode 100644 samples/python_django/13.core-bot/dialogs/booking_dialog.py delete mode 100644 samples/python_django/13.core-bot/dialogs/cancel_and_help_dialog.py delete mode 100644 samples/python_django/13.core-bot/dialogs/date_resolver_dialog.py delete mode 100644 samples/python_django/13.core-bot/dialogs/main_dialog.py delete mode 100644 samples/python_django/13.core-bot/helpers/__init__.py delete mode 100644 samples/python_django/13.core-bot/helpers/activity_helper.py delete mode 100644 samples/python_django/13.core-bot/helpers/dialog_helper.py delete mode 100644 samples/python_django/13.core-bot/helpers/luis_helper.py delete mode 100644 samples/python_django/13.core-bot/manage.py delete mode 100644 samples/python_django/13.core-bot/requirements.txt diff --git a/generators/LICENSE.md b/generators/LICENSE.md deleted file mode 100644 index 506ab97e5..000000000 --- a/generators/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2018 Microsoft Corporation - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/generators/README.md b/generators/README.md deleted file mode 100644 index 761d8ee79..000000000 --- a/generators/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# python-generator-botbuilder - -Cookiecutter generators for [Bot Framework v4](https://dev.botframework.com). Will let you quickly set up a conversational AI bot -using core AI capabilities. - -## About - -`python-generator-botbuilder` will help you build new conversational AI bots using the [Bot Framework v4](https://dev.botframework.com). - -## Templates - -The generator supports three different template options. The table below can help guide which template is right for you. - -| Template | Description | -| ---------- | --------- | -| Echo Bot | A good template if you want a little more than "Hello World!", but not much more. This template handles the very basics of sending messages to a bot, and having the bot process the messages by repeating them back to the user. This template produces a bot that simply "echoes" back to the user anything the user says to the bot. | -| Core Bot | Our most advanced template, the Core template provides 6 core features every bot is likely to have. This template covers the core features of a Conversational-AI bot using [LUIS](https://www.luis.ai). See the **Core Bot Features** table below for more details. | -| Empty Bot | A good template if you are familiar with Bot Framework v4, and simply want a basic skeleton project. Also a good option if you want to take sample code from the documentation and paste it into a minimal bot in order to learn. | - -### How to Choose a Template - -| Template | When This Template is a Good Choice | -| -------- | -------- | -| Echo Bot | You are new to Bot Framework v4 and want a working bot with minimal features. | -| Core Bot | You understand some of the core concepts of Bot Framework v4 and are beyond the concepts introduced in the Echo Bot template. You're familiar with or are ready to learn concepts such as language understanding using LUIS, managing multi-turn conversations with Dialogs, handling user initiated Dialog interruptions, and using Adaptive Cards to welcome your users. | -| Empty Bot | You are a seasoned Bot Framework v4 developer. You've built bots before, and want the minimum skeleton of a bot. | - -### Template Overview - -#### Echo Bot Template - -The Echo Bot template is slightly more than the a classic "Hello World!" example, but not by much. This template shows the basic structure of a bot, how a bot recieves messages from a user, and how a bot sends messages to a user. The bot will "echo" back to the user, what the user says to the bot. It is a good choice for first time, new to Bot Framework v4 developers. - -#### Core Bot Template - -The Core Bot template consists of set of core features most every bot is likely to have. Building off of the core message processing features found in the Echo Bot template, this template adds a number of more sophisticated features. The table below lists these features and provides links to additional documentation. - -| Core Bot Features | Description | -| ------------------ | ----------- | -| [Send and receive messages](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-send-messages?view=azure-bot-service-4.0) | The primary way your bot will communicate with users, and likewise receive communication, is through message activities. Some messages may simply consist of plain text, while others may contain richer content such as cards or attachments. | -| [Proactive messaging](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-proactive-message?view=azure-bot-service-4.0) using [Adaptive Cards](https://docs.microsoft.com/azure/bot-service/bot-builder-send-welcome-message?view=azure-bot-service-4.0?#using-adaptive-card-greeting) | The primary goal when creating any bot is to engage your user in a meaningful conversation. One of the best ways to achieve this goal is to ensure that from the moment a user first connects to your bot, they understand your bot’s main purpose and capabilities. We refer to this as "welcoming the user." The Core template uses an [Adaptive Card](http://adaptivecards.io) to implement this behavior. | -| [Language understanding using LUIS](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0) | The ability to understand what your user means conversationally and contextually can be a difficult task, but can provide your bot a more natural conversation feel. Language Understanding, called LUIS, enables you to do just that so that your bot can recognize the intent of user messages, allow for more natural language from your user, and better direct the conversation flow. | -| [Multi-turn conversation support using Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) | The ability to manage conversations is an important part of the bot/user interation. Bot Framework introduces the concept of a Dialog to handle this conversational pattern. Dialog objects process inbound Activities and generate outbound responses. The business logic of the bot runs either directly or indirectly within Dialog classes. | -| [Managing conversation state](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-state?view=azure-bot-service-4.0) | A key to good bot design is to track the context of a conversation, so that your bot remembers things like the answers to previous questions. | -| [How to handle user-initiated interruptions](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-handle-user-interrupt?view=azure-bot-service-4.0) | While you may think that your users will follow your defined conversation flow step by step, chances are good that they will change their minds or ask a question in the middle of the process instead of answering the question. Handling interruptions means making sure your bot is prepared to handle situations like this. | -| [How to unit test a bot](https://aka.ms/cs-unit-test-docs) | Optionally, the Core Bot template can generate corresponding unit tests that shows how to use the testing framework introduced in Bot Framework version 4.5. Selecting this option provides a complete set of units tests for Core Bot. It shows how to write unit tests to test the various features of Core Bot. To add the Core Bot unit tests, run the generator and answer `yes` when prompted. See below for an example of how to do this from the command line. | - -#### Empty Bot Template - -The Empty Bot template is the minimal skeleton code for a bot. It provides a stub `on_turn` handler but does not perform any actions. If you are experienced writing bots with Bot Framework v4 and want the minimum scaffolding, the Empty template is for you. - -## Features by Template - -| Feature | Empty Bot | Echo Bot | Core Bot* | -| --------- | :-----: | :-----: | :-----: | -| Generate code in Python | X | X | X | -| Support local development and testing using the [Bot Framework Emulator v4](https://www.github.com/microsoft/botframework-emulator) | X | X | X | -| Core bot message processing | | X | X | -| Deploy your bot to Microsoft Azure | | Pending | Pending | -| Welcome new users using Adaptive Card technology | | | X | -| Support AI-based greetings using [LUIS](https://www.luis.ai) | | | X | -| Use Dialogs to manage more in-depth conversations | | | X | -| Manage conversation state | | | X | -| Handle user interruptions | | | X | -| Unit test a bot using Bot Framework Testing framework (optional) | | | X | - -*Core Bot template is a work in progress landing soon. -## Installation - -1. Install [cookiecutter](https://github.com/cookiecutter/cookiecutter) using [pip](https://pip.pypa.io/en/stable/) (we assume you have pre-installed [python 3](https://www.python.org/downloads/)). - - ```bash - pip install cookiecutter - ``` - -2. Verify that cookiecutter has been installed correctly by typing the following into your console: - - ```bash - cookiecutter --help - ``` - - -## Usage - -### Creating a New Bot Project - -To create an Echo Bot project: - -```bash -cookiecutter https://github.com/microsoft/botbuilder-python/releases/download/Templates/echo.zip -``` - -To create a Core Bot project: - -```bash -# Work in progress -``` - -To create an Empty Bot project: - -```bash -cookiecutter https://github.com/microsoft/botbuilder-python/releases/download/Templates/empty.zip -``` - -When the generator is launched, it will prompt for the information required to create a new bot. - -### Generator Command Line Options and Arguments - -Cookiecutter supports a set of pre-defined command line options, the complete list with descriptions is available [here](https://cookiecutter.readthedocs.io/en/0.9.1/advanced_usage.html#command-line-options). - -Each generator can recieve a series of named arguments to pre-seed the prompt default value. If the `--no-input` option flag is send, these named arguments will be the default values for the template. - -| Named argument | Description | -| ------------------- | ----------- | -| project_name | The name given to the bot project | -| bot_description | A brief bit of text that describes the purpose of the bot | -| add_tests | **PENDING** _A Core Bot Template Only Feature_. The generator will add unit tests to the Core Bot generated bot. This option is not available to other templates at this time. To learn more about the test framework released with Bot Framework v4.5, see [How to unit test bots](https://aka.ms/js-unit-test-docs). This option is intended to enable automated bot generation for testing purposes. | - -#### Example Using Named Arguments - -This example shows how to pass named arguments to the generator, setting the default bot name to test_project. - -```bash -# Run the generator defaulting the bot name to test_project -cookiecutter https://github.com/microsoft/botbuilder-python/releases/download/Templates/echo.zip project_name="test_project" -``` - -### Generating a Bot Using --no-input - -The generator can be run in `--no-input` mode, which can be used for automated bot creation. When run in `--no-input` mode, the generator can be configured using named arguments as documented above. If a named argument is ommitted a reasonable default will be used. - -#### Default Values - -| Named argument | Default Value | -| ------------------- | ----------- | -| bot_name | `my-chat-bot` | -| bot_description | "Demonstrate the core capabilities of the Microsoft Bot Framework" | -| add_tests | `False`| - -#### Examples Using --no-input - -This example shows how to run the generator in --no-input mode, setting all required options on the command line. - -```bash -# Run the generator, setting all command line options -cookiecutter https://github.com/microsoft/botbuilder-python/releases/download/Templates/echo.zip --no-input project_name="test_bot" bot_description="Test description" -``` - -This example shows how to run the generator in --no-input mode, using all the default command line options. The generator will create a bot project using all the default values specified in the **Default Options** table above. - -```bash -# Run the generator using all default options -cookiecutter https://github.com/microsoft/botbuilder-python/releases/download/Templates/echo.zip --no-input -``` - -This example shows how to run the generator in --no-input mode, with unit tests. - -```bash -# PENDING: Run the generator using all default options -``` - -## Running Your Bot - -### Running Your Bot Locally - -To run your bot locally, type the following in your console: - -```bash -# install dependencies -pip install -r requirements.txt -``` - -```bash -# run the bot -python app.py -``` - -Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - -### Interacting With Your Bot Using the Emulator - -- Launch Bot Framework Emulator -- File -> Open Bot -- Enter a Bot URL of `http://localhost:3978/api/messages` - -Once the Emulator is connected, you can interact with and receive messages from your bot. - -#### Lint Compliant Code - -The code generated by the botbuilder generator is pylint compliant to our ruleset. To use pylint as your develop your bot: - -```bash -# Assuming you created a project with the bot_name value 'my_chat_bot' -pylint --rcfile=my_chat_bot/.pylintrc my_chat_bot -``` - -#### Testing Core Bots with Tests (Pending) - -Core Bot templates generated with unit tests can be tested using the following: - -```bash -# launch pytest -pytest -``` - -## Deploy Your Bot to Azure (PENDING) - -After creating the bot and testing it locally, you can deploy it to Azure to make it accessible from anywhere. -To learn how, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) for a complete set of deployment instructions. - -If you are new to Microsoft Azure, please refer to [Getting started with Azure](https://azure.microsoft.com/get-started/) for guidance on how to get started on Azure. - -## Logging Issues and Providing Feedback - -Issues and feedback about the botbuilder generator can be submitted through the project's [GitHub Issues](https://github.com/Microsoft/botbuilder-samples/issues) page. diff --git a/generators/app/templates/core/cookiecutter.json b/generators/app/templates/core/cookiecutter.json deleted file mode 100644 index 4a14b6ade..000000000 --- a/generators/app/templates/core/cookiecutter.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "bot_name": "my_chat_bot", - "bot_description": "Demonstrate the core capabilities of the Microsoft Bot Framework" -} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc deleted file mode 100644 index 9c1c70f04..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc +++ /dev/null @@ -1,498 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore= - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=missing-docstring, - too-few-public-methods, - bad-continuation, - no-self-use, - duplicate-code, - broad-except, - no-name-in-module - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=120 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[STRING] - -# This flag controls whether the implicit-str-concat-in-sequence should -# generate a warning on implicit string concatenation in sequences defined over -# several lines. -check-str-concat-over-line-jumps=no - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md b/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md deleted file mode 100644 index b6b9b925f..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md +++ /dev/null @@ -1,216 +0,0 @@ -# Setting up LUIS via CLI: - -This README contains information on how to create and deploy a LUIS application. When the bot is ready to be deployed to production, we recommend creating a LUIS Endpoint Resource for usage with your LUIS App. - -> _For instructions on how to create a LUIS Application via the LUIS portal, see these Quickstart steps:_ -> 1. _[Quickstart: Create a new app in the LUIS portal][Quickstart-create]_ -> 2. _[Quickstart: Deploy an app in the LUIS portal][Quickstart-deploy]_ - - [Quickstart-create]: https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-build-app - [Quickstart-deploy]:https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-deploy-app - -## Table of Contents: - -- [Prerequisites](#Prerequisites) -- [Import a new LUIS Application using a local LUIS application](#Import-a-new-LUIS-Application-using-a-local-LUIS-application) -- [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#How-to-create-a-LUIS-Endpoint-resource-in-Azure-and-pair-it-with-a-LUIS-Application) - -___ - -## [Prerequisites](#Table-of-Contents): - -#### Install Azure CLI >=2.0.61: - -Visit the following page to find the correct installer for your OS: -- https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest - -#### Install LUIS CLI >=2.4.0: - -Open a CLI of your choice and type the following: - -```bash -npm i -g luis-apis@^2.4.0 -``` - -#### LUIS portal account: - -You should already have a LUIS account with either https://luis.ai, https://eu.luis.ai, or https://au.luis.ai. To determine where to create a LUIS account, consider where you will deploy your LUIS applications, and then place them in [the corresponding region][LUIS-Authoring-Regions]. - -After you've created your account, you need your [Authoring Key][LUIS-AKey] and a LUIS application ID. - - [LUIS-Authoring-Regions]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-reference-regions#luis-authoring-regions] - [LUIS-AKey]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-concept-keys#authoring-key - -___ - -## [Import a new LUIS Application using a local LUIS application](#Table-of-Contents) - -### 1. Import the local LUIS application to luis.ai - -```bash -luis import application --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appName "FlightBooking" --in "./cognitiveModels/FlightBooking.json" -``` - -Outputs the following JSON: - -```json -{ - "id": "########-####-####-####-############", - "name": "FlightBooking", - "description": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "usageScenario": "", - "domain": "", - "versionsCount": 1, - "createdDateTime": "2019-03-29T18:32:02Z", - "endpoints": {}, - "endpointHitsCount": 0, - "activeVersion": "0.1", - "ownerEmail": "bot@contoso.com", - "tokenizerVersion": "1.0.0" -} -``` - -For the next step, you'll need the `"id"` value for `--appId` and the `"activeVersion"` value for `--versionId`. - -### 2. Train the LUIS Application - -```bash -luis train version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --wait -``` - -### 3. Publish the LUIS Application - -```bash -luis publish version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --publishRegion "LuisAppPublishRegion" -``` - -> `--region` corresponds to the region you _author_ your application in. The regions available for this are "westus", "westeurope" and "australiaeast".
-> These regions correspond to the three available portals, https://luis.ai, https://eu.luis.ai, or https://au.luis.ai.
-> `--publishRegion` corresponds to the region of the endpoint you're publishing to, (e.g. "westus", "southeastasia", "westeurope", "brazilsouth").
-> See the [reference docs][Endpoint-API] for a list of available publish/endpoint regions. - - [Endpoint-API]: https://westus.dev.cognitive.microsoft.com/docs/services/5819c76f40a6350ce09de1ac/operations/5819c77140a63516d81aee78 - -Outputs the following: - -```json - { - "versionId": "0.1", - "isStaging": false, - "endpointUrl": "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/########-####-####-####-############", - "region": "westus", - "assignedEndpointKey": null, - "endpointRegion": "westus", - "failedRegions": "", - "publishedDateTime": "2019-03-29T18:40:32Z", - "directVersionPublish": false -} -``` - -To see how to create an LUIS Cognitive Service Resource in Azure, please see [the next README][README-LUIS]. This Resource should be used when you want to move your bot to production. The instructions will show you how to create and pair the resource with a LUIS Application. - - [README-LUIS]: ./README-LUIS.md - -___ - -## [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#Table-of-Contents) - -### 1. Create a new LUIS Cognitive Services resource on Azure via Azure CLI - -> _Note:_
-> _If you don't have a Resource Group in your Azure subscription, you can create one through the Azure portal or through using:_ -> ```bash -> az group create --subscription "AzureSubscriptionGuid" --location "westus" --name "ResourceGroupName" -> ``` -> _To see a list of valid locations, use `az account list-locations`_ - - -```bash -# Use Azure CLI to create the LUIS Key resource on Azure -az cognitiveservices account create --kind "luis" --name "NewLuisResourceName" --sku "S0" --location "westus" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -The command will output a response similar to the JSON below: - -```json -{ - "endpoint": "https://westus.api.cognitive.microsoft.com/luis/v2.0", - "etag": "\"########-####-####-####-############\"", - "id": "/subscriptions/########-####-####-####-############/resourceGroups/ResourceGroupName/providers/Microsoft.CognitiveServices/accounts/NewLuisResourceName", - "internalId": "################################", - "kind": "luis", - "location": "westus", - "name": "NewLuisResourceName", - "provisioningState": "Succeeded", - "resourceGroup": "ResourceGroupName", - "sku": { - "name": "S0", - "tier": null - }, - "tags": null, - "type": "Microsoft.CognitiveServices/accounts" -} -``` - - - -Take the output from the previous command and create a JSON file in the following format: - -```json -{ - "azureSubscriptionId": "00000000-0000-0000-0000-000000000000", - "resourceGroup": "ResourceGroupName", - "accountName": "NewLuisResourceName" -} -``` - -### 2. Retrieve ARM access token via Azure CLI - -```bash -az account get-access-token --subscription "AzureSubscriptionGuid" -``` - -This will return an object that looks like this: - -```json -{ - "accessToken": "eyJ0eXAiOiJKVtokentokentokentokentokeng1dCI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyIsItokenI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTUzODc3MTUwLCJuYmYiOjE1NTM4NzcxNTAsImV4cCI6MTU1Mzg4MTA1MCwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2ZmZTQyM2RkLWJhM2YtNDg0Ny04NjgyLWExNTI5MDA4MjM4Ny9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVZRQXEvOEtBQUFBeGVUc201NDlhVHg4RE1mMFlRVnhGZmxxOE9RSC9PODR3QktuSmRqV1FqTkkwbmxLYzB0bHJEZzMyMFZ5bWZGaVVBSFBvNUFFUTNHL0FZNDRjdk01T3M0SEt0OVJkcE5JZW9WU0dzd0kvSkk9IiwiYW1yIjpbIndpYSIsIm1mYSJdLCJhcHBpZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiNDhmNDVjNjEtMTg3Zi00MjUxLTlmZWItMTllZGFkZmMwMmE3IiwiZmFtaWx5X25hbWUiOiJHdW0iLCJnaXZlbl9uYW1lIjoiU3RldmVuIiwiaXBhZGRyIjoiMTY3LjIyMC4yLjU1IiwibmFtZSI6IlN0ZXZlbiBHdW0iLCJvaWQiOiJmZmU0MjNkZC1iYTNmLTQ4NDctODY4Mi1hMTUyOTAwODIzODciLCJvbnByZW1fc2lkIjoiUy0xLTUtMjEtMjEyNzUyMTE4NC0xNjA0MDEyOTIwLTE4ODc5Mjc1MjctMjYwOTgyODUiLCJwdWlkIjoiMTAwMzdGRkVBMDQ4NjlBNyIsInJoIjoiSSIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ik1rMGRNMWszN0U5ckJyMjhieUhZYjZLSU85LXVFQVVkZFVhNWpkSUd1Nk0iLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InN0Z3VtQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzdGd1bUBtaWNyb3NvZnQuY29tIiwidXRpIjoiT2w2NGN0TXY4RVNEQzZZQWRqRUFtokenInZlciI6IjEuMCJ9.kFAsEilE0mlS1pcpqxf4rEnRKeYsehyk-gz-zJHUrE__oad3QjgDSBDPrR_ikLdweynxbj86pgG4QFaHURNCeE6SzrbaIrNKw-n9jrEtokenlosOxg_0l2g1LeEUOi5Q4gQREAU_zvSbl-RY6sAadpOgNHtGvz3Rc6FZRITfkckSLmsKAOFoh-aWC6tFKG8P52rtB0qVVRz9tovBeNqkMYL49s9ypduygbXNVwSQhm5JszeWDgrFuVFHBUP_iENCQYGQpEZf_KvjmX1Ur1F9Eh9nb4yI2gFlKncKNsQl-tokenK7-tokentokentokentokentokentokenatoken", - "expiresOn": "2200-12-31 23:59:59.999999", - "subscription": "AzureSubscriptionGuid", - "tenant": "tenant-guid", - "tokenType": "Bearer" -} -``` - -The value needed for the next step is the `"accessToken"`. - -### 3. Use `luis add appazureaccount` to pair your LUIS resource with a LUIS Application - -```bash -luis add appazureaccount --in "path/to/created/requestBody.json" --appId "LuisAppId" --authoringKey "LuisAuthoringKey" --armToken "accessToken" -``` - -If successful, it should yield a response like this: - -```json -{ - "code": "Success", - "message": "Operation Successful" -} -``` - -### 4. See the LUIS Cognitive Services' keys - -```bash -az cognitiveservices account keys list --name "NewLuisResourceName" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -This will return an object that looks like this: - -```json -{ - "key1": "9a69####dc8f####8eb4####399f####", - "key2": "####f99e####4b1a####fb3b####6b9f" -} -``` \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md b/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md deleted file mode 100644 index 35a5eb2f1..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# CoreBot - -Bot Framework v4 core bot sample. - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to: - -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities -- Implement a multi-turn conversation using Dialogs -- Handle user interruptions for such things as `Help` or `Cancel` -- Prompt for and validate requests for information from the user - -## Prerequisites - -This sample **requires** prerequisites in order to run. - -### Overview - -This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding. - -### Install Python 3.6 - - -### Create a LUIS Application to enable language understanding - -LUIS language model setup, training, and application configuration steps can be found [here](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=cs). - -If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). - -## Running the sample -- Run `pip install -r requirements.txt` to install all dependencies -- Update LuisAppId, LuisAPIKey and LuisAPIHostName in `config.py` with the information retrieved from the [LUIS portal](https://www.luis.ai) -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -## 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 -- Enter a Bot URL of `http://localhost:3978/api/messages` - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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) -- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py deleted file mode 100644 index 5b7f7a925..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py deleted file mode 100644 index d08cff888..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# pylint: disable=import-error - -""" -This sample shows how to create a bot that demonstrates the following: -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. -- Implement a multi-turn conversation using Dialogs. -- Handle user interruptions for such things as `Help` or `Cancel`. -- Prompt for and validate requests for information from the user. -""" - -import asyncio -import sys -from datetime import datetime -from types import MethodType - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - UserState, - TurnContext -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import DialogAndWelcomeBot -from dialogs import MainDialog, BookingDialog -from flight_booking_recognizer import FlightBookingRecognizer - -LOOP = asyncio.get_event_loop() -APP = Flask(__name__, instance_relative_config=True) -APP.config.from_object("config.DefaultConfig") - -SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) -ADAPTER = BotFrameworkAdapter(SETTINGS) - -# Create MemoryStorage, UserState and ConversationState -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) -RECOGNIZER = FlightBookingRecognizer(APP.config) -BOOKING_DIALOG = BookingDialog() - -# 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. -# pylint: disable=unused-argument -async def on_error(self, 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 encounted 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 = MethodType(on_error, ADAPTER) - -DIALOG = MainDialog(RECOGNIZER, BOOKING_DIALOG) -BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -@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/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py deleted file mode 100644 index ca0710ff0..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - - -class BookingDetails: - def __init__( - self, - destination: str = None, - origin: str = None, - travel_date: str = None, - unsupported_airports: List[str] = None, - ): - self.destination = destination - self.origin = origin - self.travel_date = travel_date - self.unsupported_airports = unsupported_airports or [] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py deleted file mode 100644 index 6925db302..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_bot import DialogBot -from .dialog_and_welcome_bot import DialogAndWelcomeBot - -__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py deleted file mode 100644 index 17bb2db80..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import os.path - -from typing import List -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.schema import Attachment, ChannelAccount - -from helpers import DialogHelper -from .dialog_bot import DialogBot - - -class DialogAndWelcomeBot(DialogBot): - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - # Greet anyone that was not the target (recipient) of this message. - # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. - if member.id != turn_context.activity.recipient.id: - welcome_card = self.create_adaptive_card_attachment() - response = MessageFactory.attachment(welcome_card) - await turn_context.send_activity(response) - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) - - # Load attachment from file. - def create_adaptive_card_attachment(self): - relative_path = os.path.abspath(os.path.dirname(__file__)) - path = os.path.join(relative_path, "../cards/welcomeCard.json") - with open(path) as card_file: - card = json.load(card_file) - - return Attachment( - content_type="application/vnd.microsoft.card.adaptive", content=card - ) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py deleted file mode 100644 index 5f2c148aa..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog - -from helpers import DialogHelper - - -class DialogBot(ActivityHandler): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occurred during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json b/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json deleted file mode 100644 index cc10cda9f..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.0", - "body": [ - { - "type": "Image", - "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", - "size": "stretch" - }, - { - "type": "TextBlock", - "spacing": "medium", - "size": "default", - "weight": "bolder", - "text": "Welcome to Bot Framework!", - "wrap": true, - "maxLines": 0 - }, - { - "type": "TextBlock", - "size": "default", - "isSubtle": "true", - "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", - "wrap": true, - "maxLines": 0 - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "Get an overview", - "url": "https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0" - }, - { - "type": "Action.OpenUrl", - "title": "Ask a question", - "url": "https://stackoverflow.com/questions/tagged/botframework" - }, - { - "type": "Action.OpenUrl", - "title": "Learn how to deploy", - "url": "https://docs.microsoft.com/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" - } - ] -} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json b/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json deleted file mode 100644 index f0e4b9770..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json +++ /dev/null @@ -1,339 +0,0 @@ -{ - "luis_schema_version": "3.2.0", - "versionId": "0.1", - "name": "FlightBooking", - "desc": "Luis Model for CoreBot", - "culture": "en-us", - "tokenizerVersion": "1.0.0", - "intents": [ - { - "name": "BookFlight" - }, - { - "name": "Cancel" - }, - { - "name": "GetWeather" - }, - { - "name": "None" - } - ], - "entities": [], - "composites": [ - { - "name": "From", - "children": [ - "Airport" - ], - "roles": [] - }, - { - "name": "To", - "children": [ - "Airport" - ], - "roles": [] - } - ], - "closedLists": [ - { - "name": "Airport", - "subLists": [ - { - "canonicalForm": "Paris", - "list": [ - "paris", - "cdg" - ] - }, - { - "canonicalForm": "London", - "list": [ - "london", - "lhr" - ] - }, - { - "canonicalForm": "Berlin", - "list": [ - "berlin", - "txl" - ] - }, - { - "canonicalForm": "New York", - "list": [ - "new york", - "jfk" - ] - }, - { - "canonicalForm": "Seattle", - "list": [ - "seattle", - "sea" - ] - } - ], - "roles": [] - } - ], - "patternAnyEntities": [], - "regex_entities": [], - "prebuiltEntities": [ - { - "name": "datetimeV2", - "roles": [] - } - ], - "model_features": [], - "regex_features": [], - "patterns": [], - "utterances": [ - { - "text": "book a flight", - "intent": "BookFlight", - "entities": [] - }, - { - "text": "book a flight from new york", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 19, - "endPos": 26 - } - ] - }, - { - "text": "book a flight from seattle", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 19, - "endPos": 25 - } - ] - }, - { - "text": "book a hotel in new york", - "intent": "None", - "entities": [] - }, - { - "text": "book a restaurant", - "intent": "None", - "entities": [] - }, - { - "text": "book flight from london to paris on feb 14th", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 17, - "endPos": 22 - }, - { - "entity": "To", - "startPos": 27, - "endPos": 31 - } - ] - }, - { - "text": "book flight to berlin on feb 14th", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 15, - "endPos": 20 - } - ] - }, - { - "text": "book me a flight from london to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 22, - "endPos": 27 - }, - { - "entity": "To", - "startPos": 32, - "endPos": 36 - } - ] - }, - { - "text": "bye", - "intent": "Cancel", - "entities": [] - }, - { - "text": "cancel booking", - "intent": "Cancel", - "entities": [] - }, - { - "text": "exit", - "intent": "Cancel", - "entities": [] - }, - { - "text": "find an airport near me", - "intent": "None", - "entities": [] - }, - { - "text": "flight to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "flight to paris from london on feb 14th", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - }, - { - "entity": "From", - "startPos": 21, - "endPos": 26 - } - ] - }, - { - "text": "fly from berlin to paris on may 5th", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 9, - "endPos": 14 - }, - { - "entity": "To", - "startPos": 19, - "endPos": 23 - } - ] - }, - { - "text": "go to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 6, - "endPos": 10 - } - ] - }, - { - "text": "going from paris to berlin", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 11, - "endPos": 15 - }, - { - "entity": "To", - "startPos": 20, - "endPos": 25 - } - ] - }, - { - "text": "i'd like to rent a car", - "intent": "None", - "entities": [] - }, - { - "text": "ignore", - "intent": "Cancel", - "entities": [] - }, - { - "text": "travel from new york to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 12, - "endPos": 19 - }, - { - "entity": "To", - "startPos": 24, - "endPos": 28 - } - ] - }, - { - "text": "travel to new york", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 17 - } - ] - }, - { - "text": "travel to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "what's the forecast for this friday?", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "what's the weather like for tomorrow", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "what's the weather like in new york", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "what's the weather like?", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "winter is coming", - "intent": "None", - "entities": [] - } - ], - "settings": [] -} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py deleted file mode 100644 index 8df9f92c8..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/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", "") - LUIS_APP_ID = os.environ.get("LuisAppId", "") - LUIS_API_KEY = os.environ.get("LuisAPIKey", "") - # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" - LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py deleted file mode 100644 index 567539f96..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .booking_dialog import BookingDialog -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog -from .main_dialog import MainDialog - -__all__ = ["BookingDialog", "CancelAndHelpDialog", "DateResolverDialog", "MainDialog"] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py deleted file mode 100644 index c5912075d..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult -from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions -from botbuilder.core import MessageFactory -from botbuilder.schema import InputHints - -from datatypes_date_time.timex import Timex - -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog - - -class BookingDialog(CancelAndHelpDialog): - def __init__(self, dialog_id: str = None): - super(BookingDialog, self).__init__(dialog_id or BookingDialog.__name__) - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - self.add_dialog(DateResolverDialog(DateResolverDialog.__name__)) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__, - [ - self.destination_step, - self.origin_step, - self.travel_date_step, - self.confirm_step, - self.final_step, - ], - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ - - async def destination_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """ - If a destination city has not been provided, prompt for one. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - if booking_details.destination is None: - message_text = "Where would you like to travel to?" - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - return await step_context.prompt( - TextPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - return await step_context.next(booking_details.destination) - - async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """ - If an origin city has not been provided, prompt for one. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - # Capture the response to the previous step's prompt - booking_details.destination = step_context.result - if booking_details.origin is None: - message_text = "From what city will you be travelling?" - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - return await step_context.prompt( - TextPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - return await step_context.next(booking_details.origin) - - async def travel_date_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """ - If a travel date has not been provided, prompt for one. - This will use the DATE_RESOLVER_DIALOG. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.origin = step_context.result - if not booking_details.travel_date or self.is_ambiguous( - booking_details.travel_date - ): - return await step_context.begin_dialog( - DateResolverDialog.__name__, booking_details.travel_date - ) - return await step_context.next(booking_details.travel_date) - - async def confirm_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """ - Confirm the information the user has provided. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.travel_date = step_context.result - message_text = ( - f"Please confirm, I have you traveling to: { booking_details.destination } from: " - f"{ booking_details.origin } on: { booking_details.travel_date}." - ) - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - - # Offer a YES/NO prompt. - return await step_context.prompt( - ConfirmPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """ - Complete the interaction and end the dialog. - :param step_context: - :return DialogTurnResult: - """ - if step_context.result: - booking_details = step_context.options - - return await step_context.end_dialog(booking_details) - return await step_context.end_dialog() - - def is_ambiguous(self, timex: str) -> bool: - timex_property = Timex(timex) - return "definite" not in timex_property.types diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py deleted file mode 100644 index f09a63b62..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - DialogContext, - DialogTurnResult, - DialogTurnStatus, -) -from botbuilder.schema import ActivityTypes, InputHints -from botbuilder.core import MessageFactory - - -class CancelAndHelpDialog(ComponentDialog): - async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: - result = await self.interrupt(inner_dc) - if result is not None: - return result - - return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) - - async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: - if inner_dc.context.activity.type == ActivityTypes.message: - text = inner_dc.context.activity.text.lower() - - help_message_text = "Show Help..." - help_message = MessageFactory.text( - help_message_text, help_message_text, InputHints.expecting_input - ) - - if text in ("help", "?"): - await inner_dc.context.send_activity(help_message) - return DialogTurnResult(DialogTurnStatus.Waiting) - - cancel_message_text = "Cancelling" - cancel_message = MessageFactory.text( - cancel_message_text, cancel_message_text, InputHints.ignoring_input - ) - - if text in ("cancel", "quit"): - await inner_dc.context.send_activity(cancel_message) - return await inner_dc.cancel_all_dialogs() - - return None diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py deleted file mode 100644 index 985dbf389..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory -from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext -from botbuilder.dialogs.prompts import ( - DateTimePrompt, - PromptValidatorContext, - PromptOptions, - DateTimeResolution, -) -from botbuilder.schema import InputHints -from datatypes_date_time.timex import Timex - -from .cancel_and_help_dialog import CancelAndHelpDialog - - -class DateResolverDialog(CancelAndHelpDialog): - def __init__(self, dialog_id: str = None): - super(DateResolverDialog, self).__init__( - dialog_id or DateResolverDialog.__name__ - ) - - self.add_dialog( - DateTimePrompt( - DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator - ) - ) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__ + "2", [self.initial_step, self.final_step] - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ + "2" - - async def initial_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - timex = step_context.options - - prompt_msg_text = "On what date would you like to travel?" - prompt_msg = MessageFactory.text( - prompt_msg_text, prompt_msg_text, InputHints.expecting_input - ) - - reprompt_msg_text = "I'm sorry, for best results, please enter your travel date " \ - "including the month, day and year." - reprompt_msg = MessageFactory.text( - reprompt_msg_text, reprompt_msg_text, InputHints.expecting_input - ) - - if timex is None: - # We were not given any date at all so prompt the user. - return await step_context.prompt( - DateTimePrompt.__name__, - PromptOptions(prompt=prompt_msg, retry_prompt=reprompt_msg), - ) - # We have a Date we just need to check it is unambiguous. - if "definite" not in Timex(timex).types: - # This is essentially a "reprompt" of the data we were given up front. - return await step_context.prompt( - DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) - ) - - return await step_context.next(DateTimeResolution(timex=timex)) - - async def final_step(self, step_context: WaterfallStepContext): - timex = step_context.result[0].timex - return await step_context.end_dialog(timex) - - @staticmethod - async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: - if prompt_context.recognized.succeeded: - timex = prompt_context.recognized.value[0].timex.split("T")[0] - - return "definite" in Timex(timex).types - - return False diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py deleted file mode 100644 index 91566728d..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.dialogs.prompts import TextPrompt, PromptOptions -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.schema import InputHints - -from booking_details import BookingDetails -from flight_booking_recognizer import FlightBookingRecognizer - -from helpers import LuisHelper, Intent -from .booking_dialog import BookingDialog - - -class MainDialog(ComponentDialog): - def __init__( - self, luis_recognizer: FlightBookingRecognizer, booking_dialog: BookingDialog - ): - super(MainDialog, self).__init__(MainDialog.__name__) - - self._luis_recognizer = luis_recognizer - self._booking_dialog_id = booking_dialog.id - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(booking_dialog) - self.add_dialog( - WaterfallDialog( - "WFDialog", [self.intro_step, self.act_step, self.final_step] - ) - ) - - self.initial_dialog_id = "WFDialog" - - async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - if not self._luis_recognizer.is_configured: - await step_context.context.send_activity( - MessageFactory.text( - "NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and " - "'LuisAPIHostName' to the appsettings.json file.", - input_hint=InputHints.ignoring_input, - ) - ) - - return await step_context.next(None) - message_text = ( - str(step_context.options) - if step_context.options - else "What can I help you with today?" - ) - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - - return await step_context.prompt( - TextPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - - async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - if not self._luis_recognizer.is_configured: - # LUIS is not configured, we just run the BookingDialog path with an empty BookingDetailsInstance. - return await step_context.begin_dialog( - self._booking_dialog_id, BookingDetails() - ) - - # Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.) - intent, luis_result = await LuisHelper.execute_luis_query( - self._luis_recognizer, step_context.context - ) - - if intent == Intent.BOOK_FLIGHT.value and luis_result: - # Show a warning for Origin and Destination if we can't resolve them. - await MainDialog._show_warning_for_unsupported_cities( - step_context.context, luis_result - ) - - # Run the BookingDialog giving it whatever details we have from the LUIS call. - return await step_context.begin_dialog(self._booking_dialog_id, luis_result) - - if intent == Intent.GET_WEATHER.value: - get_weather_text = "TODO: get weather flow here" - get_weather_message = MessageFactory.text( - get_weather_text, get_weather_text, InputHints.ignoring_input - ) - await step_context.context.send_activity(get_weather_message) - - else: - didnt_understand_text = ( - "Sorry, I didn't get that. Please try asking in a different way" - ) - didnt_understand_message = MessageFactory.text( - didnt_understand_text, didnt_understand_text, InputHints.ignoring_input - ) - await step_context.context.send_activity(didnt_understand_message) - - return await step_context.next(None) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # If the child dialog ("BookingDialog") was cancelled or the user failed to confirm, - # the Result here will be null. - if step_context.result is not None: - result = step_context.result - - # Now we have all the booking details call the booking service. - - # If the call to the booking service was successful tell the user. - # time_property = Timex(result.travel_date) - # travel_date_msg = time_property.to_natural_language(datetime.now()) - msg_txt = f"I have you booked to {result.destination} from {result.origin} on {result.travel_date}" - message = MessageFactory.text(msg_txt, msg_txt, InputHints.ignoring_input) - await step_context.context.send_activity(message) - - prompt_message = "What else can I do for you?" - return await step_context.replace_dialog(self.id, prompt_message) - - @staticmethod - async def _show_warning_for_unsupported_cities( - context: TurnContext, luis_result: BookingDetails - ) -> None: - if luis_result.unsupported_airports: - message_text = ( - f"Sorry but the following airports are not supported:" - f" {', '.join(luis_result.unsupported_airports)}" - ) - message = MessageFactory.text( - message_text, message_text, InputHints.ignoring_input - ) - await context.send_activity(message) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py deleted file mode 100644 index 7476103c7..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.ai.luis import LuisApplication, LuisRecognizer -from botbuilder.core import Recognizer, RecognizerResult, TurnContext - - -class FlightBookingRecognizer(Recognizer): - def __init__(self, configuration: dict): - self._recognizer = None - - luis_is_configured = ( - configuration["LUIS_APP_ID"] - and configuration["LUIS_API_KEY"] - and configuration["LUIS_API_HOST_NAME"] - ) - if luis_is_configured: - luis_application = LuisApplication( - configuration["LUIS_APP_ID"], - configuration["LUIS_API_KEY"], - "https://" + configuration["LUIS_API_HOST_NAME"], - ) - - self._recognizer = LuisRecognizer(luis_application) - - @property - def is_configured(self) -> bool: - # Returns true if luis is configured in the appsettings.json and initialized. - return self._recognizer is not None - - async def recognize(self, turn_context: TurnContext) -> RecognizerResult: - return await self._recognizer.recognize(turn_context) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py deleted file mode 100644 index 787a8ed1a..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .luis_helper import Intent, LuisHelper -from .dialog_helper import DialogHelper - -__all__ = [ - "DialogHelper", - "LuisHelper", - "Intent" -] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py deleted file mode 100644 index 30331a0d5..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from enum import Enum -from typing import Dict -from botbuilder.ai.luis import LuisRecognizer -from botbuilder.core import IntentScore, TopIntent, TurnContext - -from booking_details import BookingDetails - - -class Intent(Enum): - BOOK_FLIGHT = "BookFlight" - CANCEL = "Cancel" - GET_WEATHER = "GetWeather" - NONE_INTENT = "NoneIntent" - - -def top_intent(intents: Dict[Intent, dict]) -> TopIntent: - max_intent = Intent.NONE_INTENT - max_value = 0.0 - - for intent, value in intents: - intent_score = IntentScore(value) - if intent_score.score > max_value: - max_intent, max_value = intent, intent_score.score - - return TopIntent(max_intent, max_value) - - -class LuisHelper: - @staticmethod - async def execute_luis_query( - luis_recognizer: LuisRecognizer, turn_context: TurnContext - ) -> (Intent, object): - """ - Returns an object with pre-formatted LUIS results for the bot's dialogs to consume. - """ - result = None - intent = None - - try: - recognizer_result = await luis_recognizer.recognize(turn_context) - - intent = ( - sorted( - recognizer_result.intents, - key=recognizer_result.intents.get, - reverse=True, - )[:1][0] - if recognizer_result.intents - else None - ) - - if intent == Intent.BOOK_FLIGHT.value: - result = BookingDetails() - - # We need to get the result from the LUIS JSON which at every level - # returns an array. - to_entities = recognizer_result.entities.get("$instance", {}).get( - "To", [] - ) - if len(to_entities) > 0: - if recognizer_result.entities.get("To", [{"$instance": {}}])[0][ - "$instance" - ]: - result.destination = to_entities[0]["text"].capitalize() - else: - result.unsupported_airports.append( - to_entities[0]["text"].capitalize() - ) - - from_entities = recognizer_result.entities.get("$instance", {}).get( - "From", [] - ) - if len(from_entities) > 0: - if recognizer_result.entities.get("From", [{"$instance": {}}])[0][ - "$instance" - ]: - result.origin = from_entities[0]["text"].capitalize() - else: - result.unsupported_airports.append( - from_entities[0]["text"].capitalize() - ) - - # This value will be a TIMEX. And we are only interested in a Date so - # grab the first result and drop the Time part. - # TIMEX is a format that represents DateTime expressions that include - # some ambiguity. e.g. missing a Year. - date_entities = recognizer_result.entities.get("datetime", []) - if date_entities: - timex = date_entities[0]["timex"] - - if timex: - datetime = timex[0].split("T")[0] - - result.travel_date = datetime - - else: - result.travel_date = None - - except Exception as err: - print(err) - - return intent, result diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt b/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt deleted file mode 100644 index c11eb2923..000000000 --- a/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -botbuilder-dialogs>=4.4.0.b1 -botbuilder-ai>=4.4.0.b1 -datatypes-date-time>=1.0.0.a2 -flask>=1.0.3 - diff --git a/generators/app/templates/echo/cookiecutter.json b/generators/app/templates/echo/cookiecutter.json deleted file mode 100644 index 4a14b6ade..000000000 --- a/generators/app/templates/echo/cookiecutter.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "bot_name": "my_chat_bot", - "bot_description": "Demonstrate the core capabilities of the Microsoft Bot Framework" -} \ No newline at end of file diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc deleted file mode 100644 index 1baee5edb..000000000 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc +++ /dev/null @@ -1,497 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore= - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=missing-docstring, - too-few-public-methods, - bad-continuation, - no-self-use, - duplicate-code, - broad-except - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=120 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[STRING] - -# This flag controls whether the implicit-str-concat-in-sequence should -# generate a warning on implicit string concatenation in sequences defined over -# several lines. -check-str-concat-over-line-jumps=no - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/README.md b/generators/app/templates/echo/{{cookiecutter.bot_name}}/README.md deleted file mode 100644 index 5eeee191f..000000000 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# {{cookiecutter.bot_name}} - -{{cookiecutter.bot_description}} - -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 - -This sample **requires** prerequisites in order to run. - -### Install Python 3.6 - -## Running the sample -- Run `pip install -r requirements.txt` to install all dependencies -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -## 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 -- Enter a Bot URL of `http://localhost:3978/api/messages` - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/__init__.py b/generators/app/templates/echo/{{cookiecutter.bot_name}}/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py deleted file mode 100644 index f7fa35cac..000000000 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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 ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - TurnContext, -) -from botbuilder.schema import Activity, ActivityTypes -from bot import MyBot - -# 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. -# pylint: disable=unused-argument -async def on_error(self, 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 encounted 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 = MethodType(on_error, ADAPTER) - -# Create the main dialog -BOT = MyBot() - -# 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 diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/bot.py b/generators/app/templates/echo/{{cookiecutter.bot_name}}/bot.py deleted file mode 100644 index c1ea90861..000000000 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/bot.py +++ /dev/null @@ -1,21 +0,0 @@ -# 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/generators/app/templates/echo/{{cookiecutter.bot_name}}/config.py b/generators/app/templates/echo/{{cookiecutter.bot_name}}/config.py deleted file mode 100644 index 7163a79aa..000000000 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/config.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/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/generators/app/templates/echo/{{cookiecutter.bot_name}}/requirements.txt b/generators/app/templates/echo/{{cookiecutter.bot_name}}/requirements.txt deleted file mode 100644 index 2e5ecf3fc..000000000 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -botbuilder-core>=4.5.0.b4 -flask>=1.0.3 - diff --git a/generators/app/templates/empty/cookiecutter.json b/generators/app/templates/empty/cookiecutter.json deleted file mode 100644 index 4a14b6ade..000000000 --- a/generators/app/templates/empty/cookiecutter.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "bot_name": "my_chat_bot", - "bot_description": "Demonstrate the core capabilities of the Microsoft Bot Framework" -} \ No newline at end of file diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc deleted file mode 100644 index 1baee5edb..000000000 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc +++ /dev/null @@ -1,497 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore= - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=missing-docstring, - too-few-public-methods, - bad-continuation, - no-self-use, - duplicate-code, - broad-except - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=120 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[STRING] - -# This flag controls whether the implicit-str-concat-in-sequence should -# generate a warning on implicit string concatenation in sequences defined over -# several lines. -check-str-concat-over-line-jumps=no - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/README.md b/generators/app/templates/empty/{{cookiecutter.bot_name}}/README.md deleted file mode 100644 index 5eeee191f..000000000 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# {{cookiecutter.bot_name}} - -{{cookiecutter.bot_description}} - -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 - -This sample **requires** prerequisites in order to run. - -### Install Python 3.6 - -## Running the sample -- Run `pip install -r requirements.txt` to install all dependencies -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -## 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 -- Enter a Bot URL of `http://localhost:3978/api/messages` - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/__init__.py b/generators/app/templates/empty/{{cookiecutter.bot_name}}/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py deleted file mode 100644 index 4ab9d480f..000000000 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from types import MethodType - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - TurnContext, -) -from botbuilder.schema import Activity -from bot import MyBot - -# 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. -# pylint: disable=unused-argument -async def on_error(self, 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 encounted an error or bug.") - await context.send_activity("To continue to run this bot, please fix the bot source code.") - -ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) - -# Create the main dialog -BOT = MyBot() - -# 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 diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/bot.py b/generators/app/templates/empty/{{cookiecutter.bot_name}}/bot.py deleted file mode 100644 index f0c2122cf..000000000 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/bot.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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): - 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 world!") diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/config.py b/generators/app/templates/empty/{{cookiecutter.bot_name}}/config.py deleted file mode 100644 index 7163a79aa..000000000 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/config.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/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/generators/app/templates/empty/{{cookiecutter.bot_name}}/requirements.txt b/generators/app/templates/empty/{{cookiecutter.bot_name}}/requirements.txt deleted file mode 100644 index 2e5ecf3fc..000000000 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -botbuilder-core>=4.5.0.b4 -flask>=1.0.3 - diff --git a/samples/01.console-echo/README.md b/samples/01.console-echo/README.md deleted file mode 100644 index 996e0909b..000000000 --- a/samples/01.console-echo/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Console EchoBot -Bot Framework v4 console echo sample. - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that you can talk to from the console window. - -This sample shows a simple echo bot and demonstrates the bot working as a console app using a sample console adapter. - -## To try this sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` - - -### Visual studio code -- open `botbuilder-python\samples\01.console-echo` folder -- Bring up a terminal, navigate to `botbuilder-python\samples\01.console-echo` folder -- type 'python main.py' - - -# Adapters -[Adapters](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#the-bot-adapter) provide an abstraction for your bot to work with a variety of environments. - -A bot is directed by it's adapter, which can be thought of as the conductor for your bot. The adapter is responsible for directing incoming and outgoing communication, authentication, and so on. The adapter differs based on it's environment (the adapter internally works differently locally versus on Azure) but in each instance it achieves the same goal. - -In most situations we don't work with the adapter directly, such as when creating a bot from a template, but it's good to know it's there and what it does. -The bot adapter encapsulates authentication processes and sends activities to and receives activities from the Bot Connector Service. When your bot receives an activity, the adapter wraps up everything about that activity, creates a [context object](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0#turn-context), passes it to your bot's application logic, and sends responses generated by your bot back to the user's channel. - - -# Further reading - -- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) -- [Bot basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) -- [Channels and Bot Connector service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) -- [Activity processing](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/01.console-echo/adapter/__init__.py b/samples/01.console-echo/adapter/__init__.py deleted file mode 100644 index 56d4bd2ee..000000000 --- a/samples/01.console-echo/adapter/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .console_adapter import ConsoleAdapter - -__all__ = ["ConsoleAdapter"] diff --git a/samples/01.console-echo/adapter/console_adapter.py b/samples/01.console-echo/adapter/console_adapter.py deleted file mode 100644 index 16824436f..000000000 --- a/samples/01.console-echo/adapter/console_adapter.py +++ /dev/null @@ -1,180 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import datetime -import asyncio -import warnings -from typing import List, Callable - -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - ResourceResponse, - ConversationReference, -) -from botbuilder.core.turn_context import TurnContext -from botbuilder.core.bot_adapter import BotAdapter - - -class ConsoleAdapter(BotAdapter): - """ - Lets a user communicate with a bot from a console window. - - :Example: - import asyncio - from botbuilder.core import ConsoleAdapter - - async def logic(context): - await context.send_activity('Hello World!') - - adapter = ConsoleAdapter() - loop = asyncio.get_event_loop() - if __name__ == "__main__": - try: - loop.run_until_complete(adapter.process_activity(logic)) - except KeyboardInterrupt: - pass - finally: - loop.stop() - loop.close() - """ - - def __init__(self, reference: ConversationReference = None): - super(ConsoleAdapter, self).__init__() - - self.reference = ConversationReference( - channel_id="console", - user=ChannelAccount(id="user", name="User1"), - bot=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount(id="convo1", name="", is_group=False), - service_url="", - ) - - # Warn users to pass in an instance of a ConversationReference, otherwise the parameter will be ignored. - if reference is not None and not isinstance(reference, ConversationReference): - warnings.warn( - "ConsoleAdapter: `reference` argument is not an instance of ConversationReference and will " - "be ignored." - ) - else: - self.reference.channel_id = getattr( - reference, "channel_id", self.reference.channel_id - ) - self.reference.user = getattr(reference, "user", self.reference.user) - self.reference.bot = getattr(reference, "bot", self.reference.bot) - self.reference.conversation = getattr( - reference, "conversation", self.reference.conversation - ) - self.reference.service_url = getattr( - reference, "service_url", self.reference.service_url - ) - # The only attribute on self.reference without an initial value is activity_id, so if reference does not - # have a value for activity_id, default self.reference.activity_id to None - self.reference.activity_id = getattr(reference, "activity_id", None) - - self._next_id = 0 - - async def process_activity(self, logic: Callable): - """ - Begins listening to console input. - :param logic: - :return: - """ - while True: - msg = input() - if msg is None: - pass - else: - self._next_id += 1 - activity = Activity( - text=msg, - channel_id="console", - from_property=ChannelAccount(id="user", name="User1"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount(id="Convo1"), - type=ActivityTypes.message, - timestamp=datetime.datetime.now(), - id=str(self._next_id), - ) - - activity = TurnContext.apply_conversation_reference( - activity, self.reference, True - ) - context = TurnContext(self, activity) - await self.run_pipeline(context, logic) - - async def send_activities(self, context: TurnContext, activities: List[Activity]) -> List[ResourceResponse]: - """ - Logs a series of activities to the console. - :param context: - :param activities: - :return: - """ - if context is None: - raise TypeError( - "ConsoleAdapter.send_activities(): `context` argument cannot be None." - ) - if not isinstance(activities, list): - raise TypeError( - "ConsoleAdapter.send_activities(): `activities` argument must be a list." - ) - if len(activities) == 0: - raise ValueError( - "ConsoleAdapter.send_activities(): `activities` argument cannot have a length of 0." - ) - - async def next_activity(i: int): - responses = [] - - if i < len(activities): - responses.append(ResourceResponse()) - activity = activities[i] - - if activity.type == "delay": - await asyncio.sleep(activity.delay) - await next_activity(i + 1) - elif activity.type == ActivityTypes.message: - if ( - activity.attachments is not None - and len(activity.attachments) > 0 - ): - append = ( - "(1 attachment)" - if len(activity.attachments) == 1 - else f"({len(activity.attachments)} attachments)" - ) - print(f"{activity.text} {append}") - else: - print(activity.text) - await next_activity(i + 1) - else: - print(f"[{activity.type}]") - await next_activity(i + 1) - else: - return responses - - await next_activity(0) - - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - """ - Not supported for the ConsoleAdapter. Calling this method or `TurnContext.delete_activity()` - will result an error being returned. - :param context: - :param reference: - :return: - """ - raise NotImplementedError("ConsoleAdapter.delete_activity(): not supported.") - - async def update_activity(self, context: TurnContext, activity: Activity): - """ - Not supported for the ConsoleAdapter. Calling this method or `TurnContext.update_activity()` - will result an error being returned. - :param context: - :param activity: - :return: - """ - raise NotImplementedError("ConsoleAdapter.update_activity(): not supported.") diff --git a/samples/01.console-echo/bot.py b/samples/01.console-echo/bot.py deleted file mode 100644 index 226f0d963..000000000 --- a/samples/01.console-echo/bot.py +++ /dev/null @@ -1,16 +0,0 @@ -from sys import exit - - -class EchoBot: - async def on_turn(self, context): - # Check to see if this activity is an incoming message. - # (It could theoretically be another type of activity.) - if context.activity.type == "message" and context.activity.text: - # Check to see if the user sent a simple "quit" message. - if context.activity.text.lower() == "quit": - # Send a reply. - await context.send_activity("Bye!") - exit(0) - else: - # Echo the message text back to the user. - await context.send_activity(f"I heard you say {context.activity.text}") diff --git a/samples/01.console-echo/main.py b/samples/01.console-echo/main.py deleted file mode 100644 index 73801d1b8..000000000 --- a/samples/01.console-echo/main.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio - -from adapter import ConsoleAdapter -from bot import EchoBot - -# Create adapter -ADAPTER = ConsoleAdapter() -BOT = EchoBot() - -LOOP = asyncio.get_event_loop() - -if __name__ == "__main__": - try: - # Greet user - print("Hi... I'm an echobot. Whatever you say I'll echo back.") - - LOOP.run_until_complete(ADAPTER.process_activity(BOT.on_turn)) - except KeyboardInterrupt: - pass - finally: - LOOP.stop() - LOOP.close() diff --git a/samples/01.console-echo/requirements.txt b/samples/01.console-echo/requirements.txt deleted file mode 100644 index 7e1c1616d..000000000 --- a/samples/01.console-echo/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -asyncio>=3.4.3 -botbuilder-core>=4.4.0.b1 -botbuilder-schema>=4.4.0.b1 -botframework-connector>=4.4.0.b1 \ No newline at end of file diff --git a/samples/02.echo-bot/README.md b/samples/02.echo-bot/README.md deleted file mode 100644 index 40e84f525..000000000 --- a/samples/02.echo-bot/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) diff --git a/samples/02.echo-bot/app.py b/samples/02.echo-bot/app.py deleted file mode 100644 index 5cc960eb8..000000000 --- a/samples/02.echo-bot/app.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -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 EchoBot - -# 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 = EchoBot() - -# 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 diff --git a/samples/02.echo-bot/bots/__init__.py b/samples/02.echo-bot/bots/__init__.py deleted file mode 100644 index f95fbbbad..000000000 --- a/samples/02.echo-bot/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .echo_bot import EchoBot - -__all__ = ["EchoBot"] diff --git a/samples/02.echo-bot/bots/echo_bot.py b/samples/02.echo-bot/bots/echo_bot.py deleted file mode 100644 index 985c0694c..000000000 --- a/samples/02.echo-bot/bots/echo_bot.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount - - -class EchoBot(ActivityHandler): - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity("Hello and welcome!") - - async def on_message_activity(self, turn_context: TurnContext): - return await turn_context.send_activity(MessageFactory.text(f"Echo: {turn_context.activity.text}")) diff --git a/samples/02.echo-bot/config.py b/samples/02.echo-bot/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/02.echo-bot/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/02.echo-bot/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/02.echo-bot/requirements.txt b/samples/02.echo-bot/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/02.echo-bot/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/03.welcome-user/README.md b/samples/03.welcome-user/README.md deleted file mode 100644 index ac6c37553..000000000 --- a/samples/03.welcome-user/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# welcome users - - -Bot Framework v4 welcome users bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), is shows how to welcome users when they join the conversation. - -## 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\03.welcome-user` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - - -## Welcoming Users - -The primary goal when creating any bot is to engage your user in a meaningful conversation. One of the best ways to achieve this goal is to ensure that from the moment a user first connects, they understand your bot’s main purpose and capabilities, the reason your bot was created. See [Send welcome message to users](https://aka.ms/botframework-welcome-instructions) for additional information on how a bot can welcome users to a conversation. - -## 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) diff --git a/samples/03.welcome-user/app.py b/samples/03.welcome-user/app.py deleted file mode 100644 index 7941afeb1..000000000 --- a/samples/03.welcome-user/app.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import WelcomeUserBot - -# 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 MemoryStorage, UserState -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) - -# Create the Bot -BOT = WelcomeUserBot(USER_STATE) - -# 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 diff --git a/samples/03.welcome-user/bots/__init__.py b/samples/03.welcome-user/bots/__init__.py deleted file mode 100644 index 4f3e70d59..000000000 --- a/samples/03.welcome-user/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .welcome_user_bot import WelcomeUserBot - -__all__ = ["WelcomeUserBot"] diff --git a/samples/03.welcome-user/bots/welcome_user_bot.py b/samples/03.welcome-user/bots/welcome_user_bot.py deleted file mode 100644 index 9aa584732..000000000 --- a/samples/03.welcome-user/bots/welcome_user_bot.py +++ /dev/null @@ -1,143 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ( - ActivityHandler, - TurnContext, - UserState, - CardFactory, - MessageFactory, -) -from botbuilder.schema import ( - ChannelAccount, - HeroCard, - CardImage, - CardAction, - ActionTypes, -) - -from data_models import WelcomeUserState - - -class WelcomeUserBot(ActivityHandler): - def __init__(self, user_state: UserState): - if user_state is None: - raise TypeError( - "[WelcomeUserBot]: Missing parameter. user_state is required but None was given" - ) - - self.user_state = user_state - - self.user_state_accessor = self.user_state.create_property("WelcomeUserState") - - self.WELCOME_MESSAGE = """This is a simple Welcome Bot sample. This bot will introduce you - to welcoming and greeting users. You can say 'intro' to see the - introduction card. If you are running this bot in the Bot Framework - Emulator, press the 'Restart Conversation' button to simulate user joining - a bot or a channel""" - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # save changes to WelcomeUserState after each turn - await self.user_state.save_changes(turn_context) - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - """ - Greet when users are added to the conversation. - Note that all channels do not send the conversation update activity. - If you find that this bot works in the emulator, but does not in - another channel the reason is most likely that the channel does not - send this activity. - """ - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - f"Hi there { member.name }. " + self.WELCOME_MESSAGE - ) - - await turn_context.send_activity( - """You are seeing this message because the bot received at least one - 'ConversationUpdate' event, indicating you (and possibly others) - joined the conversation. If you are using the emulator, pressing - the 'Start Over' button to trigger this event again. The specifics - of the 'ConversationUpdate' event depends on the channel. You can - read more information at: https://aka.ms/about-botframework-welcome-user""" - ) - - await turn_context.send_activity( - """It is a good pattern to use this event to send general greeting - to user, explaining what your bot can do. In this example, the bot - handles 'hello', 'hi', 'help' and 'intro'. Try it now, type 'hi'""" - ) - - async def on_message_activity(self, turn_context: TurnContext): - """ - Respond to messages sent from the user. - """ - # Get the state properties from the turn context. - welcome_user_state = await self.user_state_accessor.get( - turn_context, WelcomeUserState - ) - - if not welcome_user_state.did_welcome_user: - welcome_user_state.did_welcome_user = True - - await turn_context.send_activity( - "You are seeing this message because this was your first message ever to this bot." - ) - - name = turn_context.activity.from_property.name - await turn_context.send_activity( - f"It is a good practice to welcome the user and provide personal greeting. For example: Welcome {name}" - ) - - else: - # This example hardcodes specific utterances. You should use LUIS or QnA for more advance language - # understanding. - text = turn_context.activity.text.lower() - if text in ("hello", "hi"): - await turn_context.send_activity(f"You said { text }") - elif text in ("intro", "help"): - await self.__send_intro_card(turn_context) - else: - await turn_context.send_activity(self.WELCOME_MESSAGE) - - async def __send_intro_card(self, turn_context: TurnContext): - card = HeroCard( - title="Welcome to Bot Framework!", - text="Welcome to Welcome Users bot sample! This Introduction card " - "is a great way to introduce your Bot to the user and suggest " - "some things to get them started. We use this opportunity to " - "recommend a few next steps for learning more creating and deploying bots.", - images=[CardImage(url="https://aka.ms/bf-welcome-card-image")], - buttons=[ - CardAction( - type=ActionTypes.open_url, - title="Get an overview", - text="Get an overview", - display_text="Get an overview", - value="https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0", - ), - CardAction( - type=ActionTypes.open_url, - title="Ask a question", - text="Ask a question", - display_text="Ask a question", - value="https://stackoverflow.com/questions/tagged/botframework", - ), - CardAction( - type=ActionTypes.open_url, - title="Learn how to deploy", - text="Learn how to deploy", - display_text="Learn how to deploy", - value="https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0", - ), - ], - ) - - return await turn_context.send_activity( - MessageFactory.attachment(CardFactory.hero_card(card)) - ) diff --git a/samples/03.welcome-user/config.py b/samples/03.welcome-user/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/03.welcome-user/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/03.welcome-user/data_models/__init__.py b/samples/03.welcome-user/data_models/__init__.py deleted file mode 100644 index a7cd0686a..000000000 --- a/samples/03.welcome-user/data_models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .welcome_user_state import WelcomeUserState - -__all__ = ["WelcomeUserState"] diff --git a/samples/03.welcome-user/data_models/welcome_user_state.py b/samples/03.welcome-user/data_models/welcome_user_state.py deleted file mode 100644 index 7470d4378..000000000 --- a/samples/03.welcome-user/data_models/welcome_user_state.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class WelcomeUserState: - def __init__(self, did_welcome: bool = False): - self.did_welcome_user = did_welcome diff --git a/samples/03.welcome-user/requirements.txt b/samples/03.welcome-user/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/03.welcome-user/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/05.multi-turn-prompt/README.md b/samples/05.multi-turn-prompt/README.md deleted file mode 100644 index 405a70f2a..000000000 --- a/samples/05.multi-turn-prompt/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# multi-turn prompt - -Bot Framework v4 welcome users bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use the prompts classes included in `botbuilder-dialogs`. This bot will ask for the user's name and age, then store the responses. It demonstrates a multi-turn dialog flow using a text prompt, a number prompt, and state accessors to store and retrieve values. - -## Running the sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` -- Run `pip install -r requirements.txt` to install all dependencies -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -### Visual studio code -- Activate your desired virtual environment -- Open `botbuilder-python\samples\45.state-management` folder -- Bring up a terminal, navigate to `botbuilder-python\samples\05.multi-turn-prompt` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - - -## Prompts - -A conversation between a bot and a user often involves asking (prompting) the user for information, parsing the user's response, -and then acting on that information. This sample demonstrates how to prompt users for information using the different prompt types -included in the [botbuilder-dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) library -and supported by the SDK. - -The `botbuilder-dialogs` library includes a variety of pre-built prompt classes, including text, number, and datetime types. This -sample demonstrates using a text prompt to collect the user's name, then using a number prompt to collect an age. - -# 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) -- [Dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/05.multi-turn-prompt/app.py b/samples/05.multi-turn-prompt/app.py deleted file mode 100644 index fd68f6667..000000000 --- a/samples/05.multi-turn-prompt/app.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from dialogs import UserProfileDialog -from bots import DialogBot - -# 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 - # 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 - 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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - - -# Set the error handler on the Adapter. -# In this case, we want an unbound method, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage, UserState and ConversationState -MEMORY = MemoryStorage() -CONVERSATION_STATE = ConversationState(MEMORY) -USER_STATE = UserState(MEMORY) - -# create main dialog and bot -DIALOG = UserProfileDialog(USER_STATE) -BOT = DialogBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -# Listen for incoming requests on /api/messages. -@APP.route("/api/messages", methods=["POST"]) -def messages(): - # Main bot message handler.s - 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/samples/05.multi-turn-prompt/bots/__init__.py b/samples/05.multi-turn-prompt/bots/__init__.py deleted file mode 100644 index 306aca22c..000000000 --- a/samples/05.multi-turn-prompt/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_bot import DialogBot - -__all__ = ["DialogBot"] diff --git a/samples/05.multi-turn-prompt/bots/dialog_bot.py b/samples/05.multi-turn-prompt/bots/dialog_bot.py deleted file mode 100644 index c66d73755..000000000 --- a/samples/05.multi-turn-prompt/bots/dialog_bot.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState -from botbuilder.dialogs import Dialog -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - """ - This Bot implementation can run any type of Dialog. The use of type parameterization is to allows multiple - different bots to be run at different endpoints within the same project. This can be achieved by defining distinct - Controller types each with dependency on distinct Bot types. The ConversationState is used by the Dialog system. The - UserState isn't, however, it might have been used in a Dialog implementation, and the requirement is that all - BotState objects are saved at the end of a turn. - """ - - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise TypeError( - "[DialogBot]: Missing parameter. conversation_state is required but None was given" - ) - if user_state is None: - raise TypeError( - "[DialogBot]: Missing parameter. user_state is required but None was given" - ) - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have ocurred during the turn. - await self.conversation_state.save_changes(turn_context) - await self.user_state.save_changes(turn_context) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/05.multi-turn-prompt/config.py b/samples/05.multi-turn-prompt/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/05.multi-turn-prompt/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/05.multi-turn-prompt/data_models/__init__.py b/samples/05.multi-turn-prompt/data_models/__init__.py deleted file mode 100644 index 35a5934d4..000000000 --- a/samples/05.multi-turn-prompt/data_models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .user_profile import UserProfile - -__all__ = ["UserProfile"] diff --git a/samples/05.multi-turn-prompt/data_models/user_profile.py b/samples/05.multi-turn-prompt/data_models/user_profile.py deleted file mode 100644 index efdc77eeb..000000000 --- a/samples/05.multi-turn-prompt/data_models/user_profile.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" - This is our application state. Just a regular serializable Python class. -""" - - -class UserProfile: - def __init__(self, name: str = None, transport: str = None, age: int = 0): - self.name = name - self.transport = transport - self.age = age diff --git a/samples/05.multi-turn-prompt/dialogs/__init__.py b/samples/05.multi-turn-prompt/dialogs/__init__.py deleted file mode 100644 index 2de723d58..000000000 --- a/samples/05.multi-turn-prompt/dialogs/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .user_profile_dialog import UserProfileDialog - -__all__ = ["UserProfileDialog"] diff --git a/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py b/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py deleted file mode 100644 index dad1f6d18..000000000 --- a/samples/05.multi-turn-prompt/dialogs/user_profile_dialog.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.dialogs.prompts import ( - TextPrompt, - NumberPrompt, - ChoicePrompt, - ConfirmPrompt, - PromptOptions, - PromptValidatorContext, -) -from botbuilder.dialogs.choices import Choice -from botbuilder.core import MessageFactory, UserState - -from data_models import UserProfile - - -class UserProfileDialog(ComponentDialog): - def __init__(self, user_state: UserState): - super(UserProfileDialog, self).__init__(UserProfileDialog.__name__) - - self.user_profile_accessor = user_state.create_property("UserProfile") - - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__, - [ - self.transport_step, - self.name_step, - self.name_confirm_step, - self.age_step, - self.confirm_step, - self.summary_step, - ], - ) - ) - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog( - NumberPrompt(NumberPrompt.__name__, UserProfileDialog.age_prompt_validator) - ) - self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) - self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - - self.initial_dialog_id = WaterfallDialog.__name__ - - async def transport_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - # WaterfallStep always finishes with the end of the Waterfall or with another dialog; - # here it is a Prompt Dialog. Running a prompt here means the next WaterfallStep will - # be run when the users response is received. - return await step_context.prompt( - ChoicePrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Please enter your mode of transport."), - choices=[Choice("Car"), Choice("Bus"), Choice("Bicycle")], - ), - ) - - async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - step_context.values["transport"] = step_context.result.value - - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions(prompt=MessageFactory.text("Please enter your name.")), - ) - - async def name_confirm_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - step_context.values["name"] = step_context.result - - # We can send messages to the user at any point in the WaterfallStep. - await step_context.context.send_activity( - MessageFactory.text(f"Thanks {step_context.result}") - ) - - # WaterfallStep always finishes with the end of the Waterfall or - # with another dialog; here it is a Prompt Dialog. - return await step_context.prompt( - ConfirmPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Would you like to give your age?") - ), - ) - - async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - if step_context.result: - # User said "yes" so we will be prompting for the age. - # WaterfallStep always finishes with the end of the Waterfall or with another dialog, - # here it is a Prompt Dialog. - return await step_context.prompt( - NumberPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Please enter your age."), - retry_prompt=MessageFactory.text( - "The value entered must be greater than 0 and less than 150." - ), - ), - ) - - # User said "no" so we will skip the next step. Give -1 as the age. - return await step_context.next(-1) - - async def confirm_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - age = step_context.result - step_context.values["age"] = step_context.result - - msg = ( - "No age given." - if step_context.result == -1 - else f"I have your age as {age}." - ) - - # We can send messages to the user at any point in the WaterfallStep. - await step_context.context.send_activity(MessageFactory.text(msg)) - - # WaterfallStep always finishes with the end of the Waterfall or - # with another dialog; here it is a Prompt Dialog. - return await step_context.prompt( - ConfirmPrompt.__name__, - PromptOptions(prompt=MessageFactory.text("Is this ok?")), - ) - - async def summary_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - if step_context.result: - # Get the current profile object from user state. Changes to it - # will saved during Bot.on_turn. - user_profile = await self.user_profile_accessor.get( - step_context.context, UserProfile - ) - - user_profile.transport = step_context.values["transport"] - user_profile.name = step_context.values["name"] - user_profile.age = step_context.values["age"] - - msg = f"I have your mode of transport as {user_profile.transport} and your name as {user_profile.name}." - if user_profile.age != -1: - msg += f" And age as {user_profile.age}." - - await step_context.context.send_activity(MessageFactory.text(msg)) - else: - await step_context.context.send_activity( - MessageFactory.text("Thanks. Your profile will not be kept.") - ) - - # WaterfallStep always finishes with the end of the Waterfall or with another - # dialog, here it is the end. - return await step_context.end_dialog() - - @staticmethod - async def age_prompt_validator(prompt_context: PromptValidatorContext) -> bool: - # This condition is our validation rule. You can also change the value at this point. - return ( - prompt_context.recognized.succeeded - and 0 < prompt_context.recognized.value < 150 - ) diff --git a/samples/05.multi-turn-prompt/helpers/__init__.py b/samples/05.multi-turn-prompt/helpers/__init__.py deleted file mode 100644 index a824eb8f4..000000000 --- a/samples/05.multi-turn-prompt/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import dialog_helper - -__all__ = ["dialog_helper"] diff --git a/samples/05.multi-turn-prompt/helpers/dialog_helper.py b/samples/05.multi-turn-prompt/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/05.multi-turn-prompt/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/05.multi-turn-prompt/requirements.txt b/samples/05.multi-turn-prompt/requirements.txt deleted file mode 100644 index 676447d22..000000000 --- a/samples/05.multi-turn-prompt/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -botbuilder-dialogs>=4.4.0.b1 -botbuilder-ai>=4.4.0.b1 -flask>=1.0.3 - diff --git a/samples/06.using-cards/README.md b/samples/06.using-cards/README.md deleted file mode 100644 index 7a0b31b06..000000000 --- a/samples/06.using-cards/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Using Cards Bot - -Bot Framework v4 using cards bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a bot that uses rich cards to enhance your bot design. - -## PREREQUISITES -- Python 3.7 or above - -## Running the sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` -- Run `pip install -r requirements.txt` to install all dependencies -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - -### Visual studio code -- Activate your desired virtual environment -- Open `botbuilder-python\samples\06.using-cards` folder -- Bring up a terminal, navigate to `botbuilder-python\samples\06.using-cards` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -# Adding media to messages -A message exchange between user and bot can contain media attachments, such as cards, images, video, audio, and files. - -There are several different card types supported by Bot Framework including: -- [Adaptive card](http://adaptivecards.io) -- [Hero card](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0#herocard-object) -- [Thumbnail card](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0#thumbnailcard-object) -- [More...](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-add-rich-cards?view=azure-bot-service-4.0) - -# Further reading - -- [Azure Bot Service Introduction](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) -- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?view=azure-bot-service-4.0) -- [Add media to messages](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-add-media-attachments?view=azure-bot-service-4.0&tabs=csharp) -- [Rich card types](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-add-rich-cards?view=azure-bot-service-4.0) diff --git a/samples/06.using-cards/app.py b/samples/06.using-cards/app.py deleted file mode 100644 index 257474898..000000000 --- a/samples/06.using-cards/app.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -This sample shows how to use different types of rich cards. -""" -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from dialogs import MainDialog -from bots import RichCardsBot - -# 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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - - -# Set the error handler on the Adapter. -# In this case, we want an unbound method, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage, UserState and ConversationState -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create dialog and Bot -DIALOG = MainDialog() -BOT = RichCardsBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -# Listen for incoming requests on /api/messages. -@APP.route("/api/messages", methods=["POST"]) -def messages(): - # Main bot message handler.s - 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/samples/06.using-cards/bots/__init__.py b/samples/06.using-cards/bots/__init__.py deleted file mode 100644 index 393acb3e7..000000000 --- a/samples/06.using-cards/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .rich_cards_bot import RichCardsBot - -__all__ = ["RichCardsBot"] diff --git a/samples/06.using-cards/bots/dialog_bot.py b/samples/06.using-cards/bots/dialog_bot.py deleted file mode 100644 index ff4473e85..000000000 --- a/samples/06.using-cards/bots/dialog_bot.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from helpers.dialog_helper import DialogHelper -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog - - -class DialogBot(ActivityHandler): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - self.dialog_state = self.conversation_state.create_property("DialogState") - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occured during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/06.using-cards/bots/rich_cards_bot.py b/samples/06.using-cards/bots/rich_cards_bot.py deleted file mode 100644 index 54da137db..000000000 --- a/samples/06.using-cards/bots/rich_cards_bot.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount -from .dialog_bot import DialogBot - - -class RichCardsBot(DialogBot): - """ - RichCardsBot prompts a user to select a Rich Card and then returns the card - that matches the user's selection. - """ - - def __init__(self, conversation_state, user_state, dialog): - super().__init__(conversation_state, user_state, dialog) - - async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - reply = MessageFactory.text( - "Welcome to CardBot. " - + "This bot will show you different types of Rich Cards. " - + "Please type anything to get started." - ) - await turn_context.send_activity(reply) diff --git a/samples/06.using-cards/config.py b/samples/06.using-cards/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/06.using-cards/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/06.using-cards/dialogs/__init__.py b/samples/06.using-cards/dialogs/__init__.py deleted file mode 100644 index 74d870b7c..000000000 --- a/samples/06.using-cards/dialogs/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .main_dialog import MainDialog - -__all__ = ["MainDialog"] diff --git a/samples/06.using-cards/dialogs/main_dialog.py b/samples/06.using-cards/dialogs/main_dialog.py deleted file mode 100644 index 9490933e7..000000000 --- a/samples/06.using-cards/dialogs/main_dialog.py +++ /dev/null @@ -1,298 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import CardFactory, MessageFactory -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, -) -from botbuilder.dialogs.prompts import TextPrompt, PromptOptions -from botbuilder.schema import ( - ActionTypes, - Attachment, - AnimationCard, - AudioCard, - HeroCard, - VideoCard, - ReceiptCard, - SigninCard, - ThumbnailCard, - MediaUrl, - CardAction, - CardImage, - ThumbnailUrl, - Fact, - ReceiptItem, -) - -from helpers.activity_helper import create_activity_reply -from .resources.adaptive_card_example import ADAPTIVE_CARD_CONTENT - -MAIN_WATERFALL_DIALOG = "mainWaterfallDialog" - - -class MainDialog(ComponentDialog): - def __init__(self): - super().__init__("MainDialog") - - # Define the main dialog and its related components. - self.add_dialog(TextPrompt("TextPrompt")) - self.add_dialog( - WaterfallDialog( - MAIN_WATERFALL_DIALOG, [self.choice_card_step, self.show_card_step] - ) - ) - - # The initial child Dialog to run. - self.initial_dialog_id = MAIN_WATERFALL_DIALOG - - async def choice_card_step(self, step_context: WaterfallStepContext): - """ - 1. Prompts the user if the user is not in the middle of a dialog. - 2. Re-prompts the user when an invalid input is received. - """ - menu_text = ( - "Which card would you like to see?\n" - "(1) Adaptive Card\n" - "(2) Animation Card\n" - "(3) Audio Card\n" - "(4) Hero Card\n" - "(5) Receipt Card\n" - "(6) Signin Card\n" - "(7) Thumbnail Card\n" - "(8) Video Card\n" - "(9) All Cards" - ) - - # Prompt the user with the configured PromptOptions. - return await step_context.prompt( - "TextPrompt", PromptOptions(prompt=MessageFactory.text(menu_text)) - ) - - async def show_card_step(self, step_context: WaterfallStepContext): - """ - Send a Rich Card response to the user based on their choice. - self method is only called when a valid prompt response is parsed from the user's - response to the ChoicePrompt. - """ - response = step_context.result.lower().strip() - choice_dict = { - "1": [self.create_adaptive_card], - "adaptive card": [self.create_adaptive_card], - "2": [self.create_animation_card], - "animation card": [self.create_animation_card], - "3": [self.create_audio_card], - "audio card": [self.create_audio_card], - "4": [self.create_hero_card], - "hero card": [self.create_hero_card], - "5": [self.create_receipt_card], - "receipt card": [self.create_receipt_card], - "6": [self.create_signin_card], - "signin card": [self.create_signin_card], - "7": [self.create_thumbnail_card], - "thumbnail card": [self.create_thumbnail_card], - "8": [self.create_video_card], - "video card": [self.create_video_card], - "9": [ - self.create_adaptive_card, - self.create_animation_card, - self.create_audio_card, - self.create_hero_card, - self.create_receipt_card, - self.create_signin_card, - self.create_thumbnail_card, - self.create_video_card, - ], - "all cards": [ - self.create_adaptive_card, - self.create_animation_card, - self.create_audio_card, - self.create_hero_card, - self.create_receipt_card, - self.create_signin_card, - self.create_thumbnail_card, - self.create_video_card, - ], - } - - # Get the functions that will generate the card(s) for our response - # If the stripped response from the user is not found in our choice_dict, default to None - choice = choice_dict.get(response, None) - # If the user's choice was not found, respond saying the bot didn't understand the user's response. - if not choice: - not_found = create_activity_reply( - step_context.context.activity, "Sorry, I didn't understand that. :(" - ) - await step_context.context.send_activity(not_found) - else: - for func in choice: - card = func() - response = create_activity_reply( - step_context.context.activity, "", "", [card] - ) - await step_context.context.send_activity(response) - - # Give the user instructions about what to do next - await step_context.context.send_activity("Type anything to see another card.") - - return await step_context.end_dialog() - - # ====================================== - # Helper functions used to create cards. - # ====================================== - - # Methods to generate cards - def create_adaptive_card(self) -> Attachment: - return CardFactory.adaptive_card(ADAPTIVE_CARD_CONTENT) - - def create_animation_card(self) -> Attachment: - card = AnimationCard( - media=[MediaUrl(url="http://i.giphy.com/Ki55RUbOV5njy.gif")], - title="Microsoft Bot Framework", - subtitle="Animation Card", - ) - return CardFactory.animation_card(card) - - def create_audio_card(self) -> Attachment: - card = AudioCard( - media=[MediaUrl(url="http://www.wavlist.com/movies/004/father.wav")], - title="I am your father", - subtitle="Star Wars: Episode V - The Empire Strikes Back", - text="The Empire Strikes Back (also known as Star Wars: Episode V – The Empire Strikes " - "Back) is a 1980 American epic space opera film directed by Irvin Kershner. Leigh " - "Brackett and Lawrence Kasdan wrote the screenplay, with George Lucas writing the " - "film's story and serving as executive producer. The second installment in the " - "original Star Wars trilogy, it was produced by Gary Kurtz for Lucasfilm Ltd. and " - "stars Mark Hamill, Harrison Ford, Carrie Fisher, Billy Dee Williams, Anthony " - "Daniels, David Prowse, Kenny Baker, Peter Mayhew and Frank Oz.", - image=ThumbnailUrl( - url="https://upload.wikimedia.org/wikipedia/en/3/3c/SW_-_Empire_Strikes_Back.jpg" - ), - buttons=[ - CardAction( - type=ActionTypes.open_url, - title="Read more", - value="https://en.wikipedia.org/wiki/The_Empire_Strikes_Back", - ) - ], - ) - return CardFactory.audio_card(card) - - def create_hero_card(self) -> Attachment: - card = HeroCard( - title="", - images=[ - CardImage( - url="https://sec.ch9.ms/ch9/7ff5/e07cfef0-aa3b-40bb-9baa-7c9ef8ff7ff5/buildreactionbotframework_960.jpg" - ) - ], - buttons=[ - CardAction( - type=ActionTypes.open_url, - title="Get Started", - value="https://docs.microsoft.com/en-us/azure/bot-service/", - ) - ], - ) - return CardFactory.hero_card(card) - - def create_video_card(self) -> Attachment: - card = VideoCard( - title="Big Buck Bunny", - subtitle="by the Blender Institute", - text="Big Buck Bunny (code-named Peach) is a short computer-animated comedy film by the Blender " - "Institute, part of the Blender Foundation. Like the foundation's previous film Elephants " - "Dream, the film was made using Blender, a free software application for animation made by " - "the same foundation. It was released as an open-source film under Creative Commons License " - "Attribution 3.0.", - media=[ - MediaUrl( - url="http://download.blender.org/peach/bigbuckbunny_movies/" - "BigBuckBunny_320x180.mp4" - ) - ], - buttons=[ - CardAction( - type=ActionTypes.open_url, - title="Learn More", - value="https://peach.blender.org/", - ) - ], - ) - return CardFactory.video_card(card) - - def create_receipt_card(self) -> Attachment: - card = ReceiptCard( - title="John Doe", - facts=[ - Fact(key="Order Number", value="1234"), - Fact(key="Payment Method", value="VISA 5555-****"), - ], - items=[ - ReceiptItem( - title="Data Transfer", - price="$38.45", - quantity="368", - image=CardImage( - url="https://github.com/amido/azure-vector-icons/raw/master/" - "renders/traffic-manager.png" - ), - ), - ReceiptItem( - title="App Service", - price="$45.00", - quantity="720", - image=CardImage( - url="https://github.com/amido/azure-vector-icons/raw/master/" - "renders/cloud-service.png" - ), - ), - ], - tax="$7.50", - total="90.95", - buttons=[ - CardAction( - type=ActionTypes.open_url, - title="More Information", - value="https://azure.microsoft.com/en-us/pricing/details/bot-service/", - ) - ], - ) - return CardFactory.receipt_card(card) - - def create_signin_card(self) -> Attachment: - card = SigninCard( - text="BotFramework Sign-in Card", - buttons=[ - CardAction( - type=ActionTypes.signin, - title="Sign-in", - value="https://login.microsoftonline.com", - ) - ], - ) - return CardFactory.signin_card(card) - - def create_thumbnail_card(self) -> Attachment: - card = ThumbnailCard( - title="BotFramework Thumbnail Card", - subtitle="Your bots — wherever your users are talking", - text="Build and connect intelligent bots to interact with your users naturally wherever" - " they are, from text/sms to Skype, Slack, Office 365 mail and other popular services.", - images=[ - CardImage( - url="https://sec.ch9.ms/ch9/7ff5/" - "e07cfef0-aa3b-40bb-9baa-7c9ef8ff7ff5/" - "buildreactionbotframework_960.jpg" - ) - ], - buttons=[ - CardAction( - type=ActionTypes.open_url, - title="Get Started", - value="https://docs.microsoft.com/en-us/azure/bot-service/", - ) - ], - ) - return CardFactory.thumbnail_card(card) diff --git a/samples/06.using-cards/dialogs/resources/__init__.py b/samples/06.using-cards/dialogs/resources/__init__.py deleted file mode 100644 index 7569a0e37..000000000 --- a/samples/06.using-cards/dialogs/resources/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import adaptive_card_example - -__all__ = ["adaptive_card_example"] diff --git a/samples/06.using-cards/dialogs/resources/adaptive_card_example.py b/samples/06.using-cards/dialogs/resources/adaptive_card_example.py deleted file mode 100644 index 49cf269b8..000000000 --- a/samples/06.using-cards/dialogs/resources/adaptive_card_example.py +++ /dev/null @@ -1,186 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Example content for an AdaptiveCard.""" - -ADAPTIVE_CARD_CONTENT = { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "version": "1.0", - "type": "AdaptiveCard", - "speak": "Your flight is confirmed for you and 3 other passengers from San Francisco to Amsterdam on Friday, October 10 8:30 AM", - "body": [ - { - "type": "TextBlock", - "text": "Passengers", - "weight": "bolder", - "isSubtle": False, - }, - {"type": "TextBlock", "text": "Sarah Hum", "separator": True}, - {"type": "TextBlock", "text": "Jeremy Goldberg", "spacing": "none"}, - {"type": "TextBlock", "text": "Evan Litvak", "spacing": "none"}, - { - "type": "TextBlock", - "text": "2 Stops", - "weight": "bolder", - "spacing": "medium", - }, - { - "type": "TextBlock", - "text": "Fri, October 10 8:30 AM", - "weight": "bolder", - "spacing": "none", - }, - { - "type": "ColumnSet", - "separator": True, - "columns": [ - { - "type": "Column", - "width": 1, - "items": [ - { - "type": "TextBlock", - "text": "San Francisco", - "isSubtle": True, - }, - { - "type": "TextBlock", - "size": "extraLarge", - "color": "accent", - "text": "SFO", - "spacing": "none", - }, - ], - }, - { - "type": "Column", - "width": "auto", - "items": [ - {"type": "TextBlock", "text": " "}, - { - "type": "Image", - "url": "http://messagecardplayground.azurewebsites.net/assets/airplane.png", - "size": "small", - "spacing": "none", - }, - ], - }, - { - "type": "Column", - "width": 1, - "items": [ - { - "type": "TextBlock", - "horizontalAlignment": "right", - "text": "Amsterdam", - "isSubtle": True, - }, - { - "type": "TextBlock", - "horizontalAlignment": "right", - "size": "extraLarge", - "color": "accent", - "text": "AMS", - "spacing": "none", - }, - ], - }, - ], - }, - { - "type": "TextBlock", - "text": "Non-Stop", - "weight": "bolder", - "spacing": "medium", - }, - { - "type": "TextBlock", - "text": "Fri, October 18 9:50 PM", - "weight": "bolder", - "spacing": "none", - }, - { - "type": "ColumnSet", - "separator": True, - "columns": [ - { - "type": "Column", - "width": 1, - "items": [ - {"type": "TextBlock", "text": "Amsterdam", "isSubtle": True}, - { - "type": "TextBlock", - "size": "extraLarge", - "color": "accent", - "text": "AMS", - "spacing": "none", - }, - ], - }, - { - "type": "Column", - "width": "auto", - "items": [ - {"type": "TextBlock", "text": " "}, - { - "type": "Image", - "url": "http://messagecardplayground.azurewebsites.net/assets/airplane.png", - "size": "small", - "spacing": "none", - }, - ], - }, - { - "type": "Column", - "width": 1, - "items": [ - { - "type": "TextBlock", - "horizontalAlignment": "right", - "text": "San Francisco", - "isSubtle": True, - }, - { - "type": "TextBlock", - "horizontalAlignment": "right", - "size": "extraLarge", - "color": "accent", - "text": "SFO", - "spacing": "none", - }, - ], - }, - ], - }, - { - "type": "ColumnSet", - "spacing": "medium", - "columns": [ - { - "type": "Column", - "width": "1", - "items": [ - { - "type": "TextBlock", - "text": "Total", - "size": "medium", - "isSubtle": True, - } - ], - }, - { - "type": "Column", - "width": 1, - "items": [ - { - "type": "TextBlock", - "horizontalAlignment": "right", - "text": "$4,032.54", - "size": "medium", - "weight": "bolder", - } - ], - }, - ], - }, - ], -} diff --git a/samples/06.using-cards/helpers/__init__.py b/samples/06.using-cards/helpers/__init__.py deleted file mode 100644 index 135279f61..000000000 --- a/samples/06.using-cards/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import activity_helper, dialog_helper - -__all__ = ["activity_helper", "dialog_helper"] diff --git a/samples/06.using-cards/helpers/activity_helper.py b/samples/06.using-cards/helpers/activity_helper.py deleted file mode 100644 index 354317c3e..000000000 --- a/samples/06.using-cards/helpers/activity_helper.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datetime import datetime -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, -) - - -def create_activity_reply( - activity: Activity, - text: str = None, - locale: str = None, - attachments=None, -): - if attachments is None: - attachments = [] - attachments_aux = attachments.copy() - - return Activity( - type=ActivityTypes.message, - timestamp=datetime.utcnow(), - from_property=ChannelAccount( - id=getattr(activity.recipient, "id", None), - name=getattr(activity.recipient, "name", None), - ), - 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 "", - locale=locale or "", - attachments=attachments_aux, - entities=[], - ) diff --git a/samples/06.using-cards/helpers/dialog_helper.py b/samples/06.using-cards/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/06.using-cards/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/06.using-cards/requirements.txt b/samples/06.using-cards/requirements.txt deleted file mode 100644 index e44abb535..000000000 --- a/samples/06.using-cards/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -botbuilder-core>=4.4.0b1 -botbuilder-dialogs>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/08.suggested-actions/README.md b/samples/08.suggested-actions/README.md deleted file mode 100644 index 4e0e76ebb..000000000 --- a/samples/08.suggested-actions/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# suggested actions - -Bot Framework v4 using adaptive cards bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use suggested actions. Suggested actions enable your bot to present buttons that the user can tap to provide input. - -## Running the sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` -- Bring up a terminal, navigate to `botbuilder-python\samples\08.suggested-actions` 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 - -## Suggested actions - -Suggested actions enable your bot to present buttons that the user can tap to provide input. Suggested actions appear close to the composer and enhance user experience. diff --git a/samples/08.suggested-actions/app.py b/samples/08.suggested-actions/app.py deleted file mode 100644 index 1504563d3..000000000 --- a/samples/08.suggested-actions/app.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - BotFrameworkAdapter, - TurnContext, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import SuggestActionsBot - -# 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 Bot -BOT = SuggestActionsBot() - - -# 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 diff --git a/samples/08.suggested-actions/bots/__init__.py b/samples/08.suggested-actions/bots/__init__.py deleted file mode 100644 index cbf771a32..000000000 --- a/samples/08.suggested-actions/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .suggested_actions_bot import SuggestActionsBot - -__all__ = ["SuggestActionsBot"] diff --git a/samples/08.suggested-actions/bots/suggested_actions_bot.py b/samples/08.suggested-actions/bots/suggested_actions_bot.py deleted file mode 100644 index 3daa70d5e..000000000 --- a/samples/08.suggested-actions/bots/suggested_actions_bot.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, SuggestedActions - - -class SuggestActionsBot(ActivityHandler): - """ - This bot will respond to the user's input with suggested actions. - Suggested actions enable your bot to present buttons that the user - can tap to provide input. - """ - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - """ - Send a welcome message to the user and tell them what actions they may perform to use this bot - """ - - return await self._send_welcome_message(turn_context) - - async def on_message_activity(self, turn_context: TurnContext): - """ - Respond to the users choice and display the suggested actions again. - """ - - text = turn_context.activity.text.lower() - response_text = self._process_input(text) - - await turn_context.send_activity(MessageFactory.text(response_text)) - - return await self._send_suggested_actions(turn_context) - - async def _send_welcome_message(self, turn_context: TurnContext): - for member in turn_context.activity.members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - MessageFactory.text( - f"Welcome to SuggestedActionsBot {member.name}." - f" This bot will introduce you to suggestedActions." - f" Please answer the question: " - ) - ) - - await self._send_suggested_actions(turn_context) - - def _process_input(self, text: str): - color_text = "is the best color, I agree." - - if text == "red": - return f"Red {color_text}" - - if text == "yellow": - return f"Yellow {color_text}" - - if text == "blue": - return f"Blue {color_text}" - - return "Please select a color from the suggested action choices" - - async def _send_suggested_actions(self, turn_context: TurnContext): - """ - Creates and sends an activity with suggested actions to the user. When the user - clicks one of the buttons the text value from the "CardAction" will be displayed - in the channel just as if the user entered the text. There are multiple - "ActionTypes" that may be used for different situations. - """ - - reply = MessageFactory.text("What is your favorite color?") - - reply.suggested_actions = SuggestedActions( - actions=[ - CardAction(title="Red", type=ActionTypes.im_back, value="Red"), - CardAction(title="Yellow", type=ActionTypes.im_back, value="Yellow"), - CardAction(title="Blue", type=ActionTypes.im_back, value="Blue"), - ] - ) - - return await turn_context.send_activity(reply) diff --git a/samples/08.suggested-actions/config.py b/samples/08.suggested-actions/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/08.suggested-actions/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/08.suggested-actions/requirements.txt b/samples/08.suggested-actions/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/08.suggested-actions/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/11.qnamaker/README.md b/samples/11.qnamaker/README.md deleted file mode 100644 index 27edff425..000000000 --- a/samples/11.qnamaker/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# QnA Maker - -Bot Framework v4 QnA Maker bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a bot that uses the [QnA Maker Cognitive AI](https://www.qnamaker.ai) service. - -The [QnA Maker Service](https://www.qnamaker.ai) enables you to build, train and publish a simple question and answer bot based on FAQ URLs, structured documents or editorial content in minutes. In this sample, we demonstrate how to use the QnA Maker service to answer questions based on a FAQ text file used as input. - -## Prerequisites - -This samples **requires** prerequisites in order to run. - -### Overview - -This bot uses [QnA Maker Service](https://www.qnamaker.ai), an AI based cognitive service, to implement simple Question and Answer conversational patterns. - -### Create a QnAMaker Application to enable QnA Knowledge Bases - -QnA knowledge base setup and application configuration steps can be found [here](https://aka.ms/qna-instructions). - -## 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\11.qnamaker` folder -- In the terminal, type `pip install -r requirements.txt` -- Update `QNA_KNOWLEDGEBASE_ID`, `QNA_ENDPOINT_KEY`, and `QNA_ENDPOINT_HOST` in `config.py` -- 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 - -## QnA Maker service - -QnA Maker enables you to power a question and answer service from your semi-structured content. - -One of the basic requirements in writing your own bot is to seed it with questions and answers. In many cases, the questions and answers already exist in content like FAQ URLs/documents, product manuals, etc. With QnA Maker, users can query your application in a natural, conversational manner. QnA Maker uses machine learning to extract relevant question-answer pairs from your content. It also uses powerful matching and ranking algorithms to provide the best possible match between the user query and the questions. - -## 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) -- [QnA Maker Documentation](https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/overview/overview) -- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) -- [QnA Maker CLI](https://github.com/Microsoft/botbuilder-tools/tree/master/packages/QnAMaker) -- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) -- [Azure Portal](https://portal.azure.com) diff --git a/samples/11.qnamaker/app.py b/samples/11.qnamaker/app.py deleted file mode 100644 index 1f8c6f97f..000000000 --- a/samples/11.qnamaker/app.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -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 QnABot - -# 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 = QnABot(app.config) - -# 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 diff --git a/samples/11.qnamaker/bots/__init__.py b/samples/11.qnamaker/bots/__init__.py deleted file mode 100644 index 457940100..000000000 --- a/samples/11.qnamaker/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .qna_bot import QnABot - -__all__ = ["QnABot"] diff --git a/samples/11.qnamaker/bots/qna_bot.py b/samples/11.qnamaker/bots/qna_bot.py deleted file mode 100644 index 8ff589e07..000000000 --- a/samples/11.qnamaker/bots/qna_bot.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from flask import Config - -from botbuilder.ai.qna import QnAMaker, QnAMakerEndpoint -from botbuilder.core import ActivityHandler, MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount - - -class QnABot(ActivityHandler): - def __init__(self, config: Config): - self.qna_maker = QnAMaker( - QnAMakerEndpoint( - knowledge_base_id=config["QNA_KNOWLEDGEBASE_ID"], - endpoint_key=config["QNA_ENDPOINT_KEY"], - host=config["QNA_ENDPOINT_HOST"], - ) - ) - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - "Welcome to the QnA Maker sample! Ask me a question and I will try " - "to answer it." - ) - - async def on_message_activity(self, turn_context: TurnContext): - # The actual call to the QnA Maker service. - response = await self.qna_maker.get_answers(turn_context) - if response and len(response) > 0: - await turn_context.send_activity(MessageFactory.text(response[0].answer)) - else: - await turn_context.send_activity("No QnA Maker answers were found.") diff --git a/samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv b/samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv deleted file mode 100644 index 754118909..000000000 --- a/samples/11.qnamaker/cognitiveModels/smartLightFAQ.tsv +++ /dev/null @@ -1,15 +0,0 @@ -Question Answer Source Keywords -Question Answer 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Source -My Contoso smart light won't turn on. Check the connection to the wall outlet to make sure it's plugged in properly. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -Light won't turn on. Check the connection to the wall outlet to make sure it's plugged in properly. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -My smart light app stopped responding. Restart the app. If the problem persists, contact support. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -How do I contact support? Email us at service@contoso.com 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -I need help. Email us at service@contoso.com 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -I upgraded the app and it doesn't work anymore. When you upgrade, you need to disable Bluetooth, then re-enable it. After re-enable, re-pair your light with the app. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -Light doesn't work after upgrade. When you upgrade, you need to disable Bluetooth, then re-enable it. After re-enable, re-pair your light with the app. 96207418-4609-48df-9cbc-dd35a71e83f7-KB.tsv Editorial -Question Answer 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Source -Who should I contact for customer service? Please direct all customer service questions to (202) 555-0164 \n 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial -Why does the light not work? The simplest way to troubleshoot your smart light is to turn it off and on. \n 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial -How long does the light's battery last for? The battery will last approximately 10 - 12 weeks with regular use. \n 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial -What type of light bulb do I need? A 26-Watt compact fluorescent light bulb that features both energy savings and long-life performance. 890b1efc-27ad-4dca-8dcb-b20d29d50e14-KB.tsv Editorial -Hi Hello Editorial \ No newline at end of file diff --git a/samples/11.qnamaker/config.py b/samples/11.qnamaker/config.py deleted file mode 100644 index 068a30d35..000000000 --- a/samples/11.qnamaker/config.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/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", "") - QNA_KNOWLEDGEBASE_ID = os.environ.get("QnAKnowledgebaseId", "") - QNA_ENDPOINT_KEY = os.environ.get("QnAEndpointKey", "") - QNA_ENDPOINT_HOST = os.environ.get("QnAEndpointHostName", "") diff --git a/samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json b/samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/11.qnamaker/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/11.qnamaker/requirements.txt b/samples/11.qnamaker/requirements.txt deleted file mode 100644 index cf76fec34..000000000 --- a/samples/11.qnamaker/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -botbuilder-core>=4.4.0b1 -botbuilder-ai>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/13.core-bot/README-LUIS.md b/samples/13.core-bot/README-LUIS.md deleted file mode 100644 index b6b9b925f..000000000 --- a/samples/13.core-bot/README-LUIS.md +++ /dev/null @@ -1,216 +0,0 @@ -# Setting up LUIS via CLI: - -This README contains information on how to create and deploy a LUIS application. When the bot is ready to be deployed to production, we recommend creating a LUIS Endpoint Resource for usage with your LUIS App. - -> _For instructions on how to create a LUIS Application via the LUIS portal, see these Quickstart steps:_ -> 1. _[Quickstart: Create a new app in the LUIS portal][Quickstart-create]_ -> 2. _[Quickstart: Deploy an app in the LUIS portal][Quickstart-deploy]_ - - [Quickstart-create]: https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-build-app - [Quickstart-deploy]:https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-deploy-app - -## Table of Contents: - -- [Prerequisites](#Prerequisites) -- [Import a new LUIS Application using a local LUIS application](#Import-a-new-LUIS-Application-using-a-local-LUIS-application) -- [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#How-to-create-a-LUIS-Endpoint-resource-in-Azure-and-pair-it-with-a-LUIS-Application) - -___ - -## [Prerequisites](#Table-of-Contents): - -#### Install Azure CLI >=2.0.61: - -Visit the following page to find the correct installer for your OS: -- https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest - -#### Install LUIS CLI >=2.4.0: - -Open a CLI of your choice and type the following: - -```bash -npm i -g luis-apis@^2.4.0 -``` - -#### LUIS portal account: - -You should already have a LUIS account with either https://luis.ai, https://eu.luis.ai, or https://au.luis.ai. To determine where to create a LUIS account, consider where you will deploy your LUIS applications, and then place them in [the corresponding region][LUIS-Authoring-Regions]. - -After you've created your account, you need your [Authoring Key][LUIS-AKey] and a LUIS application ID. - - [LUIS-Authoring-Regions]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-reference-regions#luis-authoring-regions] - [LUIS-AKey]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-concept-keys#authoring-key - -___ - -## [Import a new LUIS Application using a local LUIS application](#Table-of-Contents) - -### 1. Import the local LUIS application to luis.ai - -```bash -luis import application --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appName "FlightBooking" --in "./cognitiveModels/FlightBooking.json" -``` - -Outputs the following JSON: - -```json -{ - "id": "########-####-####-####-############", - "name": "FlightBooking", - "description": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "usageScenario": "", - "domain": "", - "versionsCount": 1, - "createdDateTime": "2019-03-29T18:32:02Z", - "endpoints": {}, - "endpointHitsCount": 0, - "activeVersion": "0.1", - "ownerEmail": "bot@contoso.com", - "tokenizerVersion": "1.0.0" -} -``` - -For the next step, you'll need the `"id"` value for `--appId` and the `"activeVersion"` value for `--versionId`. - -### 2. Train the LUIS Application - -```bash -luis train version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --wait -``` - -### 3. Publish the LUIS Application - -```bash -luis publish version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --publishRegion "LuisAppPublishRegion" -``` - -> `--region` corresponds to the region you _author_ your application in. The regions available for this are "westus", "westeurope" and "australiaeast".
-> These regions correspond to the three available portals, https://luis.ai, https://eu.luis.ai, or https://au.luis.ai.
-> `--publishRegion` corresponds to the region of the endpoint you're publishing to, (e.g. "westus", "southeastasia", "westeurope", "brazilsouth").
-> See the [reference docs][Endpoint-API] for a list of available publish/endpoint regions. - - [Endpoint-API]: https://westus.dev.cognitive.microsoft.com/docs/services/5819c76f40a6350ce09de1ac/operations/5819c77140a63516d81aee78 - -Outputs the following: - -```json - { - "versionId": "0.1", - "isStaging": false, - "endpointUrl": "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/########-####-####-####-############", - "region": "westus", - "assignedEndpointKey": null, - "endpointRegion": "westus", - "failedRegions": "", - "publishedDateTime": "2019-03-29T18:40:32Z", - "directVersionPublish": false -} -``` - -To see how to create an LUIS Cognitive Service Resource in Azure, please see [the next README][README-LUIS]. This Resource should be used when you want to move your bot to production. The instructions will show you how to create and pair the resource with a LUIS Application. - - [README-LUIS]: ./README-LUIS.md - -___ - -## [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#Table-of-Contents) - -### 1. Create a new LUIS Cognitive Services resource on Azure via Azure CLI - -> _Note:_
-> _If you don't have a Resource Group in your Azure subscription, you can create one through the Azure portal or through using:_ -> ```bash -> az group create --subscription "AzureSubscriptionGuid" --location "westus" --name "ResourceGroupName" -> ``` -> _To see a list of valid locations, use `az account list-locations`_ - - -```bash -# Use Azure CLI to create the LUIS Key resource on Azure -az cognitiveservices account create --kind "luis" --name "NewLuisResourceName" --sku "S0" --location "westus" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -The command will output a response similar to the JSON below: - -```json -{ - "endpoint": "https://westus.api.cognitive.microsoft.com/luis/v2.0", - "etag": "\"########-####-####-####-############\"", - "id": "/subscriptions/########-####-####-####-############/resourceGroups/ResourceGroupName/providers/Microsoft.CognitiveServices/accounts/NewLuisResourceName", - "internalId": "################################", - "kind": "luis", - "location": "westus", - "name": "NewLuisResourceName", - "provisioningState": "Succeeded", - "resourceGroup": "ResourceGroupName", - "sku": { - "name": "S0", - "tier": null - }, - "tags": null, - "type": "Microsoft.CognitiveServices/accounts" -} -``` - - - -Take the output from the previous command and create a JSON file in the following format: - -```json -{ - "azureSubscriptionId": "00000000-0000-0000-0000-000000000000", - "resourceGroup": "ResourceGroupName", - "accountName": "NewLuisResourceName" -} -``` - -### 2. Retrieve ARM access token via Azure CLI - -```bash -az account get-access-token --subscription "AzureSubscriptionGuid" -``` - -This will return an object that looks like this: - -```json -{ - "accessToken": "eyJ0eXAiOiJKVtokentokentokentokentokeng1dCI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyIsItokenI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTUzODc3MTUwLCJuYmYiOjE1NTM4NzcxNTAsImV4cCI6MTU1Mzg4MTA1MCwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2ZmZTQyM2RkLWJhM2YtNDg0Ny04NjgyLWExNTI5MDA4MjM4Ny9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVZRQXEvOEtBQUFBeGVUc201NDlhVHg4RE1mMFlRVnhGZmxxOE9RSC9PODR3QktuSmRqV1FqTkkwbmxLYzB0bHJEZzMyMFZ5bWZGaVVBSFBvNUFFUTNHL0FZNDRjdk01T3M0SEt0OVJkcE5JZW9WU0dzd0kvSkk9IiwiYW1yIjpbIndpYSIsIm1mYSJdLCJhcHBpZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiNDhmNDVjNjEtMTg3Zi00MjUxLTlmZWItMTllZGFkZmMwMmE3IiwiZmFtaWx5X25hbWUiOiJHdW0iLCJnaXZlbl9uYW1lIjoiU3RldmVuIiwiaXBhZGRyIjoiMTY3LjIyMC4yLjU1IiwibmFtZSI6IlN0ZXZlbiBHdW0iLCJvaWQiOiJmZmU0MjNkZC1iYTNmLTQ4NDctODY4Mi1hMTUyOTAwODIzODciLCJvbnByZW1fc2lkIjoiUy0xLTUtMjEtMjEyNzUyMTE4NC0xNjA0MDEyOTIwLTE4ODc5Mjc1MjctMjYwOTgyODUiLCJwdWlkIjoiMTAwMzdGRkVBMDQ4NjlBNyIsInJoIjoiSSIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ik1rMGRNMWszN0U5ckJyMjhieUhZYjZLSU85LXVFQVVkZFVhNWpkSUd1Nk0iLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InN0Z3VtQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzdGd1bUBtaWNyb3NvZnQuY29tIiwidXRpIjoiT2w2NGN0TXY4RVNEQzZZQWRqRUFtokenInZlciI6IjEuMCJ9.kFAsEilE0mlS1pcpqxf4rEnRKeYsehyk-gz-zJHUrE__oad3QjgDSBDPrR_ikLdweynxbj86pgG4QFaHURNCeE6SzrbaIrNKw-n9jrEtokenlosOxg_0l2g1LeEUOi5Q4gQREAU_zvSbl-RY6sAadpOgNHtGvz3Rc6FZRITfkckSLmsKAOFoh-aWC6tFKG8P52rtB0qVVRz9tovBeNqkMYL49s9ypduygbXNVwSQhm5JszeWDgrFuVFHBUP_iENCQYGQpEZf_KvjmX1Ur1F9Eh9nb4yI2gFlKncKNsQl-tokenK7-tokentokentokentokentokentokenatoken", - "expiresOn": "2200-12-31 23:59:59.999999", - "subscription": "AzureSubscriptionGuid", - "tenant": "tenant-guid", - "tokenType": "Bearer" -} -``` - -The value needed for the next step is the `"accessToken"`. - -### 3. Use `luis add appazureaccount` to pair your LUIS resource with a LUIS Application - -```bash -luis add appazureaccount --in "path/to/created/requestBody.json" --appId "LuisAppId" --authoringKey "LuisAuthoringKey" --armToken "accessToken" -``` - -If successful, it should yield a response like this: - -```json -{ - "code": "Success", - "message": "Operation Successful" -} -``` - -### 4. See the LUIS Cognitive Services' keys - -```bash -az cognitiveservices account keys list --name "NewLuisResourceName" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -This will return an object that looks like this: - -```json -{ - "key1": "9a69####dc8f####8eb4####399f####", - "key2": "####f99e####4b1a####fb3b####6b9f" -} -``` \ No newline at end of file diff --git a/samples/13.core-bot/README.md b/samples/13.core-bot/README.md deleted file mode 100644 index 01bfd900c..000000000 --- a/samples/13.core-bot/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# CoreBot - -Bot Framework v4 core bot sample. - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to: - -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities -- Implement a multi-turn conversation using Dialogs -- Handle user interruptions for such things as `Help` or `Cancel` -- Prompt for and validate requests for information from the user - -## Prerequisites - -This sample **requires** prerequisites in order to run. - -### Overview - -This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding. - -### Install Python 3.7 - - -### Create a LUIS Application to enable language understanding - -LUIS language model setup, training, and application configuration steps can be found [here](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=cs). - -If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). - -## Running the sample -- Run `pip install -r requirements.txt` to install all dependencies -- Update LuisAppId, LuisAPIKey and LuisAPIHostName in `config.py` with the information retrieved from the [LUIS portal](https://www.luis.ai) -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -## 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 -- Enter a Bot URL of `http://localhost:3978/api/messages` - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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) -- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/13.core-bot/adapter_with_error_handler.py b/samples/13.core-bot/adapter_with_error_handler.py deleted file mode 100644 index 1826e1e47..000000000 --- a/samples/13.core-bot/adapter_with_error_handler.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -import sys -from datetime import datetime - -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - TurnContext, -) -from botbuilder.schema import ActivityTypes, Activity - - -class AdapterWithErrorHandler(BotFrameworkAdapter): - def __init__( - self, - settings: BotFrameworkAdapterSettings, - conversation_state: ConversationState, - ): - super().__init__(settings) - self._conversation_state = conversation_state - - # Catch-all for errors. - async def on_error(context: TurnContext, error: Exception): - # This check writes out errors to console log - # 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) - - # Clear out state - nonlocal self - await self._conversation_state.delete(context) - - self.on_turn_error = on_error diff --git a/samples/13.core-bot/app.py b/samples/13.core-bot/app.py deleted file mode 100644 index d09b2d991..000000000 --- a/samples/13.core-bot/app.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -This sample shows how to create a bot that demonstrates the following: -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. -- Implement a multi-turn conversation using Dialogs. -- Handle user interruptions for such things as `Help` or `Cancel`. -- Prompt for and validate requests for information from the user. -""" - -import asyncio - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - UserState, -) -from botbuilder.schema import Activity -from dialogs import MainDialog, BookingDialog -from bots import DialogAndWelcomeBot - -from adapter_with_error_handler import AdapterWithErrorHandler -from flight_booking_recognizer import FlightBookingRecognizer - -# Create the loop and Flask app -LOOP = asyncio.get_event_loop() -APP = Flask(__name__, instance_relative_config=True) -APP.config.from_object("config.DefaultConfig") - -SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) - -# Create MemoryStorage, UserState and ConversationState -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -ADAPTER = AdapterWithErrorHandler(SETTINGS, CONVERSATION_STATE) - -# Create dialogs and Bot -RECOGNIZER = FlightBookingRecognizer(APP.config) -BOOKING_DIALOG = BookingDialog() -DIALOG = MainDialog(RECOGNIZER, BOOKING_DIALOG) -BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -# 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 diff --git a/samples/13.core-bot/booking_details.py b/samples/13.core-bot/booking_details.py deleted file mode 100644 index 9c2d2a1bc..000000000 --- a/samples/13.core-bot/booking_details.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class BookingDetails: - def __init__( - self, - destination: str = None, - origin: str = None, - travel_date: str = None, - unsupported_airports=None, - ): - if unsupported_airports is None: - unsupported_airports = [] - self.destination = destination - self.origin = origin - self.travel_date = travel_date - self.unsupported_airports = unsupported_airports diff --git a/samples/13.core-bot/bots/__init__.py b/samples/13.core-bot/bots/__init__.py deleted file mode 100644 index 6925db302..000000000 --- a/samples/13.core-bot/bots/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_bot import DialogBot -from .dialog_and_welcome_bot import DialogAndWelcomeBot - -__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/samples/13.core-bot/bots/dialog_and_welcome_bot.py b/samples/13.core-bot/bots/dialog_and_welcome_bot.py deleted file mode 100644 index bfe8957af..000000000 --- a/samples/13.core-bot/bots/dialog_and_welcome_bot.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import os.path - -from typing import List -from botbuilder.core import ( - ConversationState, - MessageFactory, - UserState, - TurnContext, -) -from botbuilder.dialogs import Dialog -from botbuilder.schema import Attachment, ChannelAccount -from helpers.dialog_helper import DialogHelper - -from .dialog_bot import DialogBot - - -class DialogAndWelcomeBot(DialogBot): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - super(DialogAndWelcomeBot, self).__init__( - conversation_state, user_state, dialog - ) - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - # Greet anyone that was not the target (recipient) of this message. - # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. - if member.id != turn_context.activity.recipient.id: - welcome_card = self.create_adaptive_card_attachment() - response = MessageFactory.attachment(welcome_card) - await turn_context.send_activity(response) - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) - - # Load attachment from file. - def create_adaptive_card_attachment(self): - relative_path = os.path.abspath(os.path.dirname(__file__)) - path = os.path.join(relative_path, "../cards/welcomeCard.json") - with open(path) as in_file: - card = json.load(in_file) - - return Attachment( - content_type="application/vnd.microsoft.card.adaptive", content=card - ) diff --git a/samples/13.core-bot/bots/dialog_bot.py b/samples/13.core-bot/bots/dialog_bot.py deleted file mode 100644 index eb560a1be..000000000 --- a/samples/13.core-bot/bots/dialog_bot.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occurred during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/13.core-bot/cards/welcomeCard.json b/samples/13.core-bot/cards/welcomeCard.json deleted file mode 100644 index cc10cda9f..000000000 --- a/samples/13.core-bot/cards/welcomeCard.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.0", - "body": [ - { - "type": "Image", - "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", - "size": "stretch" - }, - { - "type": "TextBlock", - "spacing": "medium", - "size": "default", - "weight": "bolder", - "text": "Welcome to Bot Framework!", - "wrap": true, - "maxLines": 0 - }, - { - "type": "TextBlock", - "size": "default", - "isSubtle": "true", - "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", - "wrap": true, - "maxLines": 0 - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "Get an overview", - "url": "https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0" - }, - { - "type": "Action.OpenUrl", - "title": "Ask a question", - "url": "https://stackoverflow.com/questions/tagged/botframework" - }, - { - "type": "Action.OpenUrl", - "title": "Learn how to deploy", - "url": "https://docs.microsoft.com/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" - } - ] -} \ No newline at end of file diff --git a/samples/13.core-bot/cognitiveModels/FlightBooking.json b/samples/13.core-bot/cognitiveModels/FlightBooking.json deleted file mode 100644 index f0e4b9770..000000000 --- a/samples/13.core-bot/cognitiveModels/FlightBooking.json +++ /dev/null @@ -1,339 +0,0 @@ -{ - "luis_schema_version": "3.2.0", - "versionId": "0.1", - "name": "FlightBooking", - "desc": "Luis Model for CoreBot", - "culture": "en-us", - "tokenizerVersion": "1.0.0", - "intents": [ - { - "name": "BookFlight" - }, - { - "name": "Cancel" - }, - { - "name": "GetWeather" - }, - { - "name": "None" - } - ], - "entities": [], - "composites": [ - { - "name": "From", - "children": [ - "Airport" - ], - "roles": [] - }, - { - "name": "To", - "children": [ - "Airport" - ], - "roles": [] - } - ], - "closedLists": [ - { - "name": "Airport", - "subLists": [ - { - "canonicalForm": "Paris", - "list": [ - "paris", - "cdg" - ] - }, - { - "canonicalForm": "London", - "list": [ - "london", - "lhr" - ] - }, - { - "canonicalForm": "Berlin", - "list": [ - "berlin", - "txl" - ] - }, - { - "canonicalForm": "New York", - "list": [ - "new york", - "jfk" - ] - }, - { - "canonicalForm": "Seattle", - "list": [ - "seattle", - "sea" - ] - } - ], - "roles": [] - } - ], - "patternAnyEntities": [], - "regex_entities": [], - "prebuiltEntities": [ - { - "name": "datetimeV2", - "roles": [] - } - ], - "model_features": [], - "regex_features": [], - "patterns": [], - "utterances": [ - { - "text": "book a flight", - "intent": "BookFlight", - "entities": [] - }, - { - "text": "book a flight from new york", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 19, - "endPos": 26 - } - ] - }, - { - "text": "book a flight from seattle", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 19, - "endPos": 25 - } - ] - }, - { - "text": "book a hotel in new york", - "intent": "None", - "entities": [] - }, - { - "text": "book a restaurant", - "intent": "None", - "entities": [] - }, - { - "text": "book flight from london to paris on feb 14th", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 17, - "endPos": 22 - }, - { - "entity": "To", - "startPos": 27, - "endPos": 31 - } - ] - }, - { - "text": "book flight to berlin on feb 14th", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 15, - "endPos": 20 - } - ] - }, - { - "text": "book me a flight from london to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 22, - "endPos": 27 - }, - { - "entity": "To", - "startPos": 32, - "endPos": 36 - } - ] - }, - { - "text": "bye", - "intent": "Cancel", - "entities": [] - }, - { - "text": "cancel booking", - "intent": "Cancel", - "entities": [] - }, - { - "text": "exit", - "intent": "Cancel", - "entities": [] - }, - { - "text": "find an airport near me", - "intent": "None", - "entities": [] - }, - { - "text": "flight to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "flight to paris from london on feb 14th", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - }, - { - "entity": "From", - "startPos": 21, - "endPos": 26 - } - ] - }, - { - "text": "fly from berlin to paris on may 5th", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 9, - "endPos": 14 - }, - { - "entity": "To", - "startPos": 19, - "endPos": 23 - } - ] - }, - { - "text": "go to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 6, - "endPos": 10 - } - ] - }, - { - "text": "going from paris to berlin", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 11, - "endPos": 15 - }, - { - "entity": "To", - "startPos": 20, - "endPos": 25 - } - ] - }, - { - "text": "i'd like to rent a car", - "intent": "None", - "entities": [] - }, - { - "text": "ignore", - "intent": "Cancel", - "entities": [] - }, - { - "text": "travel from new york to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "From", - "startPos": 12, - "endPos": 19 - }, - { - "entity": "To", - "startPos": 24, - "endPos": 28 - } - ] - }, - { - "text": "travel to new york", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 17 - } - ] - }, - { - "text": "travel to paris", - "intent": "BookFlight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "what's the forecast for this friday?", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "what's the weather like for tomorrow", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "what's the weather like in new york", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "what's the weather like?", - "intent": "GetWeather", - "entities": [] - }, - { - "text": "winter is coming", - "intent": "None", - "entities": [] - } - ], - "settings": [] -} \ No newline at end of file diff --git a/samples/13.core-bot/config.py b/samples/13.core-bot/config.py deleted file mode 100644 index 83f1bbbdf..000000000 --- a/samples/13.core-bot/config.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/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", "") - LUIS_APP_ID = os.environ.get("LuisAppId", "") - LUIS_API_KEY = os.environ.get("LuisAPIKey", "") - # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" - LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/samples/13.core-bot/dialogs/__init__.py b/samples/13.core-bot/dialogs/__init__.py deleted file mode 100644 index 567539f96..000000000 --- a/samples/13.core-bot/dialogs/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .booking_dialog import BookingDialog -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog -from .main_dialog import MainDialog - -__all__ = ["BookingDialog", "CancelAndHelpDialog", "DateResolverDialog", "MainDialog"] diff --git a/samples/13.core-bot/dialogs/booking_dialog.py b/samples/13.core-bot/dialogs/booking_dialog.py deleted file mode 100644 index 5b4381919..000000000 --- a/samples/13.core-bot/dialogs/booking_dialog.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datatypes_date_time.timex import Timex - -from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult -from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions -from botbuilder.core import MessageFactory -from botbuilder.schema import InputHints -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog - - -class BookingDialog(CancelAndHelpDialog): - def __init__(self, dialog_id: str = None): - super(BookingDialog, self).__init__(dialog_id or BookingDialog.__name__) - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - self.add_dialog(DateResolverDialog(DateResolverDialog.__name__)) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__, - [ - self.destination_step, - self.origin_step, - self.travel_date_step, - self.confirm_step, - self.final_step, - ], - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ - - async def destination_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """ - If a destination city has not been provided, prompt for one. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - if booking_details.destination is None: - message_text = "Where would you like to travel to?" - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - return await step_context.prompt( - TextPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - return await step_context.next(booking_details.destination) - - async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """ - If an origin city has not been provided, prompt for one. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - # Capture the response to the previous step's prompt - booking_details.destination = step_context.result - if booking_details.origin is None: - message_text = "From what city will you be travelling?" - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - return await step_context.prompt( - TextPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - return await step_context.next(booking_details.origin) - - async def travel_date_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """ - If a travel date has not been provided, prompt for one. - This will use the DATE_RESOLVER_DIALOG. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.origin = step_context.result - if not booking_details.travel_date or self.is_ambiguous( - booking_details.travel_date - ): - return await step_context.begin_dialog( - DateResolverDialog.__name__, booking_details.travel_date - ) - return await step_context.next(booking_details.travel_date) - - async def confirm_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """ - Confirm the information the user has provided. - :param step_context: - :return DialogTurnResult: - """ - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.travel_date = step_context.result - message_text = ( - f"Please confirm, I have you traveling to: { booking_details.destination } from: " - f"{ booking_details.origin } on: { booking_details.travel_date}." - ) - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - - # Offer a YES/NO prompt. - return await step_context.prompt( - ConfirmPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """ - Complete the interaction and end the dialog. - :param step_context: - :return DialogTurnResult: - """ - if step_context.result: - booking_details = step_context.options - - return await step_context.end_dialog(booking_details) - return await step_context.end_dialog() - - def is_ambiguous(self, timex: str) -> bool: - timex_property = Timex(timex) - return "definite" not in timex_property.types diff --git a/samples/13.core-bot/dialogs/cancel_and_help_dialog.py b/samples/13.core-bot/dialogs/cancel_and_help_dialog.py deleted file mode 100644 index f8bcc77d0..000000000 --- a/samples/13.core-bot/dialogs/cancel_and_help_dialog.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - DialogContext, - DialogTurnResult, - DialogTurnStatus, -) -from botbuilder.schema import ActivityTypes, InputHints -from botbuilder.core import MessageFactory - - -class CancelAndHelpDialog(ComponentDialog): - def __init__(self, dialog_id: str): - super(CancelAndHelpDialog, self).__init__(dialog_id) - - async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: - result = await self.interrupt(inner_dc) - if result is not None: - return result - - return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) - - async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: - if inner_dc.context.activity.type == ActivityTypes.message: - text = inner_dc.context.activity.text.lower() - - help_message_text = "Show Help..." - help_message = MessageFactory.text( - help_message_text, help_message_text, InputHints.expecting_input - ) - - if text in ("help", "?"): - await inner_dc.context.send_activity(help_message) - return DialogTurnResult(DialogTurnStatus.Waiting) - - cancel_message_text = "Cancelling" - cancel_message = MessageFactory.text( - cancel_message_text, cancel_message_text, InputHints.ignoring_input - ) - - if text in ("cancel", "quit"): - await inner_dc.context.send_activity(cancel_message) - return await inner_dc.cancel_all_dialogs() - - return None diff --git a/samples/13.core-bot/dialogs/date_resolver_dialog.py b/samples/13.core-bot/dialogs/date_resolver_dialog.py deleted file mode 100644 index a34f47a7a..000000000 --- a/samples/13.core-bot/dialogs/date_resolver_dialog.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datatypes_date_time.timex import Timex - -from botbuilder.core import MessageFactory -from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext -from botbuilder.dialogs.prompts import ( - DateTimePrompt, - PromptValidatorContext, - PromptOptions, - DateTimeResolution, -) -from botbuilder.schema import InputHints -from .cancel_and_help_dialog import CancelAndHelpDialog - - -class DateResolverDialog(CancelAndHelpDialog): - def __init__(self, dialog_id: str = None): - super(DateResolverDialog, self).__init__( - dialog_id or DateResolverDialog.__name__ - ) - - self.add_dialog( - DateTimePrompt( - DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator - ) - ) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__ + "2", [self.initial_step, self.final_step] - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ + "2" - - async def initial_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - timex = step_context.options - - prompt_msg_text = "On what date would you like to travel?" - prompt_msg = MessageFactory.text( - prompt_msg_text, prompt_msg_text, InputHints.expecting_input - ) - - reprompt_msg_text = "I'm sorry, for best results, please enter your travel date including the month, " \ - "day and year. " - reprompt_msg = MessageFactory.text( - reprompt_msg_text, reprompt_msg_text, InputHints.expecting_input - ) - - if timex is None: - # We were not given any date at all so prompt the user. - return await step_context.prompt( - DateTimePrompt.__name__, - PromptOptions(prompt=prompt_msg, retry_prompt=reprompt_msg), - ) - # We have a Date we just need to check it is unambiguous. - if "definite" not in Timex(timex).types: - # This is essentially a "reprompt" of the data we were given up front. - return await step_context.prompt( - DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) - ) - - return await step_context.next(DateTimeResolution(timex=timex)) - - async def final_step(self, step_context: WaterfallStepContext): - timex = step_context.result[0].timex - return await step_context.end_dialog(timex) - - @staticmethod - async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: - if prompt_context.recognized.succeeded: - timex = prompt_context.recognized.value[0].timex.split("T")[0] - - # TODO: Needs TimexProperty - return "definite" in Timex(timex).types - - return False diff --git a/samples/13.core-bot/dialogs/main_dialog.py b/samples/13.core-bot/dialogs/main_dialog.py deleted file mode 100644 index 82dfaa00b..000000000 --- a/samples/13.core-bot/dialogs/main_dialog.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.dialogs.prompts import TextPrompt, PromptOptions -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.schema import InputHints - -from booking_details import BookingDetails -from flight_booking_recognizer import FlightBookingRecognizer -from helpers.luis_helper import LuisHelper, Intent -from .booking_dialog import BookingDialog - - -class MainDialog(ComponentDialog): - def __init__( - self, luis_recognizer: FlightBookingRecognizer, booking_dialog: BookingDialog - ): - super(MainDialog, self).__init__(MainDialog.__name__) - - self._luis_recognizer = luis_recognizer - self._booking_dialog_id = booking_dialog.id - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(booking_dialog) - self.add_dialog( - WaterfallDialog( - "WFDialog", [self.intro_step, self.act_step, self.final_step] - ) - ) - - self.initial_dialog_id = "WFDialog" - - async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - if not self._luis_recognizer.is_configured: - await step_context.context.send_activity( - MessageFactory.text( - "NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and " - "'LuisAPIHostName' to the appsettings.json file.", - input_hint=InputHints.ignoring_input, - ) - ) - - return await step_context.next(None) - message_text = ( - str(step_context.options) - if step_context.options - else "What can I help you with today?" - ) - prompt_message = MessageFactory.text( - message_text, message_text, InputHints.expecting_input - ) - - return await step_context.prompt( - TextPrompt.__name__, PromptOptions(prompt=prompt_message) - ) - - async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - if not self._luis_recognizer.is_configured: - # LUIS is not configured, we just run the BookingDialog path with an empty BookingDetailsInstance. - return await step_context.begin_dialog( - self._booking_dialog_id, BookingDetails() - ) - - # Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.) - intent, luis_result = await LuisHelper.execute_luis_query( - self._luis_recognizer, step_context.context - ) - - if intent == Intent.BOOK_FLIGHT.value and luis_result: - # Show a warning for Origin and Destination if we can't resolve them. - await MainDialog._show_warning_for_unsupported_cities( - step_context.context, luis_result - ) - - # Run the BookingDialog giving it whatever details we have from the LUIS call. - return await step_context.begin_dialog(self._booking_dialog_id, luis_result) - - if intent == Intent.GET_WEATHER.value: - get_weather_text = "TODO: get weather flow here" - get_weather_message = MessageFactory.text( - get_weather_text, get_weather_text, InputHints.ignoring_input - ) - await step_context.context.send_activity(get_weather_message) - - else: - didnt_understand_text = ( - "Sorry, I didn't get that. Please try asking in a different way" - ) - didnt_understand_message = MessageFactory.text( - didnt_understand_text, didnt_understand_text, InputHints.ignoring_input - ) - await step_context.context.send_activity(didnt_understand_message) - - return await step_context.next(None) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # If the child dialog ("BookingDialog") was cancelled or the user failed to confirm, - # the Result here will be null. - if step_context.result is not None: - result = step_context.result - - # Now we have all the booking details call the booking service. - - # If the call to the booking service was successful tell the user. - # time_property = Timex(result.travel_date) - # travel_date_msg = time_property.to_natural_language(datetime.now()) - msg_txt = f"I have you booked to {result.destination} from {result.origin} on {result.travel_date}" - message = MessageFactory.text(msg_txt, msg_txt, InputHints.ignoring_input) - await step_context.context.send_activity(message) - - prompt_message = "What else can I do for you?" - return await step_context.replace_dialog(self.id, prompt_message) - - @staticmethod - async def _show_warning_for_unsupported_cities( - context: TurnContext, luis_result: BookingDetails - ) -> None: - if luis_result.unsupported_airports: - message_text = ( - f"Sorry but the following airports are not supported:" - f" {', '.join(luis_result.unsupported_airports)}" - ) - message = MessageFactory.text( - message_text, message_text, InputHints.ignoring_input - ) - await context.send_activity(message) diff --git a/samples/13.core-bot/flight_booking_recognizer.py b/samples/13.core-bot/flight_booking_recognizer.py deleted file mode 100644 index 7476103c7..000000000 --- a/samples/13.core-bot/flight_booking_recognizer.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.ai.luis import LuisApplication, LuisRecognizer -from botbuilder.core import Recognizer, RecognizerResult, TurnContext - - -class FlightBookingRecognizer(Recognizer): - def __init__(self, configuration: dict): - self._recognizer = None - - luis_is_configured = ( - configuration["LUIS_APP_ID"] - and configuration["LUIS_API_KEY"] - and configuration["LUIS_API_HOST_NAME"] - ) - if luis_is_configured: - luis_application = LuisApplication( - configuration["LUIS_APP_ID"], - configuration["LUIS_API_KEY"], - "https://" + configuration["LUIS_API_HOST_NAME"], - ) - - self._recognizer = LuisRecognizer(luis_application) - - @property - def is_configured(self) -> bool: - # Returns true if luis is configured in the appsettings.json and initialized. - return self._recognizer is not None - - async def recognize(self, turn_context: TurnContext) -> RecognizerResult: - return await self._recognizer.recognize(turn_context) diff --git a/samples/13.core-bot/helpers/__init__.py b/samples/13.core-bot/helpers/__init__.py deleted file mode 100644 index 699f8693c..000000000 --- a/samples/13.core-bot/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import activity_helper, luis_helper, dialog_helper - -__all__ = ["activity_helper", "dialog_helper", "luis_helper"] diff --git a/samples/13.core-bot/helpers/activity_helper.py b/samples/13.core-bot/helpers/activity_helper.py deleted file mode 100644 index 29a24823e..000000000 --- a/samples/13.core-bot/helpers/activity_helper.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datetime import datetime -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, -) - - -def create_activity_reply(activity: Activity, text: str = None, locale: str = None): - - return Activity( - type=ActivityTypes.message, - timestamp=datetime.utcnow(), - from_property=ChannelAccount( - id=getattr(activity.recipient, "id", None), - name=getattr(activity.recipient, "name", None), - ), - 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 "", - locale=locale or "", - attachments=[], - entities=[], - ) diff --git a/samples/13.core-bot/helpers/dialog_helper.py b/samples/13.core-bot/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/13.core-bot/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/13.core-bot/helpers/luis_helper.py b/samples/13.core-bot/helpers/luis_helper.py deleted file mode 100644 index 3e28bc47e..000000000 --- a/samples/13.core-bot/helpers/luis_helper.py +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from enum import Enum -from typing import Dict -from botbuilder.ai.luis import LuisRecognizer -from botbuilder.core import IntentScore, TopIntent, TurnContext - -from booking_details import BookingDetails - - -class Intent(Enum): - BOOK_FLIGHT = "BookFlight" - CANCEL = "Cancel" - GET_WEATHER = "GetWeather" - NONE_INTENT = "NoneIntent" - - -def top_intent(intents: Dict[Intent, dict]) -> TopIntent: - max_intent = Intent.NONE_INTENT - max_value = 0.0 - - for intent, value in intents: - intent_score = IntentScore(value) - if intent_score.score > max_value: - max_intent, max_value = intent, intent_score.score - - return TopIntent(max_intent, max_value) - - -class LuisHelper: - @staticmethod - async def execute_luis_query( - luis_recognizer: LuisRecognizer, turn_context: TurnContext - ) -> (Intent, object): - """ - Returns an object with preformatted LUIS results for the bot's dialogs to consume. - """ - result = None - intent = None - - try: - recognizer_result = await luis_recognizer.recognize(turn_context) - - intent = ( - sorted( - recognizer_result.intents, - key=recognizer_result.intents.get, - reverse=True, - )[:1][0] - if recognizer_result.intents - else None - ) - - if intent == Intent.BOOK_FLIGHT.value: - result = BookingDetails() - - # We need to get the result from the LUIS JSON which at every level returns an array. - to_entities = recognizer_result.entities.get("$instance", {}).get( - "To", [] - ) - if len(to_entities) > 0: - if recognizer_result.entities.get("To", [{"$instance": {}}])[0][ - "$instance" - ]: - result.destination = to_entities[0]["text"].capitalize() - else: - result.unsupported_airports.append( - to_entities[0]["text"].capitalize() - ) - - from_entities = recognizer_result.entities.get("$instance", {}).get( - "From", [] - ) - if len(from_entities) > 0: - if recognizer_result.entities.get("From", [{"$instance": {}}])[0][ - "$instance" - ]: - result.origin = from_entities[0]["text"].capitalize() - else: - result.unsupported_airports.append( - from_entities[0]["text"].capitalize() - ) - - # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop - # the Time part. TIMEX is a format that represents DateTime expressions that include some ambiguity. - # e.g. missing a Year. - date_entities = recognizer_result.entities.get("datetime", []) - if date_entities: - timex = date_entities[0]["timex"] - - if timex: - datetime = timex[0].split("T")[0] - - result.travel_date = datetime - - else: - result.travel_date = None - - except Exception as exception: - print(exception) - - return intent, result diff --git a/samples/13.core-bot/requirements.txt b/samples/13.core-bot/requirements.txt deleted file mode 100644 index c11eb2923..000000000 --- a/samples/13.core-bot/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -botbuilder-dialogs>=4.4.0.b1 -botbuilder-ai>=4.4.0.b1 -datatypes-date-time>=1.0.0.a2 -flask>=1.0.3 - diff --git a/samples/15.handling-attachments/README.md b/samples/15.handling-attachments/README.md deleted file mode 100644 index 678b34c11..000000000 --- a/samples/15.handling-attachments/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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) diff --git a/samples/15.handling-attachments/app.py b/samples/15.handling-attachments/app.py deleted file mode 100644 index 47758a1e3..000000000 --- a/samples/15.handling-attachments/app.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -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 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 diff --git a/samples/15.handling-attachments/bots/__init__.py b/samples/15.handling-attachments/bots/__init__.py deleted file mode 100644 index 28e703782..000000000 --- a/samples/15.handling-attachments/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .attachments_bot import AttachmentsBot - -__all__ = ["AttachmentsBot"] diff --git a/samples/15.handling-attachments/bots/attachments_bot.py b/samples/15.handling-attachments/bots/attachments_bot.py deleted file mode 100644 index 51fd8bb50..000000000 --- a/samples/15.handling-attachments/bots/attachments_bot.py +++ /dev/null @@ -1,218 +0,0 @@ -# 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, -) - - -class AttachmentsBot(ActivityHandler): - """ - 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. - """ - - 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 - . - :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 exception: - print(exception) - 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", - ) diff --git a/samples/15.handling-attachments/config.py b/samples/15.handling-attachments/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/15.handling-attachments/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json b/samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/15.handling-attachments/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/15.handling-attachments/requirements.txt b/samples/15.handling-attachments/requirements.txt deleted file mode 100644 index eca52e268..000000000 --- a/samples/15.handling-attachments/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -jsonpickle -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/15.handling-attachments/resources/architecture-resize.png b/samples/15.handling-attachments/resources/architecture-resize.png deleted file mode 100644 index 43419ed44e0aa6ab88b50239b8e0fdfeb4087186..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 241516 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

Bq>K7N*zY|DnT>$V&M1|VU&Ud0Hix-|jc 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س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%0DY0^mU%clPUS%y? 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

g*#|TO|2COZQ}3mdoNanQ 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

J7{GK^izu_n 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¥Br&n`R;Z;fYM@^$5C`pPIsD_}$y(oA2FL=n^ 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�^V945f>MfzRtVkY2HwMj;Ef(AwIq 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 z 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 diff --git a/samples/16.proactive-messages/README.md b/samples/16.proactive-messages/README.md deleted file mode 100644 index 109fb4085..000000000 --- a/samples/16.proactive-messages/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Proactive messages - -Bot Framework v4 proactive messages bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to send proactive messages to users by capturing a conversation reference, then using it later to initialize outbound messages. - -## Concepts introduced in this sample - -Typically, each message that a bot sends to the user directly relates to the user's prior input. In some cases, a bot may need to send the user a message that is not directly related to the current topic of conversation. These types of messages are called proactive messages. - -Proactive messages can be useful in a variety of scenarios. If a bot sets a timer or reminder, it will need to notify the user when the time arrives. Or, if a bot receives a notification from an external system, it may need to communicate that information to the user immediately. For example, if the user has previously asked the bot to monitor the price of a product, the bot can alert the user if the price of the product has dropped by 20%. Or, if a bot requires some time to compile a response to the user's question, it may inform the user of the delay and allow the conversation to continue in the meantime. When the bot finishes compiling the response to the question, it will share that information with the user. - -This project has a notify endpoint that will trigger the proactive messages to be sent to -all users who have previously messaged the bot. - -## 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\16.proactive-messages` 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 - -With the Bot Framework Emulator connected to your running bot, the sample will now respond to an HTTP GET that will trigger a proactive message. The proactive message can be triggered from the command line using `curl` or similar tooling, or can be triggered by opening a browser windows and nagivating to `http://localhost:3978/api/notify`. - -### Using curl - -- Send a get request to `http://localhost:3978/api/notify` to proactively message users from the bot. - - ```bash - curl get http://localhost:3978/api/notify - ``` - -- Using the Bot Framework Emulator, notice a message was proactively sent to the user from the bot. - -### Using the Browser - -- Launch a web browser -- Navigate to `http://localhost:3978/api/notify` -- Using the Bot Framework Emulator, notice a message was proactively sent to the user from the bot. - -## Proactive Messages - -In addition to responding to incoming messages, bots are frequently called on to send "proactive" messages based on activity, scheduled tasks, or external events. - -In order to send a proactive message using Bot Framework, the bot must first capture a conversation reference from an incoming message using `TurnContext.get_conversation_reference()`. This reference can be stored for later use. - -To send proactive messages, acquire a conversation reference, then use `adapter.continue_conversation()` to create a TurnContext object that will allow the bot to deliver the new outgoing message. - -## 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) -- [Send proactive messages](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-proactive-message?view=azure-bot-service-4.0&tabs=js) diff --git a/samples/16.proactive-messages/app.py b/samples/16.proactive-messages/app.py deleted file mode 100644 index 62ddb40c9..000000000 --- a/samples/16.proactive-messages/app.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -import uuid -from datetime import datetime -from typing import Dict - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, -) -from botbuilder.schema import Activity, ActivityTypes, ConversationReference - -from bots import ProactiveBot - -# 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 a shared dictionary. The Bot will add conversation references when users -# join the conversation and send messages. -CONVERSATION_REFERENCES: Dict[str, ConversationReference] = dict() - -# If the channel is the Emulator, and authentication is not in use, the AppId will be null. -# We generate a random AppId for this case only. This is not required for production, since -# the AppId will have a value. -APP_ID = SETTINGS.app_id if SETTINGS.app_id else uuid.uuid4() - -# Create the Bot -BOT = ProactiveBot(CONVERSATION_REFERENCES) - -# 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 - - -# Listen for requests on /api/notify, and send a messages to all conversation members. -@APP.route("/api/notify") -def notify(): - try: - task = LOOP.create_task(_send_proactive_message()) - LOOP.run_until_complete(task) - - return Response(status=201, response="Proactive messages have been sent") - except Exception as exception: - raise exception - - -# Send a message to all conversation members. -# This uses the shared Dictionary that the Bot adds conversation references to. -async def _send_proactive_message(): - for conversation_reference in CONVERSATION_REFERENCES.values(): - return await ADAPTER.continue_conversation( - conversation_reference, - lambda turn_context: turn_context.send_activity("proactive hello"), - APP_ID, - ) - - -if __name__ == "__main__": - try: - APP.run(debug=False, port=APP.config["PORT"]) # nosec debug - except Exception as exception: - raise exception diff --git a/samples/16.proactive-messages/bots/__init__.py b/samples/16.proactive-messages/bots/__init__.py deleted file mode 100644 index 72c8ccc0c..000000000 --- a/samples/16.proactive-messages/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .proactive_bot import ProactiveBot - -__all__ = ["ProactiveBot"] diff --git a/samples/16.proactive-messages/bots/proactive_bot.py b/samples/16.proactive-messages/bots/proactive_bot.py deleted file mode 100644 index c65626899..000000000 --- a/samples/16.proactive-messages/bots/proactive_bot.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Dict - -from botbuilder.core import ActivityHandler, TurnContext -from botbuilder.schema import ChannelAccount, ConversationReference, Activity - - -class ProactiveBot(ActivityHandler): - def __init__(self, conversation_references: Dict[str, ConversationReference]): - self.conversation_references = conversation_references - - async def on_conversation_update_activity(self, turn_context: TurnContext): - self._add_conversation_reference(turn_context.activity) - return await super().on_conversation_update_activity(turn_context) - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - "Welcome to the Proactive Bot sample. Navigate to " - "http://localhost:3978/api/notify to proactively message everyone " - "who has previously messaged this bot." - ) - - async def on_message_activity(self, turn_context: TurnContext): - self._add_conversation_reference(turn_context.activity) - return await turn_context.send_activity( - f"You sent: {turn_context.activity.text}" - ) - - def _add_conversation_reference(self, activity: Activity): - """ - This populates the shared Dictionary that holds conversation references. In this sample, - this dictionary is used to send a message to members when /api/notify is hit. - :param activity: - :return: - """ - conversation_reference = TurnContext.get_conversation_reference(activity) - self.conversation_references[ - conversation_reference.user.id - ] = conversation_reference diff --git a/samples/16.proactive-messages/config.py b/samples/16.proactive-messages/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/16.proactive-messages/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/16.proactive-messages/requirements.txt b/samples/16.proactive-messages/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/16.proactive-messages/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/17.multilingual-bot/README.md b/samples/17.multilingual-bot/README.md deleted file mode 100644 index 41666b6f3..000000000 --- a/samples/17.multilingual-bot/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Multilingual Bot - -Bot Framework v4 multilingual bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to translate incoming and outgoing text using a custom middleware and the [Microsoft Translator Text API](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/). - -## Concepts introduced in this sample - -Translation Middleware: We create a translation middleware that can translate text from bot to user and from user to bot, allowing the creation of multi-lingual bots. - -The middleware is driven by user state. This means that users can specify their language preference, and the middleware automatically will intercept messages back and forth and present them to the user in their preferred language. - -Users can change their language preference anytime, and since this gets written to the user state, the middleware will read this state and instantly modify its behavior to honor the newly selected preferred language. - -The [Microsoft Translator Text API](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/), Microsoft Translator Text API is a cloud-based machine translation service. With this API you can translate text in near real-time from any app or service through a simple REST API call. -The API uses the most modern neural machine translation technology, as well as offering statistical machine translation technology. - -## 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\17.multilingual-bot` folder -- In the terminal, type `pip install -r requirements.txt` - -- To consume the Microsoft Translator Text API, first obtain a key following the instructions in the [Microsoft Translator Text API documentation](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-text-how-to-signup). Paste the key in the `SUBSCRIPTION_KEY` and `SUBSCRIPTION_REGION` settings in the `config.py` file. - -- 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 - - -### Creating a custom middleware - -Translation Middleware: We create a translation middleware than can translate text from bot to user and from user to bot, allowing the creation of multilingual bots. -Users can specify their language preference, which is stored in the user state. The translation middleware translates to and from the user's preferred language. - -### Microsoft Translator Text API - -The [Microsoft Translator Text API](https://docs.microsoft.com/en-us/azure/cognitive-services/translator/), Microsoft Translator Text API is a cloud-based machine translation service. With this API you can translate text in near real-time from any app or service through a simple REST API call. -The API uses the most modern neural machine translation technology, as well as offering statistical machine translation technology. - -# 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) -- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?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) -- [Channels and Bot Connector Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/17.multilingual-bot/app.py b/samples/17.multilingual-bot/app.py deleted file mode 100644 index bdba1af1a..000000000 --- a/samples/17.multilingual-bot/app.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import MultiLingualBot - -# Create the loop and Flask app -from translation import TranslationMiddleware, MicrosoftTranslator - -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 MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) - -# Create translation middleware and add to adapter -TRANSLATOR = MicrosoftTranslator( - app.config["SUBSCRIPTION_KEY"], app.config["SUBSCRIPTION_REGION"] -) -TRANSLATION_MIDDLEWARE = TranslationMiddleware(TRANSLATOR, USER_STATE) -ADAPTER.use(TRANSLATION_MIDDLEWARE) - -# Create Bot -BOT = MultiLingualBot(USER_STATE) - - -# 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 diff --git a/samples/17.multilingual-bot/bots/__init__.py b/samples/17.multilingual-bot/bots/__init__.py deleted file mode 100644 index 377f4a8ec..000000000 --- a/samples/17.multilingual-bot/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .multilingual_bot import MultiLingualBot - -__all__ = ["MultiLingualBot"] diff --git a/samples/17.multilingual-bot/bots/multilingual_bot.py b/samples/17.multilingual-bot/bots/multilingual_bot.py deleted file mode 100644 index b2bcf24fa..000000000 --- a/samples/17.multilingual-bot/bots/multilingual_bot.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import os - -from botbuilder.core import ( - ActivityHandler, - TurnContext, - UserState, - CardFactory, - MessageFactory, -) -from botbuilder.schema import ( - ChannelAccount, - Attachment, - SuggestedActions, - CardAction, - ActionTypes, -) - -from translation.translation_settings import TranslationSettings - - -class MultiLingualBot(ActivityHandler): - """ - This bot demonstrates how to use Microsoft Translator. - More information can be found at: - https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-info-overview" - """ - - def __init__(self, user_state: UserState): - if user_state is None: - raise TypeError( - "[MultiLingualBot]: Missing parameter. user_state is required but None was given" - ) - - self.user_state = user_state - - self.language_preference_accessor = self.user_state.create_property( - "LanguagePreference" - ) - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - # Greet anyone that was not the target (recipient) of this message. - # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - MessageFactory.attachment(self._create_adaptive_card_attachment()) - ) - await turn_context.send_activity( - "This bot will introduce you to translation middleware. Say 'hi' to get started." - ) - - async def on_message_activity(self, turn_context: TurnContext): - if self._is_language_change_requested(turn_context.activity.text): - # If the user requested a language change through the suggested actions with values "es" or "en", - # simply change the user's language preference in the user state. - # The translation middleware will catch this setting and translate both ways to the user's - # selected language. - # If Spanish was selected by the user, the reply below will actually be shown in Spanish to the user. - current_language = turn_context.activity.text.lower() - if current_language in ( - TranslationSettings.english_english.value, TranslationSettings.spanish_english.value - ): - lang = TranslationSettings.english_english.value - else: - lang = TranslationSettings.english_spanish.value - - await self.language_preference_accessor.set(turn_context, lang) - - await turn_context.send_activity(f"Your current language code is: {lang}") - - # Save the user profile updates into the user state. - await self.user_state.save_changes(turn_context) - else: - # Show the user the possible options for language. If the user chooses a different language - # than the default, then the translation middleware will pick it up from the user state and - # translate messages both ways, i.e. user to bot and bot to user. - reply = MessageFactory.text("Choose your language:") - reply.suggested_actions = SuggestedActions( - actions=[ - CardAction( - title="Español", - type=ActionTypes.post_back, - value=TranslationSettings.english_spanish.value, - ), - CardAction( - title="English", - type=ActionTypes.post_back, - value=TranslationSettings.english_english.value, - ), - ] - ) - - await turn_context.send_activity(reply) - - def _create_adaptive_card_attachment(self) -> Attachment: - """ - Load attachment from file. - :return: - """ - card_path = os.path.join(os.getcwd(), "cards/welcomeCard.json") - with open(card_path, "rt") as in_file: - card_data = json.load(in_file) - - return CardFactory.adaptive_card(card_data) - - def _is_language_change_requested(self, utterance: str) -> bool: - if not utterance: - return False - - utterance = utterance.lower() - return utterance in ( - TranslationSettings.english_spanish.value, - TranslationSettings.english_english.value, - TranslationSettings.spanish_spanish.value, - TranslationSettings.spanish_english.value - ) diff --git a/samples/17.multilingual-bot/cards/welcomeCard.json b/samples/17.multilingual-bot/cards/welcomeCard.json deleted file mode 100644 index 100aa5287..000000000 --- a/samples/17.multilingual-bot/cards/welcomeCard.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.0", - "body": [ - { - "type": "Image", - "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", - "size": "stretch" - }, - { - "type": "TextBlock", - "spacing": "medium", - "size": "default", - "weight": "bolder", - "text": "Welcome to Bot Framework!", - "wrap": true, - "maxLines": 0 - }, - { - "type": "TextBlock", - "size": "default", - "isSubtle": "true", - "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", - "wrap": true, - "maxLines": 0 - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "Get an overview", - "url": "https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" - }, - { - "type": "Action.OpenUrl", - "title": "Ask a question", - "url": "https://stackoverflow.com/questions/tagged/botframework" - }, - { - "type": "Action.OpenUrl", - "title": "Learn how to deploy", - "url": "https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" - } - ] -} \ No newline at end of file diff --git a/samples/17.multilingual-bot/config.py b/samples/17.multilingual-bot/config.py deleted file mode 100644 index 7d323dda5..000000000 --- a/samples/17.multilingual-bot/config.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/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", "") - SUBSCRIPTION_KEY = os.environ.get("SubscriptionKey", "") - SUBSCRIPTION_REGION = os.environ.get("SubscriptionRegion", "") diff --git a/samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/17.multilingual-bot/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/17.multilingual-bot/requirements.txt b/samples/17.multilingual-bot/requirements.txt deleted file mode 100644 index 32e489163..000000000 --- a/samples/17.multilingual-bot/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/17.multilingual-bot/translation/__init__.py b/samples/17.multilingual-bot/translation/__init__.py deleted file mode 100644 index 7112f41c0..000000000 --- a/samples/17.multilingual-bot/translation/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .microsoft_translator import MicrosoftTranslator -from .translation_middleware import TranslationMiddleware - -__all__ = ["MicrosoftTranslator", "TranslationMiddleware"] diff --git a/samples/17.multilingual-bot/translation/microsoft_translator.py b/samples/17.multilingual-bot/translation/microsoft_translator.py deleted file mode 100644 index 9af148fc6..000000000 --- a/samples/17.multilingual-bot/translation/microsoft_translator.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import uuid -import requests - - -class MicrosoftTranslator: - def __init__(self, subscription_key: str, subscription_region: str): - self.subscription_key = subscription_key - self.subscription_region = subscription_region - - # Don't forget to replace with your Cog Services location! - # Our Flask route will supply two arguments: text_input and language_output. - # When the translate text button is pressed in our Flask app, the Ajax request - # will grab these values from our web app, and use them in the request. - # See main.js for Ajax calls. - async def translate(self, text_input, language_output): - base_url = "https://api.cognitive.microsofttranslator.com" - path = "/translate?api-version=3.0" - params = "&to=" + language_output - constructed_url = base_url + path + params - - headers = { - "Ocp-Apim-Subscription-Key": self.subscription_key, - "Ocp-Apim-Subscription-Region": self.subscription_region, - "Content-type": "application/json", - "X-ClientTraceId": str(uuid.uuid4()), - } - - # You can pass more than one object in body. - body = [{"text": text_input}] - response = requests.post(constructed_url, headers=headers, json=body) - json_response = response.json() - - # for this sample, return the first translation - return json_response[0]["translations"][0]["text"] diff --git a/samples/17.multilingual-bot/translation/translation_middleware.py b/samples/17.multilingual-bot/translation/translation_middleware.py deleted file mode 100644 index b983b2acb..000000000 --- a/samples/17.multilingual-bot/translation/translation_middleware.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Callable, Awaitable, List - -from botbuilder.core import Middleware, UserState, TurnContext -from botbuilder.schema import Activity, ActivityTypes - -from translation import MicrosoftTranslator -from translation.translation_settings import TranslationSettings - - -class TranslationMiddleware(Middleware): - """ - Middleware for translating text between the user and bot. - Uses the Microsoft Translator Text API. - """ - - def __init__(self, translator: MicrosoftTranslator, user_state: UserState): - self.translator = translator - self.language_preference_accessor = user_state.create_property( - "LanguagePreference" - ) - - async def on_turn( - self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] - ): - """ - Processes an incoming activity. - :param context: - :param logic: - :return: - """ - translate = await self._should_translate(context) - if translate and context.activity.type == ActivityTypes.message: - context.activity.text = await self.translator.translate( - context.activity.text, TranslationSettings.default_language.value - ) - - async def aux_on_send( - ctx: TurnContext, activities: List[Activity], next_send: Callable - ): - user_language = await self.language_preference_accessor.get( - ctx, TranslationSettings.default_language.value - ) - should_translate = ( - user_language != TranslationSettings.default_language.value - ) - - # Translate messages sent to the user to user language - if should_translate: - for activity in activities: - await self._translate_message_activity(activity, user_language) - - return await next_send() - - async def aux_on_update( - ctx: TurnContext, activity: Activity, next_update: Callable - ): - user_language = await self.language_preference_accessor.get( - ctx, TranslationSettings.default_language.value - ) - should_translate = ( - user_language != TranslationSettings.default_language.value - ) - - # Translate messages sent to the user to user language - if should_translate and activity.type == ActivityTypes.message: - await self._translate_message_activity(activity, user_language) - - return await next_update() - - context.on_send_activities(aux_on_send) - context.on_update_activity(aux_on_update) - - await logic() - - async def _should_translate(self, turn_context: TurnContext) -> bool: - user_language = await self.language_preference_accessor.get( - turn_context, TranslationSettings.default_language.value - ) - return user_language != TranslationSettings.default_language.value - - async def _translate_message_activity(self, activity: Activity, target_locale: str): - if activity.type == ActivityTypes.message: - activity.text = await self.translator.translate( - activity.text, target_locale - ) diff --git a/samples/17.multilingual-bot/translation/translation_settings.py b/samples/17.multilingual-bot/translation/translation_settings.py deleted file mode 100644 index aee41542d..000000000 --- a/samples/17.multilingual-bot/translation/translation_settings.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from enum import Enum - - -class TranslationSettings(str, Enum): - default_language = "en" - english_english = "en" - english_spanish = "es" - spanish_english = "in" - spanish_spanish = "it" diff --git a/samples/18.bot-authentication/README.md b/samples/18.bot-authentication/README.md deleted file mode 100644 index 2902756f5..000000000 --- a/samples/18.bot-authentication/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Bot Authentication - -Bot Framework v4 bot authentication sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to use authentication in your bot using OAuth. - -The sample uses the bot authentication capabilities in [Azure Bot Service](https://docs.botframework.com), providing features to make it easier to develop a bot that authenticates users to various identity providers such as Azure AD (Azure Active Directory), GitHub, Uber, etc. - -NOTE: Microsoft Teams currently differs slightly in the way auth is integrated with the bot. Refer to sample 46.teams-auth. - -## 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\18.bot-authentication` folder -- In the terminal, type `pip install -r requirements.txt` -- Deploy your bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) -- [Add Authentication to your bot via Azure Bot Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-authentication?view=azure-bot-service-4.0&tabs=csharp) -- Modify `APP_ID`, `APP_PASSWORD`, and `CONNECTION_NAME` in `config.py` - -After Authentication has been configured via Azure Bot Service, you can test the bot. - -- In the terminal, type `python app.py` - -## 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` -- Enter the app id and password - -## Authentication - -This sample uses bot authentication capabilities in Azure Bot Service, providing features to make it easier to develop a bot that authenticates users to various identity providers such as Azure AD (Azure Active Directory), GitHub, Uber, etc. These updates also take steps towards an improved user experience by eliminating the magic code verification for some clients. - -## 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) -- [Azure Portal](https://portal.azure.com) -- [Add Authentication to Your Bot Via Azure Bot Service](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-authentication?view=azure-bot-service-4.0&tabs=csharp) -- [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) diff --git a/samples/18.bot-authentication/app.py b/samples/18.bot-authentication/app.py deleted file mode 100644 index c8910b155..000000000 --- a/samples/18.bot-authentication/app.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import AuthBot - -# Create the loop and Flask app -from dialogs import MainDialog - -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 MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create dialog -DIALOG = MainDialog(app.config["CONNECTION_NAME"]) - -# Create Bot -BOT = AuthBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -# 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 diff --git a/samples/18.bot-authentication/bots/__init__.py b/samples/18.bot-authentication/bots/__init__.py deleted file mode 100644 index d6506ffcb..000000000 --- a/samples/18.bot-authentication/bots/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_bot import DialogBot -from .auth_bot import AuthBot - -__all__ = ["DialogBot", "AuthBot"] diff --git a/samples/18.bot-authentication/bots/auth_bot.py b/samples/18.bot-authentication/bots/auth_bot.py deleted file mode 100644 index 93166f655..000000000 --- a/samples/18.bot-authentication/bots/auth_bot.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List -from botbuilder.core import ( - ConversationState, - UserState, - TurnContext, -) -from botbuilder.dialogs import Dialog -from botbuilder.schema import ChannelAccount - -from helpers.dialog_helper import DialogHelper -from .dialog_bot import DialogBot - - -class AuthBot(DialogBot): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - super(AuthBot, self).__init__(conversation_state, user_state, dialog) - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - # Greet anyone that was not the target (recipient) of this message. - # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - "Welcome to AuthenticationBot. Type anything to get logged in. Type " - "'logout' to sign-out." - ) - - async def on_token_response_event(self, turn_context: TurnContext): - # Run the Dialog with the new Token Response Event Activity. - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/18.bot-authentication/bots/dialog_bot.py b/samples/18.bot-authentication/bots/dialog_bot.py deleted file mode 100644 index eb560a1be..000000000 --- a/samples/18.bot-authentication/bots/dialog_bot.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occurred during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/18.bot-authentication/config.py b/samples/18.bot-authentication/config.py deleted file mode 100644 index 0acc113a3..000000000 --- a/samples/18.bot-authentication/config.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/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", "") - CONNECTION_NAME = os.environ.get("ConnectionName", "") diff --git a/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json b/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/18.bot-authentication/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/18.bot-authentication/dialogs/__init__.py b/samples/18.bot-authentication/dialogs/__init__.py deleted file mode 100644 index ab5189cd5..000000000 --- a/samples/18.bot-authentication/dialogs/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .logout_dialog import LogoutDialog -from .main_dialog import MainDialog - -__all__ = ["LogoutDialog", "MainDialog"] diff --git a/samples/18.bot-authentication/dialogs/logout_dialog.py b/samples/18.bot-authentication/dialogs/logout_dialog.py deleted file mode 100644 index de77e5c04..000000000 --- a/samples/18.bot-authentication/dialogs/logout_dialog.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import DialogTurnResult, ComponentDialog, DialogContext -from botbuilder.core import BotFrameworkAdapter -from botbuilder.schema import ActivityTypes - - -class LogoutDialog(ComponentDialog): - def __init__(self, dialog_id: str, connection_name: str): - super(LogoutDialog, self).__init__(dialog_id) - - self.connection_name = connection_name - - async def on_begin_dialog( - self, inner_dc: DialogContext, options: object - ) -> DialogTurnResult: - return await inner_dc.begin_dialog(self.initial_dialog_id, options) - - async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: - return await inner_dc.continue_dialog() - - async def _interrupt(self, inner_dc: DialogContext): - if inner_dc.context.activity.type == ActivityTypes.message: - text = inner_dc.context.activity.text.lower() - if text == "logout": - bot_adapter: BotFrameworkAdapter = inner_dc.context.adapter - await bot_adapter.sign_out_user(inner_dc.context, self.connection_name) - return await inner_dc.cancel_all_dialogs() diff --git a/samples/18.bot-authentication/dialogs/main_dialog.py b/samples/18.bot-authentication/dialogs/main_dialog.py deleted file mode 100644 index 964a3aff2..000000000 --- a/samples/18.bot-authentication/dialogs/main_dialog.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory -from botbuilder.dialogs import ( - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, - PromptOptions, -) -from botbuilder.dialogs.prompts import OAuthPrompt, OAuthPromptSettings, ConfirmPrompt - -from dialogs import LogoutDialog - - -class MainDialog(LogoutDialog): - def __init__(self, connection_name: str): - super(MainDialog, self).__init__(MainDialog.__name__, connection_name) - - self.add_dialog( - OAuthPrompt( - OAuthPrompt.__name__, - OAuthPromptSettings( - connection_name=connection_name, - text="Please Sign In", - title="Sign In", - timeout=300000, - ), - ) - ) - - self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - - self.add_dialog( - WaterfallDialog( - "WFDialog", - [ - self.prompt_step, - self.login_step, - self.display_token_phase1, - self.display_token_phase2, - ], - ) - ) - - self.initial_dialog_id = "WFDialog" - - async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - return await step_context.begin_dialog(OAuthPrompt.__name__) - - async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # Get the token from the previous step. Note that we could also have gotten the - # token directly from the prompt itself. There is an example of this in the next method. - if step_context.result: - await step_context.context.send_activity("You are now logged in.") - return await step_context.prompt( - ConfirmPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("Would you like to view your token?") - ), - ) - - await step_context.context.send_activity( - "Login was not successful please try again." - ) - return await step_context.end_dialog() - - async def display_token_phase1( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - await step_context.context.send_activity("Thank you.") - - if step_context.result: - # Call the prompt again because we need the token. The reasons for this are: - # 1. If the user is already logged in we do not need to store the token locally in the bot and worry - # about refreshing it. We can always just call the prompt again to get the token. - # 2. We never know how long it will take a user to respond. By the time the - # user responds the token may have expired. The user would then be prompted to login again. - # - # There is no reason to store the token locally in the bot because we can always just call - # the OAuth prompt to get the token or get a new token if needed. - return await step_context.begin_dialog(OAuthPrompt.__name__) - - return await step_context.end_dialog() - - async def display_token_phase2( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - if step_context.result: - await step_context.context.send_activity( - f"Here is your token {step_context.result['token']}" - ) - - return await step_context.end_dialog() diff --git a/samples/18.bot-authentication/helpers/__init__.py b/samples/18.bot-authentication/helpers/__init__.py deleted file mode 100644 index a824eb8f4..000000000 --- a/samples/18.bot-authentication/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import dialog_helper - -__all__ = ["dialog_helper"] diff --git a/samples/18.bot-authentication/helpers/dialog_helper.py b/samples/18.bot-authentication/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/18.bot-authentication/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/18.bot-authentication/requirements.txt b/samples/18.bot-authentication/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/18.bot-authentication/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/19.custom-dialogs/README.md b/samples/19.custom-dialogs/README.md deleted file mode 100644 index 14874d971..000000000 --- a/samples/19.custom-dialogs/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Custom Dialogs - -Bot Framework v4 custom dialogs bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to sub-class the `Dialog` class to create different bot control mechanism like simple slot filling. - -BotFramework provides a built-in base class called `Dialog`. By subclassing `Dialog`, developers can create new ways to define and control dialog flows used by the bot. - -## 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\19.custom-dialogs` 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 - -## Custom Dialogs - -BotFramework provides a built-in base class called `Dialog`. By subclassing Dialog, developers -can create new ways to define and control dialog flows used by the bot. By adhering to the -features of this class, developers will create custom dialogs that can be used side-by-side -with other dialog types, as well as built-in or custom prompts. - -This example demonstrates a custom Dialog class called `SlotFillingDialog`, which takes a -series of "slots" which define a value the bot needs to collect from the user, as well -as the prompt it should use. The bot will iterate through all of the slots until they are -all full, at which point the dialog completes. - -# 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) -- [Dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Dialog class reference](https://docs.microsoft.com/en-us/javascript/api/botbuilder-dialogs/dialog) -- [Manage complex conversation flows with dialogs](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-dialog-manage-complex-conversation-flow?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) diff --git a/samples/19.custom-dialogs/app.py b/samples/19.custom-dialogs/app.py deleted file mode 100644 index 1c3579210..000000000 --- a/samples/19.custom-dialogs/app.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import DialogBot - -# Create the loop and Flask app -from dialogs.root_dialog import RootDialog - -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 MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create Dialog and Bot -DIALOG = RootDialog(USER_STATE) -BOT = DialogBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -# 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 diff --git a/samples/19.custom-dialogs/bots/__init__.py b/samples/19.custom-dialogs/bots/__init__.py deleted file mode 100644 index 306aca22c..000000000 --- a/samples/19.custom-dialogs/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_bot import DialogBot - -__all__ = ["DialogBot"] diff --git a/samples/19.custom-dialogs/bots/dialog_bot.py b/samples/19.custom-dialogs/bots/dialog_bot.py deleted file mode 100644 index 2edc0dbe4..000000000 --- a/samples/19.custom-dialogs/bots/dialog_bot.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState -from botbuilder.dialogs import Dialog - -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occurred during the turn. - await self.conversation_state.save_changes(turn_context) - await self.user_state.save_changes(turn_context) - - async def on_message_activity(self, turn_context: TurnContext): - # Run the Dialog with the new message Activity. - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/19.custom-dialogs/config.py b/samples/19.custom-dialogs/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/19.custom-dialogs/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/19.custom-dialogs/dialogs/__init__.py b/samples/19.custom-dialogs/dialogs/__init__.py deleted file mode 100644 index 83d4d61d3..000000000 --- a/samples/19.custom-dialogs/dialogs/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .slot_filling_dialog import SlotFillingDialog -from .root_dialog import RootDialog - -__all__ = ["RootDialog", "SlotFillingDialog"] diff --git a/samples/19.custom-dialogs/dialogs/root_dialog.py b/samples/19.custom-dialogs/dialogs/root_dialog.py deleted file mode 100644 index 5d371ce6a..000000000 --- a/samples/19.custom-dialogs/dialogs/root_dialog.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Dict -from recognizers_text import Culture - -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, - NumberPrompt, - PromptValidatorContext, -) -from botbuilder.dialogs.prompts import TextPrompt -from botbuilder.core import MessageFactory, UserState - -from dialogs import SlotFillingDialog -from dialogs.slot_details import SlotDetails - - -class RootDialog(ComponentDialog): - def __init__(self, user_state: UserState): - super(RootDialog, self).__init__(RootDialog.__name__) - - self.user_state_accessor = user_state.create_property("result") - - # Rather than explicitly coding a Waterfall we have only to declare what properties we want collected. - # In this example we will want two text prompts to run, one for the first name and one for the last - fullname_slots = [ - SlotDetails( - name="first", dialog_id="text", prompt="Please enter your first name." - ), - SlotDetails( - name="last", dialog_id="text", prompt="Please enter your last name." - ), - ] - - # This defines an address dialog that collects street, city and zip properties. - address_slots = [ - SlotDetails( - name="street", - dialog_id="text", - prompt="Please enter the street address.", - ), - SlotDetails(name="city", dialog_id="text", prompt="Please enter the city."), - SlotDetails(name="zip", dialog_id="text", prompt="Please enter the zip."), - ] - - # Dialogs can be nested and the slot filling dialog makes use of that. In this example some of the child - # dialogs are slot filling dialogs themselves. - slots = [ - SlotDetails(name="fullname", dialog_id="fullname",), - SlotDetails( - name="age", dialog_id="number", prompt="Please enter your age." - ), - SlotDetails( - name="shoesize", - dialog_id="shoesize", - prompt="Please enter your shoe size.", - retry_prompt="You must enter a size between 0 and 16. Half sizes are acceptable.", - ), - SlotDetails(name="address", dialog_id="address"), - ] - - # Add the various dialogs that will be used to the DialogSet. - self.add_dialog(SlotFillingDialog("address", address_slots)) - self.add_dialog(SlotFillingDialog("fullname", fullname_slots)) - self.add_dialog(TextPrompt("text")) - self.add_dialog(NumberPrompt("number", default_locale=Culture.English)) - self.add_dialog( - NumberPrompt( - "shoesize", - RootDialog.shoe_size_validator, - default_locale=Culture.English, - ) - ) - self.add_dialog(SlotFillingDialog("slot-dialog", slots)) - - # Defines a simple two step Waterfall to test the slot dialog. - self.add_dialog( - WaterfallDialog("waterfall", [self.start_dialog, self.process_result]) - ) - - # The initial child Dialog to run. - self.initial_dialog_id = "waterfall" - - async def start_dialog( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - # Start the child dialog. This will run the top slot dialog than will complete when all the properties are - # gathered. - return await step_context.begin_dialog("slot-dialog") - - async def process_result( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - # To demonstrate that the slot dialog collected all the properties we will echo them back to the user. - if isinstance(step_context.result, dict) and len(step_context.result) > 0: - fullname: Dict[str, object] = step_context.result["fullname"] - shoe_size: float = step_context.result["shoesize"] - address: dict = step_context.result["address"] - - # store the response on UserState - obj: dict = await self.user_state_accessor.get(step_context.context, dict) - obj["data"] = {} - obj["data"]["fullname"] = f"{fullname.get('first')} {fullname.get('last')}" - obj["data"]["shoesize"] = f"{shoe_size}" - obj["data"][ - "address" - ] = f"{address['street']}, {address['city']}, {address['zip']}" - - # show user the values - await step_context.context.send_activity( - MessageFactory.text(obj["data"]["fullname"]) - ) - await step_context.context.send_activity( - MessageFactory.text(obj["data"]["shoesize"]) - ) - await step_context.context.send_activity( - MessageFactory.text(obj["data"]["address"]) - ) - - return await step_context.end_dialog() - - @staticmethod - async def shoe_size_validator(prompt_context: PromptValidatorContext) -> bool: - shoe_size = round(prompt_context.recognized.value, 1) - - # show sizes can range from 0 to 16, whole or half sizes only - if 0 <= shoe_size <= 16 and (shoe_size * 2) % 1 == 0: - prompt_context.recognized.value = shoe_size - return True - return False diff --git a/samples/19.custom-dialogs/dialogs/slot_details.py b/samples/19.custom-dialogs/dialogs/slot_details.py deleted file mode 100644 index 172d81c67..000000000 --- a/samples/19.custom-dialogs/dialogs/slot_details.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory -from botbuilder.dialogs import PromptOptions - - -class SlotDetails: - def __init__( - self, - name: str, - dialog_id: str, - options: PromptOptions = None, - prompt: str = None, - retry_prompt: str = None, - ): - self.name = name - self.dialog_id = dialog_id - self.options = ( - options - if options - else PromptOptions( - prompt=MessageFactory.text(prompt), - retry_prompt=None - if retry_prompt is None - else MessageFactory.text(retry_prompt), - ) - ) diff --git a/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py b/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py deleted file mode 100644 index 6e354431a..000000000 --- a/samples/19.custom-dialogs/dialogs/slot_filling_dialog.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List, Dict - -from botbuilder.dialogs import ( - DialogContext, - DialogTurnResult, - Dialog, - DialogInstance, - DialogReason, -) -from botbuilder.schema import ActivityTypes - -from dialogs.slot_details import SlotDetails - - -class SlotFillingDialog(Dialog): - """ - This is an example of implementing a custom Dialog class. This is similar to the Waterfall dialog in the - framework; however, it is based on a Dictionary rather than a sequential set of functions. The dialog is defined - by a list of 'slots', each slot represents a property we want to gather and the dialog we will be using to - collect it. Often the property is simply an atomic piece of data such as a number or a date. But sometimes the - property is itself a complex object, in which case we can use the slot dialog to collect that compound property. - """ - - def __init__(self, dialog_id: str, slots: List[SlotDetails]): - super(SlotFillingDialog, self).__init__(dialog_id) - - # Custom dialogs might define their own custom state. Similarly to the Waterfall dialog we will have a set of - # values in the ConversationState. However, rather than persisting an index we will persist the last property - # we prompted for. This way when we resume this code following a prompt we will have remembered what property - # we were filling. - self.SLOT_NAME = "slot" - self.PERSISTED_VALUES = "values" - - # The list of slots defines the properties to collect and the dialogs to use to collect them. - self.slots = slots - - async def begin_dialog( - self, dialog_context: "DialogContext", options: object = None - ): - if dialog_context.context.activity.type != ActivityTypes.message: - return await dialog_context.end_dialog({}) - return await self._run_prompt(dialog_context) - - async def continue_dialog(self, dialog_context: "DialogContext"): - if dialog_context.context.activity.type != ActivityTypes.message: - return Dialog.end_of_turn - return await self._run_prompt(dialog_context) - - async def resume_dialog( - self, dialog_context: DialogContext, reason: DialogReason, result: object - ): - slot_name = dialog_context.active_dialog.state[self.SLOT_NAME] - values = self._get_persisted_values(dialog_context.active_dialog) - values[slot_name] = result - - return await self._run_prompt(dialog_context) - - async def _run_prompt(self, dialog_context: DialogContext) -> DialogTurnResult: - """ - This helper function contains the core logic of this dialog. The main idea is to compare the state we have - gathered with the list of slots we have been asked to fill. When we find an empty slot we execute the - corresponding prompt. - :param dialog_context: - :return: - """ - state = self._get_persisted_values(dialog_context.active_dialog) - - # Run through the list of slots until we find one that hasn't been filled yet. - unfilled_slot = None - for slot_detail in self.slots: - if slot_detail.name not in state: - unfilled_slot = slot_detail - break - - # If we have an unfilled slot we will try to fill it - if unfilled_slot: - # The name of the slot we will be prompting to fill. - dialog_context.active_dialog.state[self.SLOT_NAME] = unfilled_slot.name - - # Run the child dialog - return await dialog_context.begin_dialog( - unfilled_slot.dialog_id, unfilled_slot.options - ) - - # No more slots to fill so end the dialog. - return await dialog_context.end_dialog(state) - - def _get_persisted_values( - self, dialog_instance: DialogInstance - ) -> Dict[str, object]: - obj = dialog_instance.state.get(self.PERSISTED_VALUES) - - if not obj: - obj = {} - dialog_instance.state[self.PERSISTED_VALUES] = obj - - return obj diff --git a/samples/19.custom-dialogs/helpers/__init__.py b/samples/19.custom-dialogs/helpers/__init__.py deleted file mode 100644 index a824eb8f4..000000000 --- a/samples/19.custom-dialogs/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import dialog_helper - -__all__ = ["dialog_helper"] diff --git a/samples/19.custom-dialogs/helpers/dialog_helper.py b/samples/19.custom-dialogs/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/19.custom-dialogs/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/19.custom-dialogs/requirements.txt b/samples/19.custom-dialogs/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/19.custom-dialogs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/21.corebot-app-insights/NOTICE.md b/samples/21.corebot-app-insights/NOTICE.md deleted file mode 100644 index 056c7237f..000000000 --- a/samples/21.corebot-app-insights/NOTICE.md +++ /dev/null @@ -1,8 +0,0 @@ -## NOTICE - -Please note that while the 21.corebot-app-insights sample is licensed under the MIT license, the sample has dependencies that use other types of licenses. - -Since Microsoft does not modify nor distribute these dependencies, it is the sole responsibility of the user to determine the correct/compliant usage of these dependencies. Please refer to the -[bot requirements](./bot/requirements.txt), [model requirements](./model/setup.py) and [model runtime requirements](./model_runtime_svc/setup.py) for a list of the **direct** dependencies. - -Please also note that the sample depends on the `requests` package, which has a dependency `chardet` that uses LGPL license. \ No newline at end of file diff --git a/samples/21.corebot-app-insights/README-LUIS.md b/samples/21.corebot-app-insights/README-LUIS.md deleted file mode 100644 index 61bde7719..000000000 --- a/samples/21.corebot-app-insights/README-LUIS.md +++ /dev/null @@ -1,216 +0,0 @@ -# Setting up LUIS via CLI: - -This README contains information on how to create and deploy a LUIS application. When the bot is ready to be deployed to production, we recommend creating a LUIS Endpoint Resource for usage with your LUIS App. - -> _For instructions on how to create a LUIS Application via the LUIS portal, see these Quickstart steps:_ -> 1. _[Quickstart: Create a new app in the LUIS portal][Quickstart-create]_ -> 2. _[Quickstart: Deploy an app in the LUIS portal][Quickstart-deploy]_ - - [Quickstart-create]: https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-build-app - [Quickstart-deploy]:https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-deploy-app - -## Table of Contents: - -- [Prerequisites](#Prerequisites) -- [Import a new LUIS Application using a local LUIS application](#Import-a-new-LUIS-Application-using-a-local-LUIS-application) -- [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#How-to-create-a-LUIS-Endpoint-resource-in-Azure-and-pair-it-with-a-LUIS-Application) - -___ - -## [Prerequisites](#Table-of-Contents): - -#### Install Azure CLI >=2.0.61: - -Visit the following page to find the correct installer for your OS: -- https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest - -#### Install LUIS CLI >=2.4.0: - -Open a CLI of your choice and type the following: - -```bash -npm i -g luis-apis@^2.4.0 -``` - -#### LUIS portal account: - -You should already have a LUIS account with either https://luis.ai, https://eu.luis.ai, or https://au.luis.ai. To determine where to create a LUIS account, consider where you will deploy your LUIS applications, and then place them in [the corresponding region][LUIS-Authoring-Regions]. - -After you've created your account, you need your [Authoring Key][LUIS-AKey] and a LUIS application ID. - - [LUIS-Authoring-Regions]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-reference-regions#luis-authoring-regions] - [LUIS-AKey]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-concept-keys#authoring-key - -___ - -## [Import a new LUIS Application using a local LUIS application](#Table-of-Contents) - -### 1. Import the local LUIS application to luis.ai - -```bash -luis import application --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appName "FlightBooking" --in "./cognitiveModels/FlightBooking.json" -``` - -Outputs the following JSON: - -```json -{ - "id": "########-####-####-####-############", - "name": "FlightBooking", - "description": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "usageScenario": "", - "domain": "", - "versionsCount": 1, - "createdDateTime": "2019-03-29T18:32:02Z", - "endpoints": {}, - "endpointHitsCount": 0, - "activeVersion": "0.1", - "ownerEmail": "bot@contoso.com", - "tokenizerVersion": "1.0.0" -} -``` - -For the next step, you'll need the `"id"` value for `--appId` and the `"activeVersion"` value for `--versionId`. - -### 2. Train the LUIS Application - -```bash -luis train version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --wait -``` - -### 3. Publish the LUIS Application - -```bash -luis publish version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --publishRegion "LuisAppPublishRegion" -``` - -> `--region` corresponds to the region you _author_ your application in. The regions available for this are "westus", "westeurope" and "australiaeast".
-> These regions correspond to the three available portals, https://luis.ai, https://eu.luis.ai, or https://au.luis.ai.
-> `--publishRegion` corresponds to the region of the endpoint you're publishing to, (e.g. "westus", "southeastasia", "westeurope", "brazilsouth").
-> See the [reference docs][Endpoint-API] for a list of available publish/endpoint regions. - - [Endpoint-API]: https://westus.dev.cognitive.microsoft.com/docs/services/5819c76f40a6350ce09de1ac/operations/5819c77140a63516d81aee78 - -Outputs the following: - -```json - { - "versionId": "0.1", - "isStaging": false, - "endpointUrl": "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/########-####-####-####-############", - "region": "westus", - "assignedEndpointKey": null, - "endpointRegion": "westus", - "failedRegions": "", - "publishedDateTime": "2019-03-29T18:40:32Z", - "directVersionPublish": false -} -``` - -To see how to create an LUIS Cognitive Service Resource in Azure, please see [the next README][README-LUIS]. This Resource should be used when you want to move your bot to production. The instructions will show you how to create and pair the resource with a LUIS Application. - - [README-LUIS]: ./README-LUIS.md - -___ - -## [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#Table-of-Contents) - -### 1. Create a new LUIS Cognitive Services resource on Azure via Azure CLI - -> _Note:_
-> _If you don't have a Resource Group in your Azure subscription, you can create one through the Azure portal or through using:_ -> ```bash -> az group create --subscription "AzureSubscriptionGuid" --location "westus" --name "ResourceGroupName" -> ``` -> _To see a list of valid locations, use `az account list-locations`_ - - -```bash -# Use Azure CLI to create the LUIS Key resource on Azure -az cognitiveservices account create --kind "luis" --name "NewLuisResourceName" --sku "S0" --location "westus" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -The command will output a response similar to the JSON below: - -```json -{ - "endpoint": "https://westus.api.cognitive.microsoft.com/luis/v2.0", - "etag": "\"########-####-####-####-############\"", - "id": "/subscriptions/########-####-####-####-############/resourceGroups/ResourceGroupName/providers/Microsoft.CognitiveServices/accounts/NewLuisResourceName", - "internalId": "################################", - "kind": "luis", - "location": "westus", - "name": "NewLuisResourceName", - "provisioningState": "Succeeded", - "resourceGroup": "ResourceGroupName", - "sku": { - "name": "S0", - "tier": null - }, - "tags": null, - "type": "Microsoft.CognitiveServices/accounts" -} -``` - - - -Take the output from the previous command and create a JSON file in the following format: - -```json -{ - "azureSubscriptionId": "00000000-0000-0000-0000-000000000000", - "resourceGroup": "ResourceGroupName", - "accountName": "NewLuisResourceName" -} -``` - -### 2. Retrieve ARM access token via Azure CLI - -```bash -az account get-access-token --subscription "AzureSubscriptionGuid" -``` - -This will return an object that looks like this: - -```json -{ - "accessToken": "eyJ0eXAiOiJKVtokentokentokentokentokeng1dCI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyIsItokenI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTUzODc3MTUwLCJuYmYiOjE1NTM4NzcxNTAsImV4cCI6MTU1Mzg4MTA1MCwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2ZmZTQyM2RkLWJhM2YtNDg0Ny04NjgyLWExNTI5MDA4MjM4Ny9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVZRQXEvOEtBQUFBeGVUc201NDlhVHg4RE1mMFlRVnhGZmxxOE9RSC9PODR3QktuSmRqV1FqTkkwbmxLYzB0bHJEZzMyMFZ5bWZGaVVBSFBvNUFFUTNHL0FZNDRjdk01T3M0SEt0OVJkcE5JZW9WU0dzd0kvSkk9IiwiYW1yIjpbIndpYSIsIm1mYSJdLCJhcHBpZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiNDhmNDVjNjEtMTg3Zi00MjUxLTlmZWItMTllZGFkZmMwMmE3IiwiZmFtaWx5X25hbWUiOiJHdW0iLCJnaXZlbl9uYW1lIjoiU3RldmVuIiwiaXBhZGRyIjoiMTY3LjIyMC4yLjU1IiwibmFtZSI6IlN0ZXZlbiBHdW0iLCJvaWQiOiJmZmU0MjNkZC1iYTNmLTQ4NDctODY4Mi1hMTUyOTAwODIzODciLCJvbnByZW1fc2lkIjoiUy0xLTUtMjEtMjEyNzUyMTE4NC0xNjA0MDEyOTIwLTE4ODc5Mjc1MjctMjYwOTgyODUiLCJwdWlkIjoiMTAwMzdGRkVBMDQ4NjlBNyIsInJoIjoiSSIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ik1rMGRNMWszN0U5ckJyMjhieUhZYjZLSU85LXVFQVVkZFVhNWpkSUd1Nk0iLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InN0Z3VtQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzdGd1bUBtaWNyb3NvZnQuY29tIiwidXRpIjoiT2w2NGN0TXY4RVNEQzZZQWRqRUFtokenInZlciI6IjEuMCJ9.kFAsEilE0mlS1pcpqxf4rEnRKeYsehyk-gz-zJHUrE__oad3QjgDSBDPrR_ikLdweynxbj86pgG4QFaHURNCeE6SzrbaIrNKw-n9jrEtokenlosOxg_0l2g1LeEUOi5Q4gQREAU_zvSbl-RY6sAadpOgNHtGvz3Rc6FZRITfkckSLmsKAOFoh-aWC6tFKG8P52rtB0qVVRz9tovBeNqkMYL49s9ypduygbXNVwSQhm5JszeWDgrFuVFHBUP_iENCQYGQpEZf_KvjmX1Ur1F9Eh9nb4yI2gFlKncKNsQl-tokenK7-tokentokentokentokentokentokenatoken", - "expiresOn": "2200-12-31 23:59:59.999999", - "subscription": "AzureSubscriptionGuid", - "tenant": "tenant-guid", - "tokenType": "Bearer" -} -``` - -The value needed for the next step is the `"accessToken"`. - -### 3. Use `luis add appazureaccount` to pair your LUIS resource with a LUIS Application - -```bash -luis add appazureaccount --in "path/to/created/requestBody.json" --appId "LuisAppId" --authoringKey "LuisAuthoringKey" --armToken "accessToken" -``` - -If successful, it should yield a response like this: - -```json -{ - "code": "Success", - "message": "Operation Successful" -} -``` - -### 4. See the LUIS Cognitive Services' keys - -```bash -az cognitiveservices account keys list --name "NewLuisResourceName" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -This will return an object that looks like this: - -```json -{ - "key1": "9a69####dc8f####8eb4####399f####", - "key2": "####f99e####4b1a####fb3b####6b9f" -} -``` \ No newline at end of file diff --git a/samples/21.corebot-app-insights/README.md b/samples/21.corebot-app-insights/README.md deleted file mode 100644 index bea70586c..000000000 --- a/samples/21.corebot-app-insights/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# CoreBot with Application Insights - -Bot Framework v4 core bot sample. - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to: - -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities -- Implement a multi-turn conversation using Dialogs -- Handle user interruptions for such things as `Help` or `Cancel` -- Prompt for and validate requests for information from the user -- Use [Application Insights](https://docs.microsoft.com/azure/azure-monitor/app/cloudservices) to monitor your bot - -## Prerequisites - -### Install Python 3.6 - -### Overview - -This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding -and [Application Insights](https://docs.microsoft.com/azure/azure-monitor/app/cloudservices), an extensible Application Performance Management (APM) service for web developers on multiple platforms. - -### Create a LUIS Application to enable language understanding - -LUIS language model setup, training, and application configuration steps can be found [here](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=cs). - -If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). - -### Add Application Insights service to enable the bot monitoring -Application Insights resource creation steps can be found [here](https://docs.microsoft.com/azure/azure-monitor/app/create-new-resource). - -## Running the sample -- Run `pip install -r requirements.txt` to install all dependencies -- Update LuisAppId, LuisAPIKey and LuisAPIHostName in `config.py` with the information retrieved from the [LUIS portal](https://www.luis.ai) -- Update AppInsightsInstrumentationKey in `config.py` -- Run `python app.py` -- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` - - -## 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` - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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) -- [Language Understanding using LUIS](https://docs.microsoft.com/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) -- [Application insights Overview](https://docs.microsoft.com/azure/azure-monitor/app/app-insights-overview) -- [Getting Started with Application Insights](https://github.com/Microsoft/ApplicationInsights-aspnetcore/wiki/Getting-Started-with-Application-Insights-for-ASP.NET-Core) -- [Filtering and preprocessing telemetry in the Application Insights SDK](https://docs.microsoft.com/azure/azure-monitor/app/api-filtering-sampling) diff --git a/samples/21.corebot-app-insights/app.py b/samples/21.corebot-app-insights/app.py deleted file mode 100644 index 91d2f29af..000000000 --- a/samples/21.corebot-app-insights/app.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -This sample shows how to create a bot that demonstrates the following: -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. -- Implement a multi-turn conversation using Dialogs. -- Handle user interruptions for such things as `Help` or `Cancel`. -- Prompt for and validate requests for information from the user. - -""" - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - UserState, - TurnContext, -) -from botbuilder.schema import Activity, ActivityTypes -from botbuilder.applicationinsights import ApplicationInsightsTelemetryClient -from botbuilder.applicationinsights.flask import BotTelemetryMiddleware - -from dialogs import MainDialog -from bots import DialogAndWelcomeBot - -# Create the loop and Flask app -LOOP = asyncio.get_event_loop() -APP = Flask(__name__, instance_relative_config=True) -APP.config.from_object("config.DefaultConfig") -APP.wsgi_app = BotTelemetryMiddleware(APP.wsgi_app) - -# 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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - - -# Set the error handler on the Adapter. -# In this case, we want an unbound method, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage, UserState and ConversationState -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create telemetry client -INSTRUMENTATION_KEY = APP.config["APPINSIGHTS_INSTRUMENTATION_KEY"] -TELEMETRY_CLIENT = ApplicationInsightsTelemetryClient(INSTRUMENTATION_KEY) - -# Create dialog and Bot -DIALOG = MainDialog(APP.config, telemetry_client=TELEMETRY_CLIENT) -BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG, TELEMETRY_CLIENT) - - -# 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=True, port=APP.config["PORT"]) - - except Exception as exception: - raise exception diff --git a/samples/21.corebot-app-insights/booking_details.py b/samples/21.corebot-app-insights/booking_details.py deleted file mode 100644 index 81f420fa6..000000000 --- a/samples/21.corebot-app-insights/booking_details.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Booking detail.""" - - -class BookingDetails: - """Booking detail implementation""" - - def __init__( - self, destination: str = None, origin: str = None, travel_date: str = None - ): - self.destination = destination - self.origin = origin - self.travel_date = travel_date diff --git a/samples/21.corebot-app-insights/bots/__init__.py b/samples/21.corebot-app-insights/bots/__init__.py deleted file mode 100644 index 7c71ff86f..000000000 --- a/samples/21.corebot-app-insights/bots/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""bots module.""" - -from .dialog_bot import DialogBot -from .dialog_and_welcome_bot import DialogAndWelcomeBot - -__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/samples/21.corebot-app-insights/bots/dialog_and_welcome_bot.py b/samples/21.corebot-app-insights/bots/dialog_and_welcome_bot.py deleted file mode 100644 index 80f37ea71..000000000 --- a/samples/21.corebot-app-insights/bots/dialog_and_welcome_bot.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Main dialog to welcome users.""" -import json -import os.path - -from typing import List -from botbuilder.dialogs import Dialog -from botbuilder.core import ( - TurnContext, - ConversationState, - UserState, - BotTelemetryClient, -) -from botbuilder.schema import Activity, Attachment, ChannelAccount -from helpers.activity_helper import create_activity_reply -from .dialog_bot import DialogBot - - -class DialogAndWelcomeBot(DialogBot): - """Main dialog to welcome users.""" - - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - telemetry_client: BotTelemetryClient, - ): - super(DialogAndWelcomeBot, self).__init__( - conversation_state, user_state, dialog, telemetry_client - ) - self.telemetry_client = telemetry_client - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - # Greet anyone that was not the target (recipient) of this message. - # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards - # for more details. - if member.id != turn_context.activity.recipient.id: - welcome_card = self.create_adaptive_card_attachment() - response = self.create_response(turn_context.activity, welcome_card) - await turn_context.send_activity(response) - - def create_response(self, activity: Activity, attachment: Attachment): - """Create an attachment message response.""" - response = create_activity_reply(activity) - response.attachments = [attachment] - return response - - # Load attachment from file. - def create_adaptive_card_attachment(self): - """Create an adaptive card.""" - relative_path = os.path.abspath(os.path.dirname(__file__)) - path = os.path.join(relative_path, "resources/welcomeCard.json") - with open(path) as card_file: - card = json.load(card_file) - - return Attachment( - content_type="application/vnd.microsoft.card.adaptive", content=card - ) diff --git a/samples/21.corebot-app-insights/bots/dialog_bot.py b/samples/21.corebot-app-insights/bots/dialog_bot.py deleted file mode 100644 index 8c9322bc9..000000000 --- a/samples/21.corebot-app-insights/bots/dialog_bot.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Implements bot Activity handler.""" - -from botbuilder.core import ( - ActivityHandler, - ConversationState, - UserState, - TurnContext, - BotTelemetryClient, - NullTelemetryClient, -) -from botbuilder.dialogs import Dialog -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - """Main activity handler for the bot.""" - - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - telemetry_client: BotTelemetryClient, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - self.telemetry_client = telemetry_client - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occured during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) - - @property - def telemetry_client(self) -> BotTelemetryClient: - """ - Gets the telemetry client for logging events. - """ - return self._telemetry_client - - # pylint:disable=attribute-defined-outside-init - @telemetry_client.setter - def telemetry_client(self, value: BotTelemetryClient) -> None: - """ - Sets the telemetry client for logging events. - """ - if value is None: - self._telemetry_client = NullTelemetryClient() - else: - self._telemetry_client = value diff --git a/samples/21.corebot-app-insights/bots/resources/welcomeCard.json b/samples/21.corebot-app-insights/bots/resources/welcomeCard.json deleted file mode 100644 index d9a35548c..000000000 --- a/samples/21.corebot-app-insights/bots/resources/welcomeCard.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.0", - "body": [ - { - "type": "Image", - "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", - "size": "stretch" - }, - { - "type": "TextBlock", - "spacing": "medium", - "size": "default", - "weight": "bolder", - "text": "Welcome to Bot Framework!", - "wrap": true, - "maxLines": 0 - }, - { - "type": "TextBlock", - "size": "default", - "isSubtle": "true", - "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", - "wrap": true, - "maxLines": 0 - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "Get an overview", - "url": "https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" - }, - { - "type": "Action.OpenUrl", - "title": "Ask a question", - "url": "https://stackoverflow.com/questions/tagged/botframework" - }, - { - "type": "Action.OpenUrl", - "title": "Learn how to deploy", - "url": "https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" - } - ] -} \ No newline at end of file diff --git a/samples/21.corebot-app-insights/cognitiveModels/FlightBooking.json b/samples/21.corebot-app-insights/cognitiveModels/FlightBooking.json deleted file mode 100644 index 5d1c9ec38..000000000 --- a/samples/21.corebot-app-insights/cognitiveModels/FlightBooking.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "luis_schema_version": "3.2.0", - "versionId": "0.1", - "name": "Airline Reservation", - "desc": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "tokenizerVersion": "1.0.0", - "intents": [ - { - "name": "Book flight" - }, - { - "name": "Cancel" - }, - { - "name": "None" - } - ], - "entities": [], - "composites": [ - { - "name": "From", - "children": [ - "Airport" - ], - "roles": [] - }, - { - "name": "To", - "children": [ - "Airport" - ], - "roles": [] - } - ], - "closedLists": [ - { - "name": "Airport", - "subLists": [ - { - "canonicalForm": "Paris", - "list": [ - "paris" - ] - }, - { - "canonicalForm": "London", - "list": [ - "london" - ] - }, - { - "canonicalForm": "Berlin", - "list": [ - "berlin" - ] - }, - { - "canonicalForm": "New York", - "list": [ - "new york" - ] - } - ], - "roles": [] - } - ], - "patternAnyEntities": [], - "regex_entities": [], - "prebuiltEntities": [ - { - "name": "datetimeV2", - "roles": [] - } - ], - "model_features": [], - "regex_features": [], - "patterns": [], - "utterances": [ - { - "text": "book flight from london to paris on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 27, - "endPos": 31 - }, - { - "entity": "From", - "startPos": 17, - "endPos": 22 - } - ] - }, - { - "text": "book flight to berlin on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 15, - "endPos": 20 - } - ] - }, - { - "text": "book me a flight from london to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "From", - "startPos": 22, - "endPos": 27 - }, - { - "entity": "To", - "startPos": 32, - "endPos": 36 - } - ] - }, - { - "text": "bye", - "intent": "Cancel", - "entities": [] - }, - { - "text": "cancel booking", - "intent": "Cancel", - "entities": [] - }, - { - "text": "exit", - "intent": "Cancel", - "entities": [] - }, - { - "text": "flight to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "flight to paris from london on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - }, - { - "entity": "From", - "startPos": 21, - "endPos": 26 - } - ] - }, - { - "text": "fly from berlin to paris on may 5th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 19, - "endPos": 23 - }, - { - "entity": "From", - "startPos": 9, - "endPos": 14 - } - ] - }, - { - "text": "go to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 6, - "endPos": 10 - } - ] - }, - { - "text": "going from paris to berlin", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 20, - "endPos": 25 - }, - { - "entity": "From", - "startPos": 11, - "endPos": 15 - } - ] - }, - { - "text": "ignore", - "intent": "Cancel", - "entities": [] - }, - { - "text": "travel to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - } - ], - "settings": [] -} \ No newline at end of file diff --git a/samples/21.corebot-app-insights/config.py b/samples/21.corebot-app-insights/config.py deleted file mode 100644 index b3c87e304..000000000 --- a/samples/21.corebot-app-insights/config.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Configuration for the bot.""" - -import os - - -class DefaultConfig: - """Configuration for the bot.""" - - PORT = 3978 - APP_ID = os.environ.get("MicrosoftAppId", "") - APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") - LUIS_APP_ID = os.environ.get("LuisAppId", "") - LUIS_API_KEY = os.environ.get("LuisAPIKey", "") - # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" - LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") - APPINSIGHTS_INSTRUMENTATION_KEY = os.environ.get( - "AppInsightsInstrumentationKey", "" - ) diff --git a/samples/21.corebot-app-insights/dialogs/__init__.py b/samples/21.corebot-app-insights/dialogs/__init__.py deleted file mode 100644 index d37afdc97..000000000 --- a/samples/21.corebot-app-insights/dialogs/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Dialogs module""" -from .booking_dialog import BookingDialog -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog -from .main_dialog import MainDialog - -__all__ = ["BookingDialog", "CancelAndHelpDialog", "DateResolverDialog", "MainDialog"] diff --git a/samples/21.corebot-app-insights/dialogs/booking_dialog.py b/samples/21.corebot-app-insights/dialogs/booking_dialog.py deleted file mode 100644 index ab9b341b8..000000000 --- a/samples/21.corebot-app-insights/dialogs/booking_dialog.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Flight booking dialog.""" - -from datatypes_date_time.timex import Timex - -from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult -from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions -from botbuilder.core import MessageFactory, BotTelemetryClient, NullTelemetryClient -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog - - -class BookingDialog(CancelAndHelpDialog): - """Flight booking implementation.""" - - def __init__( - self, - dialog_id: str = None, - telemetry_client: BotTelemetryClient = NullTelemetryClient(), - ): - super(BookingDialog, self).__init__( - dialog_id or BookingDialog.__name__, telemetry_client - ) - self.telemetry_client = telemetry_client - text_prompt = TextPrompt(TextPrompt.__name__) - text_prompt.telemetry_client = telemetry_client - - waterfall_dialog = WaterfallDialog( - WaterfallDialog.__name__, - [ - self.destination_step, - self.origin_step, - self.travel_date_step, - # self.confirm_step, - self.final_step, - ], - ) - waterfall_dialog.telemetry_client = telemetry_client - - self.add_dialog(text_prompt) - # self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - self.add_dialog( - DateResolverDialog(DateResolverDialog.__name__, self.telemetry_client) - ) - self.add_dialog(waterfall_dialog) - - self.initial_dialog_id = WaterfallDialog.__name__ - - async def destination_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Prompt for destination.""" - booking_details = step_context.options - - if booking_details.destination is None: - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("To what city would you like to travel?") - ), - ) # pylint: disable=line-too-long,bad-continuation - - return await step_context.next(booking_details.destination) - - async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Prompt for origin city.""" - booking_details = step_context.options - - # Capture the response to the previous step's prompt - booking_details.destination = step_context.result - if booking_details.origin is None: - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("From what city will you be travelling?") - ), - ) # pylint: disable=line-too-long,bad-continuation - - return await step_context.next(booking_details.origin) - - async def travel_date_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Prompt for travel date. - This will use the DATE_RESOLVER_DIALOG.""" - - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.origin = step_context.result - if not booking_details.travel_date or self.is_ambiguous( - booking_details.travel_date - ): - return await step_context.begin_dialog( - DateResolverDialog.__name__, booking_details.travel_date - ) # pylint: disable=line-too-long - - return await step_context.next(booking_details.travel_date) - - async def confirm_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Confirm the information the user has provided.""" - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.travel_date = step_context.result - msg = ( - f"Please confirm, I have you traveling to: { booking_details.destination }" - f" from: { booking_details.origin } on: { booking_details.travel_date}." - ) - - # Offer a YES/NO prompt. - return await step_context.prompt( - ConfirmPrompt.__name__, PromptOptions(prompt=MessageFactory.text(msg)) - ) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Complete the interaction and end the dialog.""" - if step_context.result: - booking_details = step_context.options - booking_details.travel_date = step_context.result - - return await step_context.end_dialog(booking_details) - - return await step_context.end_dialog() - - def is_ambiguous(self, timex: str) -> bool: - """Ensure time is correct.""" - timex_property = Timex(timex) - return "definite" not in timex_property.types diff --git a/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py b/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py deleted file mode 100644 index 4dab4dbe4..000000000 --- a/samples/21.corebot-app-insights/dialogs/cancel_and_help_dialog.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Handle cancel and help intents.""" - -from botbuilder.core import BotTelemetryClient, NullTelemetryClient -from botbuilder.dialogs import ( - ComponentDialog, - DialogContext, - DialogTurnResult, - DialogTurnStatus, -) -from botbuilder.schema import ActivityTypes - - -class CancelAndHelpDialog(ComponentDialog): - """Implementation of handling cancel and help.""" - - def __init__( - self, - dialog_id: str, - telemetry_client: BotTelemetryClient = NullTelemetryClient(), - ): - super(CancelAndHelpDialog, self).__init__(dialog_id) - self.telemetry_client = telemetry_client - - async def on_begin_dialog( - self, inner_dc: DialogContext, options: object - ) -> DialogTurnResult: - result = await self.interrupt(inner_dc) - if result is not None: - return result - - return await super(CancelAndHelpDialog, self).on_begin_dialog(inner_dc, options) - - async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: - result = await self.interrupt(inner_dc) - if result is not None: - return result - - return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) - - async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: - """Detect interruptions.""" - if inner_dc.context.activity.type == ActivityTypes.message: - text = inner_dc.context.activity.text.lower() - - if text in ("help", "?"): - await inner_dc.context.send_activity("Show Help...") - return DialogTurnResult(DialogTurnStatus.Waiting) - - if text in ("cancel", "quit"): - await inner_dc.context.send_activity("Cancelling") - return await inner_dc.cancel_all_dialogs() - - return None diff --git a/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py b/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py deleted file mode 100644 index baa5224ac..000000000 --- a/samples/21.corebot-app-insights/dialogs/date_resolver_dialog.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Handle date/time resolution for booking dialog.""" - -from datatypes_date_time.timex import Timex - -from botbuilder.core import MessageFactory, BotTelemetryClient, NullTelemetryClient -from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext -from botbuilder.dialogs.prompts import ( - DateTimePrompt, - PromptValidatorContext, - PromptOptions, - DateTimeResolution, -) -from .cancel_and_help_dialog import CancelAndHelpDialog - - -class DateResolverDialog(CancelAndHelpDialog): - """Resolve the date""" - - def __init__( - self, - dialog_id: str = None, - telemetry_client: BotTelemetryClient = NullTelemetryClient(), - ): - super(DateResolverDialog, self).__init__( - dialog_id or DateResolverDialog.__name__, telemetry_client - ) - self.telemetry_client = telemetry_client - - date_time_prompt = DateTimePrompt( - DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator - ) - date_time_prompt.telemetry_client = telemetry_client - - waterfall_dialog = WaterfallDialog( - WaterfallDialog.__name__ + "2", [self.initial_step, self.final_step] - ) - waterfall_dialog.telemetry_client = telemetry_client - - self.add_dialog(date_time_prompt) - self.add_dialog(waterfall_dialog) - - self.initial_dialog_id = WaterfallDialog.__name__ + "2" - - async def initial_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Prompt for the date.""" - timex = step_context.options - - prompt_msg = "On what date would you like to travel?" - reprompt_msg = ( - "I'm sorry, for best results, please enter your travel " - "date including the month, day and year." - ) - - if timex is None: - # We were not given any date at all so prompt the user. - return await step_context.prompt( - DateTimePrompt.__name__, - PromptOptions( # pylint: disable=bad-continuation - prompt=MessageFactory.text(prompt_msg), - retry_prompt=MessageFactory.text(reprompt_msg), - ), - ) - - # We have a Date we just need to check it is unambiguous. - if "definite" in Timex(timex).types: - # This is essentially a "reprompt" of the data we were given up front. - return await step_context.prompt( - DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) - ) - - return await step_context.next(DateTimeResolution(timex=timex)) - - async def final_step(self, step_context: WaterfallStepContext): - """Cleanup - set final return value and end dialog.""" - timex = step_context.result[0].timex - return await step_context.end_dialog(timex) - - @staticmethod - async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: - """ Validate the date provided is in proper form. """ - if prompt_context.recognized.succeeded: - timex = prompt_context.recognized.value[0].timex.split("T")[0] - - # TODO: Needs TimexProperty - return "definite" in Timex(timex).types - - return False diff --git a/samples/21.corebot-app-insights/dialogs/main_dialog.py b/samples/21.corebot-app-insights/dialogs/main_dialog.py deleted file mode 100644 index 6e70deadd..000000000 --- a/samples/21.corebot-app-insights/dialogs/main_dialog.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Main dialog. """ - -from botbuilder.core import BotTelemetryClient, NullTelemetryClient -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.dialogs.prompts import TextPrompt, PromptOptions -from botbuilder.core import MessageFactory -from booking_details import BookingDetails -from helpers.luis_helper import LuisHelper -from .booking_dialog import BookingDialog - - -class MainDialog(ComponentDialog): - """Main dialog. """ - - def __init__( - self, - configuration: dict, - dialog_id: str = None, - telemetry_client: BotTelemetryClient = NullTelemetryClient(), - ): - super(MainDialog, self).__init__(dialog_id or MainDialog.__name__) - - self._configuration = configuration - self.telemetry_client = telemetry_client - - text_prompt = TextPrompt(TextPrompt.__name__) - text_prompt.telemetry_client = self.telemetry_client - - booking_dialog = BookingDialog(telemetry_client=self._telemetry_client) - booking_dialog.telemetry_client = self.telemetry_client - - wf_dialog = WaterfallDialog( - "WFDialog", [self.intro_step, self.act_step, self.final_step] - ) - wf_dialog.telemetry_client = self.telemetry_client - - self.add_dialog(text_prompt) - self.add_dialog(booking_dialog) - self.add_dialog(wf_dialog) - - self.initial_dialog_id = "WFDialog" - - async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Initial prompt.""" - if ( - not self._configuration.get("LUIS_APP_ID", "") - or not self._configuration.get("LUIS_API_KEY", "") - or not self._configuration.get("LUIS_API_HOST_NAME", "") - ): - await step_context.context.send_activity( - MessageFactory.text( - "NOTE: LUIS is not configured. To enable all" - " capabilities, add 'LUIS_APP_ID', 'LUIS_API_KEY' and 'LUIS_API_HOST_NAME'" - " to the config.py file." - ) - ) - - return await step_context.next(None) - - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("What can I help you with today?") - ), - ) - - async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Use language understanding to gather details about booking.""" - # Call LUIS and gather any potential booking details. (Note the TurnContext - # has the response to the prompt.) - booking_details = ( - await LuisHelper.execute_luis_query( - self._configuration, step_context.context, self.telemetry_client - ) - if step_context.result is not None - else BookingDetails() - ) # pylint: disable=bad-continuation - - # In this sample we only have a single Intent we are concerned with. However, - # typically a scenario will have multiple different Intents each corresponding - # to starting a different child Dialog. - - # Run the BookingDialog giving it whatever details we have from the - # model. The dialog will prompt to find out the remaining details. - return await step_context.begin_dialog(BookingDialog.__name__, booking_details) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Complete dialog. - At this step, with details from the user, display the completed - flight booking to the user. - """ - # If the child dialog ("BookingDialog") was cancelled or the user failed - # to confirm, the Result here will be null. - if step_context.result is not None: - result = step_context.result - - # Now we have all the booking details call the booking service. - # If the call to the booking service was successful tell the user. - # time_property = Timex(result.travel_date) - # travel_date_msg = time_property.to_natural_language(datetime.now()) - msg = ( - f"I have you booked to {result.destination} from" - f" {result.origin} on {result.travel_date}." - ) - await step_context.context.send_activity(MessageFactory.text(msg)) - else: - await step_context.context.send_activity(MessageFactory.text("Thank you.")) - return await step_context.end_dialog() diff --git a/samples/21.corebot-app-insights/helpers/__init__.py b/samples/21.corebot-app-insights/helpers/__init__.py deleted file mode 100644 index 162eef503..000000000 --- a/samples/21.corebot-app-insights/helpers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Helpers module.""" - -from . import activity_helper, luis_helper, dialog_helper - -__all__ = ["activity_helper", "dialog_helper", "luis_helper"] diff --git a/samples/21.corebot-app-insights/helpers/activity_helper.py b/samples/21.corebot-app-insights/helpers/activity_helper.py deleted file mode 100644 index bbd0ac848..000000000 --- a/samples/21.corebot-app-insights/helpers/activity_helper.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Helper to create reply object.""" - -from datetime import datetime -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, -) - - -def create_activity_reply(activity: Activity, text: str = None, locale: str = None): - """Helper to create reply object.""" - return Activity( - type=ActivityTypes.message, - timestamp=datetime.utcnow(), - from_property=ChannelAccount( - id=getattr(activity.recipient, "id", None), - name=getattr(activity.recipient, "name", None), - ), - 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 "", - locale=locale or "", - attachments=[], - entities=[], - ) diff --git a/samples/21.corebot-app-insights/helpers/dialog_helper.py b/samples/21.corebot-app-insights/helpers/dialog_helper.py deleted file mode 100644 index 56ba5b05f..000000000 --- a/samples/21.corebot-app-insights/helpers/dialog_helper.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Utility to run dialogs.""" -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - """Dialog Helper implementation.""" - - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): # pylint: disable=line-too-long - """Run dialog.""" - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/21.corebot-app-insights/helpers/luis_helper.py b/samples/21.corebot-app-insights/helpers/luis_helper.py deleted file mode 100644 index 81e28a032..000000000 --- a/samples/21.corebot-app-insights/helpers/luis_helper.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Helper to call LUIS service.""" -from botbuilder.ai.luis import LuisRecognizer, LuisApplication, LuisPredictionOptions -from botbuilder.core import TurnContext, BotTelemetryClient, NullTelemetryClient - -from booking_details import BookingDetails - -# pylint: disable=line-too-long -class LuisHelper: - """LUIS helper implementation.""" - - @staticmethod - async def execute_luis_query( - configuration, - turn_context: TurnContext, - telemetry_client: BotTelemetryClient = None, - ) -> BookingDetails: - """Invoke LUIS service to perform prediction/evaluation of utterance.""" - booking_details = BookingDetails() - - # pylint:disable=broad-except - try: - luis_application = LuisApplication( - configuration.get("LUIS_APP_ID"), - configuration.get("LUIS_API_KEY"), - configuration.get("LUIS_API_HOST_NAME"), - ) - options = LuisPredictionOptions() - options.telemetry_client = ( - telemetry_client - if telemetry_client is not None - else NullTelemetryClient() - ) - recognizer = LuisRecognizer(luis_application, prediction_options=options) - recognizer_result = await recognizer.recognize(turn_context) - print(f"Recognize Result: {recognizer_result}") - - if recognizer_result.intents: - intent = sorted( - recognizer_result.intents, - key=recognizer_result.intents.get, - reverse=True, - )[:1][0] - if intent == "Book_flight": - # We need to get the result from the LUIS JSON which at every level returns an array. - to_entities = recognizer_result.entities.get("$instance", {}).get( - "To", [] - ) - if to_entities: - booking_details.destination = to_entities[0]["text"] - from_entities = recognizer_result.entities.get("$instance", {}).get( - "From", [] - ) - if from_entities: - booking_details.origin = from_entities[0]["text"] - - # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. - # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. - date_entities = recognizer_result.entities.get("$instance", {}).get( - "datetime", [] - ) - if date_entities: - booking_details.travel_date = ( - None # Set when we get a timex format - ) - except Exception as exception: - print(exception) - - return booking_details diff --git a/samples/21.corebot-app-insights/requirements.txt b/samples/21.corebot-app-insights/requirements.txt deleted file mode 100644 index ffcf72c6b..000000000 --- a/samples/21.corebot-app-insights/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -Flask>=1.0.2 -asyncio>=3.4.3 -requests>=2.18.1 -botframework-connector>=4.4.0.b1 -botbuilder-schema>=4.4.0.b1 -botbuilder-core>=4.4.0.b1 -botbuilder-dialogs>=4.4.0.b1 -botbuilder-ai>=4.4.0.b1 -botbuilder-applicationinsights>=4.4.0.b1 -datatypes-date-time>=1.0.0.a1 -azure-cognitiveservices-language-luis>=0.2.0 -msrest>=0.6.6 - diff --git a/samples/23.facebook-events/README.md b/samples/23.facebook-events/README.md deleted file mode 100644 index 01a0f2619..000000000 --- a/samples/23.facebook-events/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Facebook events - -Bot Framework v4 facebook events bot sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), is shows how to integrate and consume Facebook specific payloads, such as postbacks, quick replies and optin events. Since Bot Framework supports multiple Facebook pages for a single bot, we also show how to know the page to which the message was sent, so developers can have custom behavior per page. - -More information about configuring a bot for Facebook Messenger can be found here: [Connect a bot to Facebook](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-channel-connect-facebook) - -## 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\23.facebook-evbents` 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 - -## 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) -- [Facebook Quick Replies](https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies/0) -- [Facebook PostBack](https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messaging_postbacks/) -- [Facebook Opt-in](https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messaging_optins/) -- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/23.facebook-events/app.py b/samples/23.facebook-events/app.py deleted file mode 100644 index efd359d67..000000000 --- a/samples/23.facebook-events/app.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -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 FacebookBot - -# 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 = FacebookBot() - -# 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 diff --git a/samples/23.facebook-events/bots/__init__.py b/samples/23.facebook-events/bots/__init__.py deleted file mode 100644 index 7db4bb27c..000000000 --- a/samples/23.facebook-events/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .facebook_bot import FacebookBot - -__all__ = ["FacebookBot"] diff --git a/samples/23.facebook-events/bots/facebook_bot.py b/samples/23.facebook-events/bots/facebook_bot.py deleted file mode 100644 index 7ee4ee609..000000000 --- a/samples/23.facebook-events/bots/facebook_bot.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs.choices import Choice, ChoiceFactory -from botbuilder.core import ActivityHandler, MessageFactory, TurnContext, CardFactory -from botbuilder.schema import ChannelAccount, CardAction, ActionTypes, HeroCard - -FACEBOOK_PAGEID_OPTION = "Facebook Id" -QUICK_REPLIES_OPTION = "Quick Replies" -POSTBACK_OPTION = "PostBack" - - -class FacebookBot(ActivityHandler): - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity("Hello and welcome!") - - async def on_message_activity(self, turn_context: TurnContext): - if not await self._process_facebook_payload( - turn_context, turn_context.activity.channel_data - ): - await self._show_choices(turn_context) - - async def on_event_activity(self, turn_context: TurnContext): - await self._process_facebook_payload(turn_context, turn_context.activity.value) - - async def _show_choices(self, turn_context: TurnContext): - choices = [ - Choice( - value=QUICK_REPLIES_OPTION, - action=CardAction( - title=QUICK_REPLIES_OPTION, - type=ActionTypes.post_back, - value=QUICK_REPLIES_OPTION, - ), - ), - Choice( - value=FACEBOOK_PAGEID_OPTION, - action=CardAction( - title=FACEBOOK_PAGEID_OPTION, - type=ActionTypes.post_back, - value=FACEBOOK_PAGEID_OPTION, - ), - ), - Choice( - value=POSTBACK_OPTION, - action=CardAction( - title=POSTBACK_OPTION, - type=ActionTypes.post_back, - value=POSTBACK_OPTION, - ), - ), - ] - - message = ChoiceFactory.for_channel( - turn_context.activity.channel_id, - choices, - "What Facebook feature would you like to try? Here are some quick replies to choose from!", - ) - await turn_context.send_activity(message) - - async def _process_facebook_payload(self, turn_context: TurnContext, data) -> bool: - if "postback" in data: - await self._on_facebook_postback(turn_context, data["postback"]) - return True - - if "optin" in data: - await self._on_facebook_optin(turn_context, data["optin"]) - return True - - if "message" in data and "quick_reply" in data["message"]: - await self._on_facebook_quick_reply( - turn_context, data["message"]["quick_reply"] - ) - return True - - if "message" in data and data["message"]["is_echo"]: - await self._on_facebook_echo(turn_context, data["message"]) - return True - - async def _on_facebook_postback( - self, turn_context: TurnContext, facebook_postback: dict - ): - # TODO: Your PostBack handling logic here... - - reply = MessageFactory.text(f"Postback: {facebook_postback}") - await turn_context.send_activity(reply) - await self._show_choices(turn_context) - - async def _on_facebook_quick_reply( - self, turn_context: TurnContext, facebook_quick_reply: dict - ): - # TODO: Your quick reply event handling logic here... - - if turn_context.activity.text == FACEBOOK_PAGEID_OPTION: - reply = MessageFactory.text( - f"This message comes from the following Facebook Page: {turn_context.activity.recipient.id}" - ) - await turn_context.send_activity(reply) - await self._show_choices(turn_context) - elif turn_context.activity.text == POSTBACK_OPTION: - card = HeroCard( - text="Is 42 the answer to the ultimate question of Life, the Universe, and Everything?", - buttons=[ - CardAction(title="Yes", type=ActionTypes.post_back, value="Yes"), - CardAction(title="No", type=ActionTypes.post_back, value="No"), - ], - ) - reply = MessageFactory.attachment(CardFactory.hero_card(card)) - await turn_context.send_activity(reply) - else: - print(facebook_quick_reply) - await turn_context.send_activity("Quick Reply") - await self._show_choices(turn_context) - - async def _on_facebook_optin(self, turn_context: TurnContext, facebook_optin: dict): - # TODO: Your optin event handling logic here... - print(facebook_optin) - await turn_context.send_activity("Opt In") - - async def _on_facebook_echo( - self, turn_context: TurnContext, facebook_message: dict - ): - # TODO: Your echo event handling logic here... - print(facebook_message) - await turn_context.send_activity("Echo") diff --git a/samples/23.facebook-events/config.py b/samples/23.facebook-events/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/23.facebook-events/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json b/samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/23.facebook-events/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/23.facebook-events/requirements.txt b/samples/23.facebook-events/requirements.txt deleted file mode 100644 index a69322ec3..000000000 --- a/samples/23.facebook-events/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -jsonpickle==1.2 -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/40.timex-resolution/README.md b/samples/40.timex-resolution/README.md deleted file mode 100644 index 2d6b6b0a8..000000000 --- a/samples/40.timex-resolution/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Timex Resolution - -This sample shows how to use TIMEX expressions. - -## Concepts introduced in this sample - -### What is a TIMEX expression? - -A TIMEX expression is an alpha-numeric expression derived in outline from the standard date-time representation ISO 8601. -The interesting thing about TIMEX expressions is that they can represent various degrees of ambiguity in the date parts. For example, May 29th, is not a -full calendar date because we haven't said which May 29th - it could be this year, last year, any year in fact. -TIMEX has other features such as the ability to represent ranges, date ranges, time ranges and even date-time ranges. - -### Where do TIMEX expressions come from? - -TIMEX expressions are produced as part of the output of running a DateTimeRecognizer against some natural language input. As the same -Recognizers are run in LUIS the result returned in the JSON from a call to LUIS also contains the TIMEX expressions. - -### What can the library do? - -It turns out that TIMEX expressions are not that simple to work with in code. This library attempts to address that. One helpful way to -think about a TIMEX expression is as a partially filled property bag. The properties might be such things as "day of week" or "year." -Basically the more properties we have captured in the expression the less ambiguity we have. - -The library can do various things: - -- Parse TIMEX expressions to give you the properties contained there in. -- Generate TIMEX expressions based on setting raw properties. -- Generate natural language from the TIMEX expression. (This is logically the reverse of the Recognizer.) -- Resolve TIMEX expressions to produce example date-times. (This produces the same result as the Recognizer (and therefore LUIS)). -- Evaluate TIMEX expressions against constraints such that new more precise TIMEX expressions are produced. - -### Where is the source code? - -The TIMEX expression library is contained in the same GitHub repo as the recognizers. Refer to the further reading section below. - -## 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\40.timex-resolution` folder -- In the terminal, type `pip install -r requirements.txt` -- In the terminal, type `python main.py` - -## Further reading - -- [TIMEX](https://en.wikipedia.org/wiki/TimeML#TIMEX3) -- [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) -- [Recognizers Text](https://github.com/Microsoft/recognizers-text) diff --git a/samples/40.timex-resolution/ambiguity.py b/samples/40.timex-resolution/ambiguity.py deleted file mode 100644 index a412b2f55..000000000 --- a/samples/40.timex-resolution/ambiguity.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from recognizers_date_time import recognize_datetime, Culture - - -class Ambiguity: - """ - TIMEX expressions are designed to represent ambiguous rather than definite dates. For - example: "Monday" could be any Monday ever. "May 5th" could be any one of the possible May - 5th in the past or the future. TIMEX does not represent ambiguous times. So if the natural - language mentioned 4 o'clock it could be either 4AM or 4PM. For that the recognizer (and by - extension LUIS) would return two TIMEX expressions. A TIMEX expression can include a date and - time parts. So ambiguity of date can be combined with multiple results. Code that deals with - TIMEX expressions is frequently dealing with sets of TIMEX expressions. - """ - - @staticmethod - def date_ambiguity(): - # Run the recognizer. - results = recognize_datetime( - "Either Saturday or Sunday would work.", Culture.English - ) - - # We should find two results in this example. - for result in results: - # The resolution includes two example values: going backwards and forwards from NOW in the calendar. - # Each result includes a TIMEX expression that captures the inherent date but not time ambiguity. - # We are interested in the distinct set of TIMEX expressions. - # There is also either a "value" property on each value or "start" and "end". - distinct_timex_expressions = { - value["timex"] - for value in result.resolution["values"] - if "timex" in value - } - print(f"{result.text} ({','.join(distinct_timex_expressions)})") - - @staticmethod - def time_ambiguity(): - # Run the recognizer. - results = recognize_datetime( - "We would like to arrive at 4 o'clock or 5 o'clock.", Culture.English - ) - - # We should find two results in this example. - for result in results: - # The resolution includes two example values: one for AM and one for PM. - # Each result includes a TIMEX expression that captures the inherent date but not time ambiguity. - # We are interested in the distinct set of TIMEX expressions. - distinct_timex_expressions = { - value["timex"] - for value in result.resolution["values"] - if "timex" in value - } - - # TIMEX expressions don't capture time ambiguity so there will be two distinct expressions for each result. - print(f"{result.text} ({','.join(distinct_timex_expressions)})") - - @staticmethod - def date_time_ambiguity(): - # Run the recognizer. - results = recognize_datetime( - "It will be ready Wednesday at 5 o'clock.", Culture.English - ) - - # We should find a single result in this example. - for result in results: - # The resolution includes four example values: backwards and forward in the calendar and then AM and PM. - # Each result includes a TIMEX expression that captures the inherent date but not time ambiguity. - # We are interested in the distinct set of TIMEX expressions. - distinct_timex_expressions = { - value["timex"] - for value in result.resolution["values"] - if "timex" in value - } - - # TIMEX expressions don't capture time ambiguity so there will be two distinct expressions for each result. - print(f"{result.text} ({','.join(distinct_timex_expressions)})") diff --git a/samples/40.timex-resolution/constraints.py b/samples/40.timex-resolution/constraints.py deleted file mode 100644 index 21e8d2190..000000000 --- a/samples/40.timex-resolution/constraints.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import datetime - -from datatypes_timex_expression import TimexRangeResolver, TimexCreator - - -class Constraints: - """ - The TimexRangeResolved can be used in application logic to apply constraints to a set of TIMEX expressions. - The constraints themselves are TIMEX expressions. This is designed to appear a little like a database join, - of course its a little less generic than that because dates can be complicated things. - """ - - @staticmethod - def examples(): - """ - When you give the recognizer the text "Wednesday 4 o'clock" you get these distinct TIMEX values back. - But our bot logic knows that whatever the user says it should be evaluated against the constraints of - a week from today with respect to the date part and in the evening with respect to the time part. - """ - - resolutions = TimexRangeResolver.evaluate( - ["XXXX-WXX-3T04", "XXXX-WXX-3T16"], - [TimexCreator.week_from_today(), TimexCreator.EVENING], - ) - - today = datetime.datetime.now() - for resolution in resolutions: - print(resolution.to_natural_language(today)) diff --git a/samples/40.timex-resolution/language_generation.py b/samples/40.timex-resolution/language_generation.py deleted file mode 100644 index c8b156521..000000000 --- a/samples/40.timex-resolution/language_generation.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import datetime - -from datatypes_timex_expression import Timex - - -class LanguageGeneration: - """ - This language generation capabilities are the logical opposite of what the recognizer does. - As an experiment try feeding the result of language generation back into a recognizer. - You should get back the same TIMEX expression in the result. - """ - - @staticmethod - def examples(): - LanguageGeneration.__describe(Timex("2019-05-29")) - LanguageGeneration.__describe(Timex("XXXX-WXX-6")) - LanguageGeneration.__describe(Timex("XXXX-WXX-6T16")) - LanguageGeneration.__describe(Timex("T12")) - - LanguageGeneration.__describe(Timex.from_date(datetime.datetime.now())) - LanguageGeneration.__describe( - Timex.from_date(datetime.datetime.now() + datetime.timedelta(days=1)) - ) - - @staticmethod - def __describe(timex: Timex): - # Note natural language is often relative, for example the sentence "Yesterday all my troubles seemed so far - # away." Having your bot say something like "next Wednesday" in a response can make it sound more natural. - reference_date = datetime.datetime.now() - print(f"{timex.timex_value()} : {timex.to_natural_language(reference_date)}") diff --git a/samples/40.timex-resolution/main.py b/samples/40.timex-resolution/main.py deleted file mode 100644 index 1079efd7a..000000000 --- a/samples/40.timex-resolution/main.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from ambiguity import Ambiguity -from constraints import Constraints -from language_generation import LanguageGeneration -from parsing import Parsing -from ranges import Ranges -from resolution import Resolution - -if __name__ == "__main__": - # Creating TIMEX expressions from natural language using the Recognizer package. - Ambiguity.date_ambiguity() - Ambiguity.time_ambiguity() - Ambiguity.date_time_ambiguity() - Ranges.date_range() - Ranges.time_range() - - # Manipulating TIMEX expressions in code using the TIMEX Datatype package. - Parsing.examples() - LanguageGeneration.examples() - Resolution.examples() - Constraints.examples() diff --git a/samples/40.timex-resolution/parsing.py b/samples/40.timex-resolution/parsing.py deleted file mode 100644 index 194dc97cc..000000000 --- a/samples/40.timex-resolution/parsing.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datatypes_timex_expression import Timex, Constants - - -class Parsing: - """ - The Timex class takes a TIMEX expression as a string argument in its constructor. - This pulls all the component parts of the expression into properties on this object. You can - then manipulate the TIMEX expression via those properties. - The "types" property infers a datetimeV2 type from the underlying set of properties. - If you take a TIMEX with date components and add time components you add the - inferred type datetime (its still a date). - Logic can be written against the inferred type, perhaps to have the bot ask the user for - disambiguation. - """ - - @staticmethod - def __describe(timex_pattern: str): - timex = Timex(timex_pattern) - - print(timex.timex_value(), end=" ") - - if Constants.TIMEX_TYPES_DATE in timex.types: - if Constants.TIMEX_TYPES_DEFINITE in timex.types: - print("We have a definite calendar date.", end=" ") - else: - print("We have a date but there is some ambiguity.", end=" ") - - if Constants.TIMEX_TYPES_TIME in timex.types: - print("We have a time.") - else: - print("") - - @staticmethod - def examples(): - """ - Print information an various TimeX expressions. - :return: None - """ - Parsing.__describe("2017-05-29") - Parsing.__describe("XXXX-WXX-6") - Parsing.__describe("XXXX-WXX-6T16") - Parsing.__describe("T12") diff --git a/samples/40.timex-resolution/ranges.py b/samples/40.timex-resolution/ranges.py deleted file mode 100644 index 1bae92ce0..000000000 --- a/samples/40.timex-resolution/ranges.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from recognizers_date_time import recognize_datetime -from recognizers_text import Culture - - -class Ranges: - """ - TIMEX expressions can represent date and time ranges. Here are a couple of examples. - """ - - @staticmethod - def date_range(): - # Run the recognizer. - results = recognize_datetime( - "Some time in the next two weeks.", Culture.English - ) - - # We should find a single result in this example. - for result in results: - # The resolution includes a single value because there is no ambiguity. - # We are interested in the distinct set of TIMEX expressions. - distinct_timex_expressions = { - value["timex"] - for value in result.resolution["values"] - if "timex" in value - } - - # The TIMEX expression can also capture the notion of range. - print(f"{result.text} ({','.join(distinct_timex_expressions)})") - - @staticmethod - def time_range(): - # Run the recognizer. - results = recognize_datetime( - "Some time between 6pm and 6:30pm.", Culture.English - ) - - # We should find a single result in this example. - for result in results: - # The resolution includes a single value because there is no ambiguity. - # We are interested in the distinct set of TIMEX expressions. - distinct_timex_expressions = { - value["timex"] - for value in result.resolution["values"] - if "timex" in value - } - - # The TIMEX expression can also capture the notion of range. - print(f"{result.text} ({','.join(distinct_timex_expressions)})") diff --git a/samples/40.timex-resolution/requirements.txt b/samples/40.timex-resolution/requirements.txt deleted file mode 100644 index 26579538e..000000000 --- a/samples/40.timex-resolution/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -recognizers-text>=1.0.2a2 -datatypes-timex-expression>=1.0.2a2 - diff --git a/samples/40.timex-resolution/resolution.py b/samples/40.timex-resolution/resolution.py deleted file mode 100644 index 4e42f5e88..000000000 --- a/samples/40.timex-resolution/resolution.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import datetime - -from datatypes_timex_expression import TimexResolver - - -class Resolution: - """ - Given the TIMEX expressions it is easy to create the computed example values that the recognizer gives. - """ - - @staticmethod - def examples(): - # When you give the recognizer the text "Wednesday 4 o'clock" you get these distinct TIMEX values back. - - today = datetime.datetime.now() - resolution = TimexResolver.resolve(["XXXX-WXX-3T04", "XXXX-WXX-3T16"], today) - - print(f"Resolution Values: {len(resolution.values)}") - - for value in resolution.values: - print(value.timex) - print(value.type) - print(value.value) diff --git a/samples/42.scaleout/README.md b/samples/42.scaleout/README.md deleted file mode 100644 index e9b8d103c..000000000 --- a/samples/42.scaleout/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Scale Out - -Bot Framework v4 bot Scale Out sample - -This bot has been created using [Bot Framework](https://dev.botframework.com), is shows how to use a custom storage solution that supports a deployment scaled out across multiple machines. - -The custom storage solution is implemented against memory for testing purposes and against Azure Blob Storage. The sample shows how storage solutions with different policies can be implemented and integrated with the framework. The solution makes use of the standard HTTP ETag/If-Match mechanisms commonly found on cloud storage technologies. - -## 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\42.scaleout` 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 - -## 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) -- [Implementing custom storage for you bot](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-custom-storage?view=azure-bot-service-4.0) -- [Bot Storage](https://docs.microsoft.com/en-us/azure/bot-service/dotnet/bot-builder-dotnet-state?view=azure-bot-service-3.0&viewFallbackFrom=azure-bot-service-4.0) -- [HTTP ETag](https://en.wikipedia.org/wiki/HTTP_ETag) -- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/42.scaleout/app.py b/samples/42.scaleout/app.py deleted file mode 100644 index ac780beed..000000000 --- a/samples/42.scaleout/app.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -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 ScaleoutBot - -# Create the loop and Flask app -from dialogs import RootDialog -from store import MemoryStore - -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 -STORAGE = MemoryStore() -# Use BlobStore to test with Azure Blob storage. -# STORAGE = BlobStore(app.config["BLOB_ACCOUNT_NAME"], app.config["BLOB_KEY"], app.config["BLOB_CONTAINER"]) -DIALOG = RootDialog() -BOT = ScaleoutBot(STORAGE, DIALOG) - -# 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 diff --git a/samples/42.scaleout/bots/__init__.py b/samples/42.scaleout/bots/__init__.py deleted file mode 100644 index b1886b216..000000000 --- a/samples/42.scaleout/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .scaleout_bot import ScaleoutBot - -__all__ = ["ScaleoutBot"] diff --git a/samples/42.scaleout/bots/scaleout_bot.py b/samples/42.scaleout/bots/scaleout_bot.py deleted file mode 100644 index 83489cd47..000000000 --- a/samples/42.scaleout/bots/scaleout_bot.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, TurnContext -from botbuilder.dialogs import Dialog - -from host import DialogHost -from store import Store - - -class ScaleoutBot(ActivityHandler): - """ - This bot runs Dialogs that send message Activities in a way that can be scaled out with a multi-machine deployment. - The bot logic makes use of the standard HTTP ETag/If-Match mechanism for optimistic locking. This mechanism - is commonly supported on cloud storage technologies from multiple vendors including teh Azure Blob Storage - service. A full implementation against Azure Blob Storage is included in this sample. - """ - - def __init__(self, store: Store, dialog: Dialog): - self.store = store - self.dialog = dialog - - async def on_message_activity(self, turn_context: TurnContext): - # Create the storage key for this conversation. - key = f"{turn_context.activity.channel_id}/conversations/{turn_context.activity.conversation.id}" - - # The execution sits in a loop because there might be a retry if the save operation fails. - while True: - # Load any existing state associated with this key - old_state, e_tag = await self.store.load(key) - - # Run the dialog system with the old state and inbound activity, the result is a new state and outbound - # activities. - activities, new_state = await DialogHost.run( - self.dialog, turn_context.activity, old_state - ) - - # Save the updated state associated with this key. - success = await self.store.save(key, new_state, e_tag) - if success: - if activities: - # This is an actual send on the TurnContext we were given and so will actual do a send this time. - await turn_context.send_activities(activities) - - break diff --git a/samples/42.scaleout/config.py b/samples/42.scaleout/config.py deleted file mode 100644 index 5737815c9..000000000 --- a/samples/42.scaleout/config.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/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", "") - BLOB_ACCOUNT_NAME = "tboehrestorage" - BLOB_KEY = "A7tc3c9T/n67iDYO7Lx19sTjnA+DD3bR/HQ4yPhJuyVXO1yJ8mYzDOXsBhJrjldh7zKMjE9Wc6PrM1It4nlGPw==" - BLOB_CONTAINER = "dialogs" diff --git a/samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json b/samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json deleted file mode 100644 index bff8c096d..000000000 --- a/samples/42.scaleout/deploymentTemplates/template-with-preexisting-rg.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "appId": { - "type": "string", - "metadata": { - "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." - } - }, - "appSecret": { - "type": "string", - "metadata": { - "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." - } - }, - "botId": { - "type": "string", - "metadata": { - "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." - } - }, - "botSku": { - "defaultValue": "F0", - "type": "string", - "metadata": { - "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." - } - }, - "newAppServicePlanName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The name of the new App Service Plan." - } - }, - "newAppServicePlanSku": { - "type": "object", - "defaultValue": { - "name": "S1", - "tier": "Standard", - "size": "S1", - "family": "S", - "capacity": 1 - }, - "metadata": { - "description": "The SKU of the App Service Plan. Defaults to Standard values." - } - }, - "appServicePlanLocation": { - "type": "string", - "metadata": { - "description": "The location of the App Service Plan." - } - }, - "existingAppServicePlan": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Name of the existing App Service Plan used to create the Web App for the bot." - } - }, - "newWebAppName": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." - } - } - }, - "variables": { - "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", - "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", - "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", - "publishingUsername": "[concat('$', parameters('newWebAppName'))]", - "resourcesLocation": "[parameters('appServicePlanLocation')]", - "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", - "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", - "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" - }, - "resources": [ - { - "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2016-09-01", - "name": "[variables('servicePlanName')]", - "location": "[variables('resourcesLocation')]", - "sku": "[parameters('newAppServicePlanSku')]", - "kind": "linux", - "properties": { - "name": "[variables('servicePlanName')]", - "perSiteScaling": false, - "reserved": true, - "targetWorkerCount": 0, - "targetWorkerSizeId": 0 - } - }, - { - "comments": "Create a Web App using a Linux App Service Plan", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('webAppName')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "kind": "app,linux", - "properties": { - "enabled": true, - "hostNameSslStates": [ - { - "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Standard" - }, - { - "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", - "sslState": "Disabled", - "hostType": "Repository" - } - ], - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", - "reserved": true, - "scmSiteAlsoStopped": false, - "clientAffinityEnabled": false, - "clientCertEnabled": false, - "hostNamesDisabled": false, - "containerSize": 0, - "dailyMemoryTimeQuota": 0, - "httpsOnly": false, - "siteConfig": { - "appSettings": [ - { - "name": "MicrosoftAppId", - "value": "[parameters('appId')]" - }, - { - "name": "MicrosoftAppPassword", - "value": "[parameters('appSecret')]" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - } - ], - "cors": { - "allowedOrigins": [ - "https://botservice.hosting.portal.azure.net", - "https://hosting.onecloud.azure-test.net/" - ] - } - } - } - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2016-08-01", - "name": "[concat(variables('webAppName'), '/web')]", - "location": "[variables('resourcesLocation')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" - ], - "properties": { - "numberOfWorkers": 1, - "defaultDocuments": [ - "Default.htm", - "Default.html", - "Default.asp", - "index.htm", - "index.html", - "iisstart.htm", - "default.aspx", - "index.php", - "hostingstart.html" - ], - "netFrameworkVersion": "v4.0", - "phpVersion": "", - "pythonVersion": "", - "nodeVersion": "", - "linuxFxVersion": "PYTHON|3.7", - "requestTracingEnabled": false, - "remoteDebuggingEnabled": false, - "remoteDebuggingVersion": "VS2017", - "httpLoggingEnabled": true, - "logsDirectorySizeLimit": 35, - "detailedErrorLoggingEnabled": false, - "publishingUsername": "[variables('publishingUsername')]", - "scmType": "None", - "use32BitWorkerProcess": true, - "webSocketsEnabled": false, - "alwaysOn": false, - "appCommandLine": "", - "managedPipelineMode": "Integrated", - "virtualApplications": [ - { - "virtualPath": "/", - "physicalPath": "site\\wwwroot", - "preloadEnabled": false, - "virtualDirectories": null - } - ], - "winAuthAdminState": 0, - "winAuthTenantState": 0, - "customAppPoolIdentityAdminState": false, - "customAppPoolIdentityTenantState": false, - "loadBalancing": "LeastRequests", - "routingRules": [], - "experiments": { - "rampUpRules": [] - }, - "autoHealEnabled": false, - "vnetName": "", - "minTlsVersion": "1.2", - "ftpsState": "AllAllowed", - "reservedInstanceCount": 0 - } - }, - { - "apiVersion": "2017-12-01", - "type": "Microsoft.BotService/botServices", - "name": "[parameters('botId')]", - "location": "global", - "kind": "bot", - "sku": { - "name": "[parameters('botSku')]" - }, - "properties": { - "name": "[parameters('botId')]", - "displayName": "[parameters('botId')]", - "endpoint": "[variables('botEndpoint')]", - "msaAppId": "[parameters('appId')]", - "developerAppInsightsApplicationId": null, - "developerAppInsightKey": null, - "publishingCredentials": null, - "storageResourceId": null - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" - ] - } - ] -} \ No newline at end of file diff --git a/samples/42.scaleout/dialogs/__init__.py b/samples/42.scaleout/dialogs/__init__.py deleted file mode 100644 index d97c50169..000000000 --- a/samples/42.scaleout/dialogs/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .root_dialog import RootDialog - -__all__ = ["RootDialog"] diff --git a/samples/42.scaleout/dialogs/root_dialog.py b/samples/42.scaleout/dialogs/root_dialog.py deleted file mode 100644 index e849ba02b..000000000 --- a/samples/42.scaleout/dialogs/root_dialog.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, - NumberPrompt, - PromptOptions, -) - - -class RootDialog(ComponentDialog): - def __init__(self): - super(RootDialog, self).__init__(RootDialog.__name__) - - self.add_dialog(self.__create_waterfall()) - self.add_dialog(NumberPrompt("number")) - - self.initial_dialog_id = "waterfall" - - def __create_waterfall(self) -> WaterfallDialog: - return WaterfallDialog("waterfall", [self.__step1, self.__step2, self.__step3]) - - async def __step1(self, step_context: WaterfallStepContext) -> DialogTurnResult: - return await step_context.prompt( - "number", PromptOptions(prompt=MessageFactory.text("Enter a number.")) - ) - - async def __step2(self, step_context: WaterfallStepContext) -> DialogTurnResult: - first: int = step_context.result - step_context.values["first"] = first - - return await step_context.prompt( - "number", - PromptOptions( - prompt=MessageFactory.text(f"I have {first}, now enter another number") - ), - ) - - async def __step3(self, step_context: WaterfallStepContext) -> DialogTurnResult: - first: int = step_context.values["first"] - second: int = step_context.result - - await step_context.prompt( - "number", - PromptOptions( - prompt=MessageFactory.text( - f"The result of the first minus the second is {first - second}." - ) - ), - ) - - return await step_context.end_dialog() diff --git a/samples/42.scaleout/helpers/__init__.py b/samples/42.scaleout/helpers/__init__.py deleted file mode 100644 index a824eb8f4..000000000 --- a/samples/42.scaleout/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import dialog_helper - -__all__ = ["dialog_helper"] diff --git a/samples/42.scaleout/helpers/dialog_helper.py b/samples/42.scaleout/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/42.scaleout/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/42.scaleout/host/__init__.py b/samples/42.scaleout/host/__init__.py deleted file mode 100644 index 3ce168e54..000000000 --- a/samples/42.scaleout/host/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_host import DialogHost -from .dialog_host_adapter import DialogHostAdapter - -__all__ = ["DialogHost", "DialogHostAdapter"] diff --git a/samples/42.scaleout/host/dialog_host.py b/samples/42.scaleout/host/dialog_host.py deleted file mode 100644 index b7cfe1692..000000000 --- a/samples/42.scaleout/host/dialog_host.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json - -from jsonpickle import encode -from jsonpickle.unpickler import Unpickler - -from botbuilder.core import TurnContext -from botbuilder.dialogs import Dialog, ComponentDialog -from botbuilder.schema import Activity - -from helpers.dialog_helper import DialogHelper -from host.dialog_host_adapter import DialogHostAdapter -from store import RefAccessor - - -class DialogHost: - """ - The essential code for running a dialog. The execution of the dialog is treated here as a pure function call. - The input being the existing (or old) state and the inbound Activity and the result being the updated (or new) - state and the Activities that should be sent. The assumption is that this code can be re-run without causing any - unintended or harmful side-effects, for example, any outbound service calls made directly from the - dialog implementation should be idempotent. - """ - - @staticmethod - async def run(dialog: Dialog, activity: Activity, old_state) -> (): - """ - A function to run a dialog while buffering the outbound Activities. - """ - - # A custom adapter and corresponding TurnContext that buffers any messages sent. - adapter = DialogHostAdapter() - turn_context = TurnContext(adapter, activity) - - # Run the dialog using this TurnContext with the existing state. - new_state = await DialogHost.__run_turn(dialog, turn_context, old_state) - - # The result is a set of activities to send and a replacement state. - return adapter.activities, new_state - - @staticmethod - async def __run_turn(dialog: Dialog, turn_context: TurnContext, state): - """ - Execute the turn of the bot. The functionality here closely resembles that which is found in the - Bot.on_turn method in an implementation that is using the regular BotFrameworkAdapter. - Also here in this example the focus is explicitly on Dialogs but the pattern could be adapted - to other conversation modeling abstractions. - """ - # If we have some state, deserialize it. (This mimics the shape produced by BotState.cs.) - dialog_state_property = ( - state[ComponentDialog.persisted_dialog_state] if state else None - ) - dialog_state = ( - None - if not dialog_state_property - else Unpickler().restore(json.loads(dialog_state_property)) - ) - - # A custom accessor is used to pass a handle on the state to the dialog system. - accessor = RefAccessor(dialog_state) - - # Run the dialog. - await DialogHelper.run_dialog(dialog, turn_context, accessor) - - # Serialize the result (available as Value on the accessor), and put its value back into a new json object. - return { - ComponentDialog.persisted_dialog_state: None - if not accessor.value - else encode(accessor.value) - } diff --git a/samples/42.scaleout/host/dialog_host_adapter.py b/samples/42.scaleout/host/dialog_host_adapter.py deleted file mode 100644 index ab7151c0f..000000000 --- a/samples/42.scaleout/host/dialog_host_adapter.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -from botbuilder.core import BotAdapter, TurnContext -from botbuilder.schema import Activity, ConversationReference - - -class DialogHostAdapter(BotAdapter): - """ - This custom BotAdapter supports scenarios that only Send Activities. Update and Delete Activity - are not supported. - Rather than sending the outbound Activities directly as the BotFrameworkAdapter does this class - buffers them in a list. The list is exposed as a public property. - """ - - def __init__(self): - super(DialogHostAdapter, self).__init__() - self.activities = [] - - async def send_activities(self, context: TurnContext, activities: List[Activity]): - self.activities.extend(activities) - return [] - - async def update_activity(self, context: TurnContext, activity: Activity): - raise NotImplementedError - - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - raise NotImplementedError diff --git a/samples/42.scaleout/requirements.txt b/samples/42.scaleout/requirements.txt deleted file mode 100644 index 4760c7682..000000000 --- a/samples/42.scaleout/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -jsonpickle -botbuilder-core>=4.4.0b1 -azure>=4.0.0 -flask>=1.0.3 diff --git a/samples/42.scaleout/store/__init__.py b/samples/42.scaleout/store/__init__.py deleted file mode 100644 index 0aaa4235a..000000000 --- a/samples/42.scaleout/store/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .store import Store -from .memory_store import MemoryStore -from .blob_store import BlobStore -from .ref_accessor import RefAccessor - -__all__ = ["Store", "MemoryStore", "BlobStore", "RefAccessor"] diff --git a/samples/42.scaleout/store/blob_store.py b/samples/42.scaleout/store/blob_store.py deleted file mode 100644 index c17ebd2c6..000000000 --- a/samples/42.scaleout/store/blob_store.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json - -from azure.storage.blob import BlockBlobService, PublicAccess -from jsonpickle import encode -from jsonpickle.unpickler import Unpickler - -from store.store import Store - - -class BlobStore(Store): - """ - An implementation of the ETag aware Store interface against Azure Blob Storage. - """ - - def __init__(self, account_name: str, account_key: str, container_name: str): - self.container_name = container_name - self.client = BlockBlobService( - account_name=account_name, account_key=account_key - ) - - async def load(self, key: str) -> (): - self.client.create_container(self.container_name) - self.client.set_container_acl( - self.container_name, public_access=PublicAccess.Container - ) - - if not self.client.exists(container_name=self.container_name, blob_name=key): - return None, None - - blob = self.client.get_blob_to_text( - container_name=self.container_name, blob_name=key - ) - return Unpickler().restore(json.loads(blob.content)), blob.properties.etag - - async def save(self, key: str, content, e_tag: str): - self.client.create_container(self.container_name) - self.client.set_container_acl( - self.container_name, public_access=PublicAccess.Container - ) - - self.client.create_blob_from_text( - container_name=self.container_name, - blob_name=key, - text=encode(content), - if_match=e_tag, - ) - - return True diff --git a/samples/42.scaleout/store/memory_store.py b/samples/42.scaleout/store/memory_store.py deleted file mode 100644 index d72293422..000000000 --- a/samples/42.scaleout/store/memory_store.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import uuid -from typing import Tuple - -from store.store import Store - - -class MemoryStore(Store): - """ - Implementation of the IStore abstraction intended for testing. - """ - - def __init__(self): - # dict of Tuples - self.store = {} - - async def load(self, key: str) -> (): - return self.store[key] if key in self.store else (None, None) - - async def save(self, key: str, content, e_tag: str) -> bool: - if e_tag: - value: Tuple = self.store[key] - if value and value[1] != e_tag: - return False - - self.store[key] = (content, str(uuid.uuid4())) - return True diff --git a/samples/42.scaleout/store/ref_accessor.py b/samples/42.scaleout/store/ref_accessor.py deleted file mode 100644 index 45bb5d4a4..000000000 --- a/samples/42.scaleout/store/ref_accessor.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext - - -class RefAccessor(StatePropertyAccessor): - """ - This is an accessor for any object. By definition objects (as opposed to values) - are returned by reference in the GetAsync call on the accessor. As such the SetAsync - call is never used. The actual act of saving any state to an external store therefore - cannot be encapsulated in the Accessor implementation itself. And so to facilitate this - the state itself is available as a public property on this class. The reason its here is - because the caller of the constructor could pass in null for the state, in which case - the factory provided on the GetAsync call will be used. - """ - - def __init__(self, value): - self.value = value - self.name = type(value).__name__ - - async def get( - self, turn_context: TurnContext, default_value_or_factory=None - ) -> object: - if not self.value: - if not default_value_or_factory: - raise Exception("key not found") - - self.value = default_value_or_factory() - - return self.value - - async def delete(self, turn_context: TurnContext) -> None: - pass - - async def set(self, turn_context: TurnContext, value) -> None: - pass diff --git a/samples/42.scaleout/store/store.py b/samples/42.scaleout/store/store.py deleted file mode 100644 index 4d13e0889..000000000 --- a/samples/42.scaleout/store/store.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC, abstractmethod - - -class Store(ABC): - """ - An ETag aware store definition. - The interface is defined in terms of JObject to move serialization out of the storage layer - while still indicating it is JSON, a fact the store may choose to make use of. - """ - - @abstractmethod - async def load(self, key: str) -> (): - """ - Loads a value from the Store. - :param key: - :return: (object, etag) - """ - raise NotImplementedError - - @abstractmethod - async def save(self, key: str, content, e_tag: str) -> bool: - """ - Saves a values to the Store if the etag matches. - :param key: - :param content: - :param e_tag: - :return: True if the content was saved. - """ - raise NotImplementedError diff --git a/samples/43.complex-dialog/README.md b/samples/43.complex-dialog/README.md deleted file mode 100644 index 1605fcce5..000000000 --- a/samples/43.complex-dialog/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Complex dialog sample - -This sample creates a complex conversation with dialogs. - -## 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\43.complex-dialog` 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 - - -# 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/43.complex-dialog/app.py b/samples/43.complex-dialog/app.py deleted file mode 100644 index f18a309d1..000000000 --- a/samples/43.complex-dialog/app.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import DialogAndWelcomeBot - -# Create the loop and Flask app -from dialogs import MainDialog - -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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - - -# Set the error handler on the Adapter. -# In this case, we want an unbound function, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create Dialog and Bot -DIALOG = MainDialog(USER_STATE) -BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - -# 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 diff --git a/samples/43.complex-dialog/bots/__init__.py b/samples/43.complex-dialog/bots/__init__.py deleted file mode 100644 index 6925db302..000000000 --- a/samples/43.complex-dialog/bots/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .dialog_bot import DialogBot -from .dialog_and_welcome_bot import DialogAndWelcomeBot - -__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py b/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py deleted file mode 100644 index 68c3c9a30..000000000 --- a/samples/43.complex-dialog/bots/dialog_and_welcome_bot.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List -from botbuilder.core import ( - ConversationState, - MessageFactory, - UserState, - TurnContext, -) -from botbuilder.dialogs import Dialog -from botbuilder.schema import ChannelAccount - -from .dialog_bot import DialogBot - - -class DialogAndWelcomeBot(DialogBot): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - super(DialogAndWelcomeBot, self).__init__( - conversation_state, user_state, dialog - ) - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - # Greet anyone that was not the target (recipient) of this message. - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - MessageFactory.text( - f"Welcome to Complex Dialog Bot {member.name}. This bot provides a complex conversation, with " - f"multiple dialogs. Type anything to get started. " - ) - ) diff --git a/samples/43.complex-dialog/bots/dialog_bot.py b/samples/43.complex-dialog/bots/dialog_bot.py deleted file mode 100644 index eb560a1be..000000000 --- a/samples/43.complex-dialog/bots/dialog_bot.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occurred during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) diff --git a/samples/43.complex-dialog/config.py b/samples/43.complex-dialog/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/43.complex-dialog/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/43.complex-dialog/data_models/__init__.py b/samples/43.complex-dialog/data_models/__init__.py deleted file mode 100644 index 35a5934d4..000000000 --- a/samples/43.complex-dialog/data_models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .user_profile import UserProfile - -__all__ = ["UserProfile"] diff --git a/samples/43.complex-dialog/data_models/user_profile.py b/samples/43.complex-dialog/data_models/user_profile.py deleted file mode 100644 index 0267721d4..000000000 --- a/samples/43.complex-dialog/data_models/user_profile.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - - -class UserProfile: - def __init__( - self, name: str = None, age: int = 0, companies_to_review: List[str] = None - ): - self.name: str = name - self.age: int = age - self.companies_to_review: List[str] = companies_to_review diff --git a/samples/43.complex-dialog/dialogs/__init__.py b/samples/43.complex-dialog/dialogs/__init__.py deleted file mode 100644 index cde97fd80..000000000 --- a/samples/43.complex-dialog/dialogs/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .main_dialog import MainDialog -from .review_selection_dialog import ReviewSelectionDialog -from .top_level_dialog import TopLevelDialog - -__all__ = ["MainDialog", "ReviewSelectionDialog", "TopLevelDialog"] diff --git a/samples/43.complex-dialog/dialogs/main_dialog.py b/samples/43.complex-dialog/dialogs/main_dialog.py deleted file mode 100644 index 8b3fcd82d..000000000 --- a/samples/43.complex-dialog/dialogs/main_dialog.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.core import MessageFactory, UserState - -from data_models import UserProfile -from dialogs.top_level_dialog import TopLevelDialog - - -class MainDialog(ComponentDialog): - def __init__(self, user_state: UserState): - super(MainDialog, self).__init__(MainDialog.__name__) - - self.user_state = user_state - - self.add_dialog(TopLevelDialog(TopLevelDialog.__name__)) - self.add_dialog( - WaterfallDialog("WFDialog", [self.initial_step, self.final_step]) - ) - - self.initial_dialog_id = "WFDialog" - - async def initial_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - return await step_context.begin_dialog(TopLevelDialog.__name__) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - user_info: UserProfile = step_context.result - - companies = ( - "no companies" - if len(user_info.companies_to_review) == 0 - else " and ".join(user_info.companies_to_review) - ) - status = f"You are signed up to review {companies}." - - await step_context.context.send_activity(MessageFactory.text(status)) - - # store the UserProfile - accessor = self.user_state.create_property("UserProfile") - await accessor.set(step_context.context, user_info) - - return await step_context.end_dialog() diff --git a/samples/43.complex-dialog/dialogs/review_selection_dialog.py b/samples/43.complex-dialog/dialogs/review_selection_dialog.py deleted file mode 100644 index 2119068bb..000000000 --- a/samples/43.complex-dialog/dialogs/review_selection_dialog.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -from botbuilder.dialogs import ( - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, - ComponentDialog, -) -from botbuilder.dialogs.prompts import ChoicePrompt, PromptOptions -from botbuilder.dialogs.choices import Choice, FoundChoice -from botbuilder.core import MessageFactory - - -class ReviewSelectionDialog(ComponentDialog): - def __init__(self, dialog_id: str = None): - super(ReviewSelectionDialog, self).__init__( - dialog_id or ReviewSelectionDialog.__name__ - ) - - self.COMPANIES_SELECTED = "value-companiesSelected" - self.DONE_OPTION = "done" - - self.company_options = [ - "Adatum Corporation", - "Contoso Suites", - "Graphic Design Institute", - "Wide World Importers", - ] - - self.add_dialog(ChoicePrompt(ChoicePrompt.__name__)) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__, [self.selection_step, self.loop_step] - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ - - async def selection_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - # step_context.options will contains the value passed in begin_dialog or replace_dialog. - # if this value wasn't provided then start with an emtpy selection list. This list will - # eventually be returned to the parent via end_dialog. - selected: [ - str - ] = step_context.options if step_context.options is not None else [] - step_context.values[self.COMPANIES_SELECTED] = selected - - if len(selected) == 0: - message = ( - f"Please choose a company to review, or `{self.DONE_OPTION}` to finish." - ) - else: - message = ( - f"You have selected **{selected[0]}**. You can review an additional company, " - f"or choose `{self.DONE_OPTION}` to finish. " - ) - - # create a list of options to choose, with already selected items removed. - options = self.company_options.copy() - options.append(self.DONE_OPTION) - if len(selected) > 0: - options.remove(selected[0]) - - # prompt with the list of choices - prompt_options = PromptOptions( - prompt=MessageFactory.text(message), - retry_prompt=MessageFactory.text("Please choose an option from the list."), - choices=self._to_choices(options), - ) - return await step_context.prompt(ChoicePrompt.__name__, prompt_options) - - def _to_choices(self, choices: [str]) -> List[Choice]: - choice_list: List[Choice] = [] - for choice in choices: - choice_list.append(Choice(value=choice)) - return choice_list - - async def loop_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - selected: List[str] = step_context.values[self.COMPANIES_SELECTED] - choice: FoundChoice = step_context.result - done = choice.value == self.DONE_OPTION - - # If they chose a company, add it to the list. - if not done: - selected.append(choice.value) - - # If they're done, exit and return their list. - if done or len(selected) >= 2: - return await step_context.end_dialog(selected) - - # Otherwise, repeat this dialog, passing in the selections from this iteration. - return await step_context.replace_dialog( - ReviewSelectionDialog.__name__, selected - ) diff --git a/samples/43.complex-dialog/dialogs/top_level_dialog.py b/samples/43.complex-dialog/dialogs/top_level_dialog.py deleted file mode 100644 index 4342e668f..000000000 --- a/samples/43.complex-dialog/dialogs/top_level_dialog.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory -from botbuilder.dialogs import ( - WaterfallDialog, - DialogTurnResult, - WaterfallStepContext, - ComponentDialog, -) -from botbuilder.dialogs.prompts import PromptOptions, TextPrompt, NumberPrompt - -from data_models import UserProfile -from dialogs.review_selection_dialog import ReviewSelectionDialog - - -class TopLevelDialog(ComponentDialog): - def __init__(self, dialog_id: str = None): - super(TopLevelDialog, self).__init__(dialog_id or TopLevelDialog.__name__) - - # Key name to store this dialogs state info in the StepContext - self.USER_INFO = "value-userInfo" - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(NumberPrompt(NumberPrompt.__name__)) - - self.add_dialog(ReviewSelectionDialog(ReviewSelectionDialog.__name__)) - - self.add_dialog( - WaterfallDialog( - "WFDialog", - [ - self.name_step, - self.age_step, - self.start_selection_step, - self.acknowledgement_step, - ], - ) - ) - - self.initial_dialog_id = "WFDialog" - - async def name_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # Create an object in which to collect the user's information within the dialog. - step_context.values[self.USER_INFO] = UserProfile() - - # Ask the user to enter their name. - prompt_options = PromptOptions( - prompt=MessageFactory.text("Please enter your name.") - ) - return await step_context.prompt(TextPrompt.__name__, prompt_options) - - async def age_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # Set the user's name to what they entered in response to the name prompt. - user_profile = step_context.values[self.USER_INFO] - user_profile.name = step_context.result - - # Ask the user to enter their age. - prompt_options = PromptOptions( - prompt=MessageFactory.text("Please enter your age.") - ) - return await step_context.prompt(NumberPrompt.__name__, prompt_options) - - async def start_selection_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - # Set the user's age to what they entered in response to the age prompt. - user_profile: UserProfile = step_context.values[self.USER_INFO] - user_profile.age = step_context.result - - if user_profile.age < 25: - # If they are too young, skip the review selection dialog, and pass an empty list to the next step. - await step_context.context.send_activity( - MessageFactory.text("You must be 25 or older to participate.") - ) - - return await step_context.next([]) - - # Otherwise, start the review selection dialog. - return await step_context.begin_dialog(ReviewSelectionDialog.__name__) - - async def acknowledgement_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - # Set the user's company selection to what they entered in the review-selection dialog. - user_profile: UserProfile = step_context.values[self.USER_INFO] - user_profile.companies_to_review = step_context.result - - # Thank them for participating. - await step_context.context.send_activity( - MessageFactory.text(f"Thanks for participating, {user_profile.name}.") - ) - - # Exit the dialog, returning the collected user information. - return await step_context.end_dialog(user_profile) diff --git a/samples/43.complex-dialog/helpers/__init__.py b/samples/43.complex-dialog/helpers/__init__.py deleted file mode 100644 index a824eb8f4..000000000 --- a/samples/43.complex-dialog/helpers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import dialog_helper - -__all__ = ["dialog_helper"] diff --git a/samples/43.complex-dialog/helpers/dialog_helper.py b/samples/43.complex-dialog/helpers/dialog_helper.py deleted file mode 100644 index 6b2646b0b..000000000 --- a/samples/43.complex-dialog/helpers/dialog_helper.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/43.complex-dialog/requirements.txt b/samples/43.complex-dialog/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/43.complex-dialog/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/44.prompt-users-for-input/README.md b/samples/44.prompt-users-for-input/README.md deleted file mode 100644 index 527bb8a82..000000000 --- a/samples/44.prompt-users-for-input/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Prompt users for input - -This sample demonstrates how to create your own prompts with the Python Bot Framework. -The bot maintains conversation state to track and direct the conversation and ask the user questions. -The bot maintains user state to track the user's answers. - -## Running the sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` -- Bring up a terminal, navigate to `botbuilder-python\samples\44.prompt-user-for-input` 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 - - -## Bot State - -A key to good bot design is to track the context of a conversation, so that your bot remembers things like the answers to previous questions. Depending on what your bot is used for, you may even need to keep track of state or store information for longer than the lifetime of the conversation. A bot's state is information it remembers in order to respond appropriately to incoming messages. The Bot Builder SDK provides classes for storing and retrieving state data as an object associated with a user or a conversation. - -# Further reading - -- [Azure Bot Service Introduction](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) -- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?view=azure-bot-service-4.0) -- [Write directly to storage](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-v4-storage?view=azure-bot-service-4.0&tabs=csharpechorproperty%2Ccsetagoverwrite%2Ccsetag) -- [Managing conversation and user state](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-v4-state?view=azure-bot-service-4.0) -- [Microsoft Recognizers-Text](https://github.com/Microsoft/Recognizers-Text/tree/master/Python) \ No newline at end of file diff --git a/samples/44.prompt-users-for-input/app.py b/samples/44.prompt-users-for-input/app.py deleted file mode 100644 index 34633b1fe..000000000 --- a/samples/44.prompt-users-for-input/app.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import CustomPromptBot - -# 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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - - -# Set the error handler on the Adapter. -# In this case, we want an unbound method, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create Bot -BOT = CustomPromptBot(CONVERSATION_STATE, USER_STATE) - - -# 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 diff --git a/samples/44.prompt-users-for-input/bots/__init__.py b/samples/44.prompt-users-for-input/bots/__init__.py deleted file mode 100644 index 87a52e887..000000000 --- a/samples/44.prompt-users-for-input/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .custom_prompt_bot import CustomPromptBot - -__all__ = ["CustomPromptBot"] diff --git a/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py b/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py deleted file mode 100644 index 693eee92a..000000000 --- a/samples/44.prompt-users-for-input/bots/custom_prompt_bot.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from datetime import datetime - -from recognizers_number import recognize_number, Culture -from recognizers_date_time import recognize_datetime - -from botbuilder.core import ( - ActivityHandler, - ConversationState, - TurnContext, - UserState, - MessageFactory, -) - -from data_models import ConversationFlow, Question, UserProfile - - -class ValidationResult: - def __init__( - self, is_valid: bool = False, value: object = None, message: str = None - ): - self.is_valid = is_valid - self.value = value - self.message = message - - -class CustomPromptBot(ActivityHandler): - def __init__(self, conversation_state: ConversationState, user_state: UserState): - if conversation_state is None: - raise TypeError( - "[CustomPromptBot]: Missing parameter. conversation_state is required but None was given" - ) - if user_state is None: - raise TypeError( - "[CustomPromptBot]: Missing parameter. user_state is required but None was given" - ) - - self.conversation_state = conversation_state - self.user_state = user_state - - self.flow_accessor = self.conversation_state.create_property("ConversationFlow") - self.profile_accessor = self.conversation_state.create_property("UserProfile") - - async def on_message_activity(self, turn_context: TurnContext): - # Get the state properties from the turn context. - profile = await self.profile_accessor.get(turn_context, UserProfile) - flow = await self.flow_accessor.get(turn_context, ConversationFlow) - - await self._fill_out_user_profile(flow, profile, turn_context) - - # Save changes to UserState and ConversationState - await self.conversation_state.save_changes(turn_context) - await self.user_state.save_changes(turn_context) - - async def _fill_out_user_profile( - self, flow: ConversationFlow, profile: UserProfile, turn_context: TurnContext - ): - user_input = turn_context.activity.text.strip() - - # ask for name - if flow.last_question_asked == Question.NONE: - await turn_context.send_activity( - MessageFactory.text("Let's get started. What is your name?") - ) - flow.last_question_asked = Question.NAME - - # validate name then ask for age - elif flow.last_question_asked == Question.NAME: - validate_result = self._validate_name(user_input) - if not validate_result.is_valid: - await turn_context.send_activity( - MessageFactory.text(validate_result.message) - ) - else: - profile.name = validate_result.value - await turn_context.send_activity( - MessageFactory.text(f"Hi {profile.name}") - ) - await turn_context.send_activity( - MessageFactory.text("How old are you?") - ) - flow.last_question_asked = Question.AGE - - # validate age then ask for date - elif flow.last_question_asked == Question.AGE: - validate_result = self._validate_age(user_input) - if not validate_result.is_valid: - await turn_context.send_activity( - MessageFactory.text(validate_result.message) - ) - else: - profile.age = validate_result.value - await turn_context.send_activity( - MessageFactory.text(f"I have your age as {profile.age}.") - ) - await turn_context.send_activity( - MessageFactory.text("When is your flight?") - ) - flow.last_question_asked = Question.DATE - - # validate date and wrap it up - elif flow.last_question_asked == Question.DATE: - validate_result = self._validate_date(user_input) - if not validate_result.is_valid: - await turn_context.send_activity( - MessageFactory.text(validate_result.message) - ) - else: - profile.date = validate_result.value - await turn_context.send_activity( - MessageFactory.text( - f"Your cab ride to the airport is scheduled for {profile.date}." - ) - ) - await turn_context.send_activity( - MessageFactory.text( - f"Thanks for completing the booking {profile.name}." - ) - ) - await turn_context.send_activity( - MessageFactory.text("Type anything to run the bot again.") - ) - flow.last_question_asked = Question.NONE - - def _validate_name(self, user_input: str) -> ValidationResult: - if not user_input: - return ValidationResult( - is_valid=False, - message="Please enter a name that contains at least one character.", - ) - - return ValidationResult(is_valid=True, value=user_input) - - def _validate_age(self, user_input: str) -> ValidationResult: - # Attempt to convert the Recognizer result to an integer. This works for "a dozen", "twelve", "12", and so on. - # The recognizer returns a list of potential recognition results, if any. - results = recognize_number(user_input, Culture.English) - for result in results: - if "value" in result.resolution: - age = int(result.resolution["value"]) - if 18 <= age <= 120: - return ValidationResult(is_valid=True, value=age) - - return ValidationResult( - is_valid=False, message="Please enter an age between 18 and 120." - ) - - def _validate_date(self, user_input: str) -> ValidationResult: - try: - # Try to recognize the input as a date-time. This works for responses such as "11/14/2018", "9pm", - # "tomorrow", "Sunday at 5pm", and so on. The recognizer returns a list of potential recognition results, - # if any. - results = recognize_datetime(user_input, Culture.English) - for result in results: - for resolution in result.resolution["values"]: - if "value" in resolution: - now = datetime.now() - - value = resolution["value"] - if resolution["type"] == "date": - candidate = datetime.strptime(value, "%Y-%m-%d") - elif resolution["type"] == "time": - candidate = datetime.strptime(value, "%H:%M:%S") - candidate = candidate.replace( - year=now.year, month=now.month, day=now.day - ) - else: - candidate = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") - - # user response must be more than an hour out - diff = candidate - now - if diff.total_seconds() >= 3600: - return ValidationResult( - is_valid=True, - value=candidate.strftime("%m/%d/%y @ %H:%M"), - ) - - return ValidationResult( - is_valid=False, - message="I'm sorry, please enter a date at least an hour out.", - ) - except ValueError: - return ValidationResult( - is_valid=False, - message="I'm sorry, I could not interpret that as an appropriate " - "date. Please enter a date at least an hour out.", - ) diff --git a/samples/44.prompt-users-for-input/config.py b/samples/44.prompt-users-for-input/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/44.prompt-users-for-input/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/44.prompt-users-for-input/data_models/__init__.py b/samples/44.prompt-users-for-input/data_models/__init__.py deleted file mode 100644 index 1ca181322..000000000 --- a/samples/44.prompt-users-for-input/data_models/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .conversation_flow import ConversationFlow, Question -from .user_profile import UserProfile - -__all__ = ["ConversationFlow", "Question", "UserProfile"] diff --git a/samples/44.prompt-users-for-input/data_models/conversation_flow.py b/samples/44.prompt-users-for-input/data_models/conversation_flow.py deleted file mode 100644 index f848db64f..000000000 --- a/samples/44.prompt-users-for-input/data_models/conversation_flow.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from enum import Enum - - -class Question(Enum): - NAME = 1 - AGE = 2 - DATE = 3 - NONE = 4 - - -class ConversationFlow: - def __init__( - self, last_question_asked: Question = Question.NONE, - ): - self.last_question_asked = last_question_asked diff --git a/samples/44.prompt-users-for-input/data_models/user_profile.py b/samples/44.prompt-users-for-input/data_models/user_profile.py deleted file mode 100644 index b1c40978e..000000000 --- a/samples/44.prompt-users-for-input/data_models/user_profile.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class UserProfile: - def __init__(self, name: str = None, age: int = 0, date: str = None): - self.name = name - self.age = age - self.date = date diff --git a/samples/44.prompt-users-for-input/requirements.txt b/samples/44.prompt-users-for-input/requirements.txt deleted file mode 100644 index 5a3de5833..000000000 --- a/samples/44.prompt-users-for-input/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 -recognizers-text>=1.0.2a1 diff --git a/samples/45.state-management/README.md b/samples/45.state-management/README.md deleted file mode 100644 index f6ca355a4..000000000 --- a/samples/45.state-management/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Save user and conversation data - -This sample demonstrates how to save user and conversation data in a Python bot. -The bot maintains conversation state to track and direct the conversation and ask the user questions. -The bot maintains user state to track the user's answers. - -## 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\45.state-management` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - - -## Bot State - -A key to good bot design is to track the context of a conversation, so that your bot remembers things like the answers to previous questions. Depending on what your bot is used for, you may even need to keep track of state or store information for longer than the lifetime of the conversation. A bot's state is information it remembers in order to respond appropriately to incoming messages. The Bot Builder SDK provides classes for storing and retrieving state data as an object associated with a user or a conversation. - -# Further reading - -- [Azure Bot Service Introduction](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) -- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?view=azure-bot-service-4.0) -- [Write directly to storage](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-v4-storage?view=azure-bot-service-4.0&tabs=csharpechorproperty%2Ccsetagoverwrite%2Ccsetag) -- [Managing conversation and user state](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-v4-state?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/45.state-management/app.py b/samples/45.state-management/app.py deleted file mode 100644 index 4609c2881..000000000 --- a/samples/45.state-management/app.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import StateManagementBot - -# 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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - -# Set the error handler on the Adapter. -# In this case, we want an unbound method, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create Bot -BOT = StateManagementBot(CONVERSATION_STATE, USER_STATE) - - -# 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 diff --git a/samples/45.state-management/bots/__init__.py b/samples/45.state-management/bots/__init__.py deleted file mode 100644 index 535957236..000000000 --- a/samples/45.state-management/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .state_management_bot import StateManagementBot - -__all__ = ["StateManagementBot"] diff --git a/samples/45.state-management/bots/state_management_bot.py b/samples/45.state-management/bots/state_management_bot.py deleted file mode 100644 index 47b8b21f8..000000000 --- a/samples/45.state-management/bots/state_management_bot.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import time -from datetime import datetime - -from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState -from botbuilder.schema import ChannelAccount - -from data_models import ConversationData, UserProfile - - -class StateManagementBot(ActivityHandler): - def __init__(self, conversation_state: ConversationState, user_state: UserState): - if conversation_state is None: - raise TypeError( - "[StateManagementBot]: Missing parameter. conversation_state is required but None was given" - ) - if user_state is None: - raise TypeError( - "[StateManagementBot]: Missing parameter. user_state is required but None was given" - ) - - self.conversation_state = conversation_state - self.user_state = user_state - - self.conversation_data_accessor = self.conversation_state.create_property( - "ConversationData" - ) - self.user_profile_accessor = self.user_state.create_property("UserProfile") - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - await self.conversation_state.save_changes(turn_context) - await self.user_state.save_changes(turn_context) - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - "Welcome to State Bot Sample. Type anything to get started." - ) - - async def on_message_activity(self, turn_context: TurnContext): - # Get the state properties from the turn context. - user_profile = await self.user_profile_accessor.get(turn_context, UserProfile) - conversation_data = await self.conversation_data_accessor.get( - turn_context, ConversationData - ) - - if user_profile.name is None: - # First time around this is undefined, so we will prompt user for name. - if conversation_data.prompted_for_user_name: - # Set the name to what the user provided. - user_profile.name = turn_context.activity.text - - # Acknowledge that we got their name. - await turn_context.send_activity( - f"Thanks { user_profile.name }. To see conversation data, type anything." - ) - - # Reset the flag to allow the bot to go though the cycle again. - conversation_data.prompted_for_user_name = False - else: - # Prompt the user for their name. - await turn_context.send_activity("What is your name?") - - # Set the flag to true, so we don't prompt in the next turn. - conversation_data.prompted_for_user_name = True - else: - # Add message details to the conversation data. - conversation_data.timestamp = self.__datetime_from_utc_to_local( - turn_context.activity.timestamp - ) - conversation_data.channel_id = turn_context.activity.channel_id - - # Display state data. - await turn_context.send_activity( - f"{ user_profile.name } sent: { turn_context.activity.text }" - ) - await turn_context.send_activity( - f"Message received at: { conversation_data.timestamp }" - ) - await turn_context.send_activity( - f"Message received from: { conversation_data.channel_id }" - ) - - def __datetime_from_utc_to_local(self, utc_datetime): - now_timestamp = time.time() - offset = datetime.fromtimestamp(now_timestamp) - datetime.utcfromtimestamp( - now_timestamp - ) - result = utc_datetime + offset - return result.strftime("%I:%M:%S %p, %A, %B %d of %Y") diff --git a/samples/45.state-management/config.py b/samples/45.state-management/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/45.state-management/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/45.state-management/data_models/__init__.py b/samples/45.state-management/data_models/__init__.py deleted file mode 100644 index 4e69f286b..000000000 --- a/samples/45.state-management/data_models/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .conversation_data import ConversationData -from .user_profile import UserProfile - -__all__ = ["ConversationData", "UserProfile"] diff --git a/samples/45.state-management/data_models/conversation_data.py b/samples/45.state-management/data_models/conversation_data.py deleted file mode 100644 index 4b2757e43..000000000 --- a/samples/45.state-management/data_models/conversation_data.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class ConversationData: - def __init__( - self, - timestamp: str = None, - channel_id: str = None, - prompted_for_user_name: bool = False, - ): - self.timestamp = timestamp - self.channel_id = channel_id - self.prompted_for_user_name = prompted_for_user_name diff --git a/samples/45.state-management/data_models/user_profile.py b/samples/45.state-management/data_models/user_profile.py deleted file mode 100644 index 36add1ea1..000000000 --- a/samples/45.state-management/data_models/user_profile.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class UserProfile: - def __init__(self, name: str = None): - self.name = name diff --git a/samples/45.state-management/requirements.txt b/samples/45.state-management/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/45.state-management/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/47.inspection/README.md b/samples/47.inspection/README.md deleted file mode 100644 index 6e2c42a08..000000000 --- a/samples/47.inspection/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Inspection Bot - -Bot Framework v4 Inspection Middleware sample. - -This bot demonstrates a feature called Inspection. This feature allows the Bot Framework Emulator to debug traffic into and out of the bot in addition to looking at the current state of the bot. This is done by having this data sent to the emulator using trace messages. - -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. Included in this sample are two counters maintained in User and Conversation state to demonstrate the ability to look at state. - -This runtime behavior is achieved by simply adding a middleware to the Adapter. In this sample you can find that being done in `app.py`. - -More details are available [here](https://github.com/microsoft/BotFramework-Emulator/blob/master/content/CHANNELS.md) - -## Running the sample -- Clone the repository -```bash -git clone https://github.com/Microsoft/botbuilder-python.git -``` -- Bring up a terminal, navigate to `botbuilder-python\samples\47.inspection` 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 version 4.5.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` - -### Special Instructions for Running Inspection - -- In the emulator, select Debug -> Start Debugging. -- Enter the endpoint url (http://localhost:8080)/api/messages, and select Connect. -- The result is a trace activity which contains a statement that looks like /INSPECT attach < identifier > -- Right click and copy that response. -- In the original Live Chat session paste the value. -- Now all the traffic will be replicated (as trace activities) to the Emulator Debug tab. - -# Further reading - -- [Getting started with the Bot Inspector](https://github.com/microsoft/BotFramework-Emulator/blob/master/content/CHANNELS.md) -- [Azure Bot Service Introduction](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) -- [Bot State](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-storage-concept?view=azure-bot-service-4.0) diff --git a/samples/47.inspection/app.py b/samples/47.inspection/app.py deleted file mode 100644 index c699450c5..000000000 --- a/samples/47.inspection/app.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import sys -from datetime import datetime - -from flask import Flask, request, Response - -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - TurnContext, - UserState -) -from botbuilder.core.inspection import InspectionMiddleware, InspectionState -from botbuilder.schema import Activity, ActivityTypes -from botframework.connector.auth import MicrosoftAppCredentials - -from bots import EchoBot - -# 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) - - # Clear out state - await CONVERSATION_STATE.delete(context) - - -# Set the error handler on the Adapter. -# In this case, we want an unbound method, so MethodType is not needed. -ADAPTER.on_turn_error = on_error - -# Create MemoryStorage and state -MEMORY = MemoryStorage() -USER_STATE = UserState(MEMORY) -CONVERSATION_STATE = ConversationState(MEMORY) - -# Create InspectionMiddleware -INSPECTION_MIDDLEWARE = InspectionMiddleware( - inspection_state=InspectionState(MEMORY), - user_state=USER_STATE, - conversation_state=CONVERSATION_STATE, - credentials=MicrosoftAppCredentials( - app_id=APP.config["APP_ID"], password=APP.config["APP_PASSWORD"] - ), -) -ADAPTER.use(INSPECTION_MIDDLEWARE) - -# Create Bot -BOT = EchoBot(CONVERSATION_STATE, USER_STATE) - - -# 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 diff --git a/samples/47.inspection/bots/__init__.py b/samples/47.inspection/bots/__init__.py deleted file mode 100644 index f95fbbbad..000000000 --- a/samples/47.inspection/bots/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .echo_bot import EchoBot - -__all__ = ["EchoBot"] diff --git a/samples/47.inspection/bots/echo_bot.py b/samples/47.inspection/bots/echo_bot.py deleted file mode 100644 index 21a99aa9d..000000000 --- a/samples/47.inspection/bots/echo_bot.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ( - ActivityHandler, - ConversationState, - TurnContext, - UserState, - MessageFactory, -) -from botbuilder.schema import ChannelAccount - -from data_models import CustomState - - -class EchoBot(ActivityHandler): - def __init__(self, conversation_state: ConversationState, user_state: UserState): - if conversation_state is None: - raise TypeError( - "[EchoBot]: Missing parameter. conversation_state is required but None was given" - ) - if user_state is None: - raise TypeError( - "[EchoBot]: Missing parameter. user_state is required but None was given" - ) - - self.conversation_state = conversation_state - self.user_state = user_state - - self.conversation_state_accessor = self.conversation_state.create_property( - "CustomState" - ) - self.user_state_accessor = self.user_state.create_property("CustomState") - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - await self.conversation_state.save_changes(turn_context) - await self.user_state.save_changes(turn_context) - - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity("Hello and welcome!") - - async def on_message_activity(self, turn_context: TurnContext): - # Get the state properties from the turn context. - user_data = await self.user_state_accessor.get(turn_context, CustomState) - conversation_data = await self.conversation_state_accessor.get( - turn_context, CustomState - ) - - await turn_context.send_activity( - MessageFactory.text( - f"Echo: {turn_context.activity.text}, " - f"conversation state: {conversation_data.value}, " - f"user state: {user_data.value}" - ) - ) - - user_data.value = user_data.value + 1 - conversation_data.value = conversation_data.value + 1 diff --git a/samples/47.inspection/config.py b/samples/47.inspection/config.py deleted file mode 100644 index e007d0fa9..000000000 --- a/samples/47.inspection/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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", "") diff --git a/samples/47.inspection/data_models/__init__.py b/samples/47.inspection/data_models/__init__.py deleted file mode 100644 index f84d31d7b..000000000 --- a/samples/47.inspection/data_models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .custom_state import CustomState - -__all__ = ["CustomState"] diff --git a/samples/47.inspection/data_models/custom_state.py b/samples/47.inspection/data_models/custom_state.py deleted file mode 100644 index 96a405cd4..000000000 --- a/samples/47.inspection/data_models/custom_state.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class CustomState: - def __init__(self, value: int = 0): - self.value = value diff --git a/samples/47.inspection/requirements.txt b/samples/47.inspection/requirements.txt deleted file mode 100644 index 7e54b62ec..000000000 --- a/samples/47.inspection/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -botbuilder-core>=4.4.0b1 -flask>=1.0.3 diff --git a/samples/README.md b/samples/README.md deleted file mode 100644 index 72f1506a9..000000000 --- a/samples/README.md +++ /dev/null @@ -1,14 +0,0 @@ - -# Contributing - -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.microsoft.com. - -When you submit a pull request, a CLA-bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/samples/python_django/13.core-bot/README-LUIS.md b/samples/python_django/13.core-bot/README-LUIS.md deleted file mode 100644 index b6b9b925f..000000000 --- a/samples/python_django/13.core-bot/README-LUIS.md +++ /dev/null @@ -1,216 +0,0 @@ -# Setting up LUIS via CLI: - -This README contains information on how to create and deploy a LUIS application. When the bot is ready to be deployed to production, we recommend creating a LUIS Endpoint Resource for usage with your LUIS App. - -> _For instructions on how to create a LUIS Application via the LUIS portal, see these Quickstart steps:_ -> 1. _[Quickstart: Create a new app in the LUIS portal][Quickstart-create]_ -> 2. _[Quickstart: Deploy an app in the LUIS portal][Quickstart-deploy]_ - - [Quickstart-create]: https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-build-app - [Quickstart-deploy]:https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-deploy-app - -## Table of Contents: - -- [Prerequisites](#Prerequisites) -- [Import a new LUIS Application using a local LUIS application](#Import-a-new-LUIS-Application-using-a-local-LUIS-application) -- [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#How-to-create-a-LUIS-Endpoint-resource-in-Azure-and-pair-it-with-a-LUIS-Application) - -___ - -## [Prerequisites](#Table-of-Contents): - -#### Install Azure CLI >=2.0.61: - -Visit the following page to find the correct installer for your OS: -- https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest - -#### Install LUIS CLI >=2.4.0: - -Open a CLI of your choice and type the following: - -```bash -npm i -g luis-apis@^2.4.0 -``` - -#### LUIS portal account: - -You should already have a LUIS account with either https://luis.ai, https://eu.luis.ai, or https://au.luis.ai. To determine where to create a LUIS account, consider where you will deploy your LUIS applications, and then place them in [the corresponding region][LUIS-Authoring-Regions]. - -After you've created your account, you need your [Authoring Key][LUIS-AKey] and a LUIS application ID. - - [LUIS-Authoring-Regions]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-reference-regions#luis-authoring-regions] - [LUIS-AKey]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-concept-keys#authoring-key - -___ - -## [Import a new LUIS Application using a local LUIS application](#Table-of-Contents) - -### 1. Import the local LUIS application to luis.ai - -```bash -luis import application --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appName "FlightBooking" --in "./cognitiveModels/FlightBooking.json" -``` - -Outputs the following JSON: - -```json -{ - "id": "########-####-####-####-############", - "name": "FlightBooking", - "description": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "usageScenario": "", - "domain": "", - "versionsCount": 1, - "createdDateTime": "2019-03-29T18:32:02Z", - "endpoints": {}, - "endpointHitsCount": 0, - "activeVersion": "0.1", - "ownerEmail": "bot@contoso.com", - "tokenizerVersion": "1.0.0" -} -``` - -For the next step, you'll need the `"id"` value for `--appId` and the `"activeVersion"` value for `--versionId`. - -### 2. Train the LUIS Application - -```bash -luis train version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --wait -``` - -### 3. Publish the LUIS Application - -```bash -luis publish version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --publishRegion "LuisAppPublishRegion" -``` - -> `--region` corresponds to the region you _author_ your application in. The regions available for this are "westus", "westeurope" and "australiaeast".
-> These regions correspond to the three available portals, https://luis.ai, https://eu.luis.ai, or https://au.luis.ai.
-> `--publishRegion` corresponds to the region of the endpoint you're publishing to, (e.g. "westus", "southeastasia", "westeurope", "brazilsouth").
-> See the [reference docs][Endpoint-API] for a list of available publish/endpoint regions. - - [Endpoint-API]: https://westus.dev.cognitive.microsoft.com/docs/services/5819c76f40a6350ce09de1ac/operations/5819c77140a63516d81aee78 - -Outputs the following: - -```json - { - "versionId": "0.1", - "isStaging": false, - "endpointUrl": "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/########-####-####-####-############", - "region": "westus", - "assignedEndpointKey": null, - "endpointRegion": "westus", - "failedRegions": "", - "publishedDateTime": "2019-03-29T18:40:32Z", - "directVersionPublish": false -} -``` - -To see how to create an LUIS Cognitive Service Resource in Azure, please see [the next README][README-LUIS]. This Resource should be used when you want to move your bot to production. The instructions will show you how to create and pair the resource with a LUIS Application. - - [README-LUIS]: ./README-LUIS.md - -___ - -## [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#Table-of-Contents) - -### 1. Create a new LUIS Cognitive Services resource on Azure via Azure CLI - -> _Note:_
-> _If you don't have a Resource Group in your Azure subscription, you can create one through the Azure portal or through using:_ -> ```bash -> az group create --subscription "AzureSubscriptionGuid" --location "westus" --name "ResourceGroupName" -> ``` -> _To see a list of valid locations, use `az account list-locations`_ - - -```bash -# Use Azure CLI to create the LUIS Key resource on Azure -az cognitiveservices account create --kind "luis" --name "NewLuisResourceName" --sku "S0" --location "westus" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -The command will output a response similar to the JSON below: - -```json -{ - "endpoint": "https://westus.api.cognitive.microsoft.com/luis/v2.0", - "etag": "\"########-####-####-####-############\"", - "id": "/subscriptions/########-####-####-####-############/resourceGroups/ResourceGroupName/providers/Microsoft.CognitiveServices/accounts/NewLuisResourceName", - "internalId": "################################", - "kind": "luis", - "location": "westus", - "name": "NewLuisResourceName", - "provisioningState": "Succeeded", - "resourceGroup": "ResourceGroupName", - "sku": { - "name": "S0", - "tier": null - }, - "tags": null, - "type": "Microsoft.CognitiveServices/accounts" -} -``` - - - -Take the output from the previous command and create a JSON file in the following format: - -```json -{ - "azureSubscriptionId": "00000000-0000-0000-0000-000000000000", - "resourceGroup": "ResourceGroupName", - "accountName": "NewLuisResourceName" -} -``` - -### 2. Retrieve ARM access token via Azure CLI - -```bash -az account get-access-token --subscription "AzureSubscriptionGuid" -``` - -This will return an object that looks like this: - -```json -{ - "accessToken": "eyJ0eXAiOiJKVtokentokentokentokentokeng1dCI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyIsItokenI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTUzODc3MTUwLCJuYmYiOjE1NTM4NzcxNTAsImV4cCI6MTU1Mzg4MTA1MCwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2ZmZTQyM2RkLWJhM2YtNDg0Ny04NjgyLWExNTI5MDA4MjM4Ny9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVZRQXEvOEtBQUFBeGVUc201NDlhVHg4RE1mMFlRVnhGZmxxOE9RSC9PODR3QktuSmRqV1FqTkkwbmxLYzB0bHJEZzMyMFZ5bWZGaVVBSFBvNUFFUTNHL0FZNDRjdk01T3M0SEt0OVJkcE5JZW9WU0dzd0kvSkk9IiwiYW1yIjpbIndpYSIsIm1mYSJdLCJhcHBpZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiNDhmNDVjNjEtMTg3Zi00MjUxLTlmZWItMTllZGFkZmMwMmE3IiwiZmFtaWx5X25hbWUiOiJHdW0iLCJnaXZlbl9uYW1lIjoiU3RldmVuIiwiaXBhZGRyIjoiMTY3LjIyMC4yLjU1IiwibmFtZSI6IlN0ZXZlbiBHdW0iLCJvaWQiOiJmZmU0MjNkZC1iYTNmLTQ4NDctODY4Mi1hMTUyOTAwODIzODciLCJvbnByZW1fc2lkIjoiUy0xLTUtMjEtMjEyNzUyMTE4NC0xNjA0MDEyOTIwLTE4ODc5Mjc1MjctMjYwOTgyODUiLCJwdWlkIjoiMTAwMzdGRkVBMDQ4NjlBNyIsInJoIjoiSSIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ik1rMGRNMWszN0U5ckJyMjhieUhZYjZLSU85LXVFQVVkZFVhNWpkSUd1Nk0iLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InN0Z3VtQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzdGd1bUBtaWNyb3NvZnQuY29tIiwidXRpIjoiT2w2NGN0TXY4RVNEQzZZQWRqRUFtokenInZlciI6IjEuMCJ9.kFAsEilE0mlS1pcpqxf4rEnRKeYsehyk-gz-zJHUrE__oad3QjgDSBDPrR_ikLdweynxbj86pgG4QFaHURNCeE6SzrbaIrNKw-n9jrEtokenlosOxg_0l2g1LeEUOi5Q4gQREAU_zvSbl-RY6sAadpOgNHtGvz3Rc6FZRITfkckSLmsKAOFoh-aWC6tFKG8P52rtB0qVVRz9tovBeNqkMYL49s9ypduygbXNVwSQhm5JszeWDgrFuVFHBUP_iENCQYGQpEZf_KvjmX1Ur1F9Eh9nb4yI2gFlKncKNsQl-tokenK7-tokentokentokentokentokentokenatoken", - "expiresOn": "2200-12-31 23:59:59.999999", - "subscription": "AzureSubscriptionGuid", - "tenant": "tenant-guid", - "tokenType": "Bearer" -} -``` - -The value needed for the next step is the `"accessToken"`. - -### 3. Use `luis add appazureaccount` to pair your LUIS resource with a LUIS Application - -```bash -luis add appazureaccount --in "path/to/created/requestBody.json" --appId "LuisAppId" --authoringKey "LuisAuthoringKey" --armToken "accessToken" -``` - -If successful, it should yield a response like this: - -```json -{ - "code": "Success", - "message": "Operation Successful" -} -``` - -### 4. See the LUIS Cognitive Services' keys - -```bash -az cognitiveservices account keys list --name "NewLuisResourceName" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" -``` - -This will return an object that looks like this: - -```json -{ - "key1": "9a69####dc8f####8eb4####399f####", - "key2": "####f99e####4b1a####fb3b####6b9f" -} -``` \ No newline at end of file diff --git a/samples/python_django/13.core-bot/README.md b/samples/python_django/13.core-bot/README.md deleted file mode 100644 index 1724d8d04..000000000 --- a/samples/python_django/13.core-bot/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# CoreBot - -Bot Framework v4 core bot sample. - -This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to: - -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities -- Implement a multi-turn conversation using Dialogs -- Handle user interruptions for such things as `Help` or `Cancel` -- Prompt for and validate requests for information from the user - -## Prerequisites - -This sample **requires** prerequisites in order to run. - -### Overview - -This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding. - -### Install Python 3.6 - - -### Create a LUIS Application to enable language understanding - -LUIS language model setup, training, and application configuration steps can be found [here](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=cs). - -If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). - - -### Configure your bot to use your LUIS app - -Update config.py with your newly imported LUIS app id, LUIS API key from https:///user/settings, LUIS API host name, ie .api.cognitive.microsoft.com. LUIS authoring region is listed on https:///user/settings. - - -## 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` - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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) -- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/samples/python_django/13.core-bot/booking_details.py b/samples/python_django/13.core-bot/booking_details.py deleted file mode 100644 index 4502ee974..000000000 --- a/samples/python_django/13.core-bot/booking_details.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Booking detail.""" - - -class BookingDetails: - """Booking detail implementation""" - - def __init__( - self, destination: str = None, origin: str = None, travel_date: str = None - ): - self.destination = destination - self.origin = origin - self.travel_date = travel_date diff --git a/samples/python_django/13.core-bot/bots/__init__.py b/samples/python_django/13.core-bot/bots/__init__.py deleted file mode 100644 index ee478912d..000000000 --- a/samples/python_django/13.core-bot/bots/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""bots module.""" - -from .dialog_bot import DialogBot -from .dialog_and_welcome_bot import DialogAndWelcomeBot - -__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/samples/python_django/13.core-bot/bots/bots.py b/samples/python_django/13.core-bot/bots/bots.py deleted file mode 100644 index a1d783449..000000000 --- a/samples/python_django/13.core-bot/bots/bots.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" Bot initialization """ -# pylint: disable=line-too-long -import sys -import asyncio -from django.apps import AppConfig -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - TurnContext, - ConversationState, - MemoryStorage, - UserState, -) -from dialogs import MainDialog -from bots import DialogAndWelcomeBot -import config - - -class BotConfig(AppConfig): - """ Bot initialization """ - - name = "bots" - appConfig = config.DefaultConfig - - SETTINGS = BotFrameworkAdapterSettings(appConfig.APP_ID, appConfig.APP_PASSWORD) - ADAPTER = BotFrameworkAdapter(SETTINGS) - LOOP = asyncio.get_event_loop() - - # Create MemoryStorage, UserState and ConversationState - memory = MemoryStorage() - user_state = UserState(memory) - conversation_state = ConversationState(memory) - - dialog = MainDialog(appConfig) - bot = DialogAndWelcomeBot(conversation_state, user_state, dialog) - - async def on_error(self, context: TurnContext, error: Exception): - """ - Catch-all for errors. - This check writes out errors to console log - 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 - await context.send_activity("Oops. Something went wrong!") - # Clear out state - await self.conversation_state.delete(context) - - def ready(self): - self.ADAPTER.on_turn_error = self.on_error diff --git a/samples/python_django/13.core-bot/bots/dialog_and_welcome_bot.py b/samples/python_django/13.core-bot/bots/dialog_and_welcome_bot.py deleted file mode 100644 index fe030d056..000000000 --- a/samples/python_django/13.core-bot/bots/dialog_and_welcome_bot.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Main dialog to welcome users.""" -import json -import os.path -from typing import List -from botbuilder.core import TurnContext -from botbuilder.schema import Activity, Attachment, ChannelAccount -from helpers.activity_helper import create_activity_reply -from .dialog_bot import DialogBot - - -class DialogAndWelcomeBot(DialogBot): - """Main dialog to welcome users implementation.""" - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - # Greet anyone that was not the target (recipient) of this message. - # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards - # for more details. - if member.id != turn_context.activity.recipient.id: - welcome_card = self.create_adaptive_card_attachment() - response = self.create_response(turn_context.activity, welcome_card) - await turn_context.send_activity(response) - - def create_response(self, activity: Activity, attachment: Attachment): - """Create an attachment message response.""" - response = create_activity_reply(activity) - response.attachments = [attachment] - return response - - # Load attachment from file. - def create_adaptive_card_attachment(self): - """Create an adaptive card.""" - relative_path = os.path.abspath(os.path.dirname(__file__)) - path = os.path.join(relative_path, "resources/welcomeCard.json") - with open(path) as card_file: - card = json.load(card_file) - - return Attachment( - content_type="application/vnd.microsoft.card.adaptive", content=card - ) diff --git a/samples/python_django/13.core-bot/bots/dialog_bot.py b/samples/python_django/13.core-bot/bots/dialog_bot.py deleted file mode 100644 index f8b221e87..000000000 --- a/samples/python_django/13.core-bot/bots/dialog_bot.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Implements bot Activity handler.""" - -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - """Main activity handler for the bot.""" - - def __init__( - self, - conversation_state: ConversationState, - user_state: UserState, - dialog: Dialog, - ): - if conversation_state is None: - raise Exception( - "[DialogBot]: Missing parameter. conversation_state is required" - ) - if user_state is None: - raise Exception("[DialogBot]: Missing parameter. user_state is required") - if dialog is None: - raise Exception("[DialogBot]: Missing parameter. dialog is required") - - self.conversation_state = conversation_state - self.user_state = user_state - self.dialog = dialog - self.dialogState = self.conversation_state.create_property( - "DialogState" - ) # pylint: disable=C0103 - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - # Save any state changes that might have occured during the turn. - await self.conversation_state.save_changes(turn_context, False) - await self.user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState"), - ) # pylint: disable=C0103 diff --git a/samples/python_django/13.core-bot/bots/resources/welcomeCard.json b/samples/python_django/13.core-bot/bots/resources/welcomeCard.json deleted file mode 100644 index 100aa5287..000000000 --- a/samples/python_django/13.core-bot/bots/resources/welcomeCard.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.0", - "body": [ - { - "type": "Image", - "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", - "size": "stretch" - }, - { - "type": "TextBlock", - "spacing": "medium", - "size": "default", - "weight": "bolder", - "text": "Welcome to Bot Framework!", - "wrap": true, - "maxLines": 0 - }, - { - "type": "TextBlock", - "size": "default", - "isSubtle": "true", - "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", - "wrap": true, - "maxLines": 0 - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "Get an overview", - "url": "https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" - }, - { - "type": "Action.OpenUrl", - "title": "Ask a question", - "url": "https://stackoverflow.com/questions/tagged/botframework" - }, - { - "type": "Action.OpenUrl", - "title": "Learn how to deploy", - "url": "https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" - } - ] -} \ No newline at end of file diff --git a/samples/python_django/13.core-bot/bots/settings.py b/samples/python_django/13.core-bot/bots/settings.py deleted file mode 100644 index 99fd265c7..000000000 --- a/samples/python_django/13.core-bot/bots/settings.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -Django settings for bots project. - -Generated by 'django-admin startproject' using Django 2.2.1. - -For more information on this file, see -https://docs.djangoproject.com/en/2.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.2/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "My Secret Key" - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "bots.bots.BotConfig", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "bots.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - }, - } -] - -WSGI_APPLICATION = "bots.wsgi.application" - - -# Database -# https://docs.djangoproject.com/en/2.2/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } -} - - -# Password validation -# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" - }, - {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, - {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, - {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, -] - - -# Internationalization -# https://docs.djangoproject.com/en/2.2/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.2/howto/static-files/ - -STATIC_URL = "/static/" diff --git a/samples/python_django/13.core-bot/bots/urls.py b/samples/python_django/13.core-bot/bots/urls.py deleted file mode 100644 index 99cf42018..000000000 --- a/samples/python_django/13.core-bot/bots/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" URL configuration for bot message handler """ - -from django.urls import path -from django.views.decorators.csrf import csrf_exempt -from . import views - -# pylint:disable=invalid-name -urlpatterns = [ - path("", views.home, name="home"), - path("api/messages", csrf_exempt(views.messages), name="messages"), -] diff --git a/samples/python_django/13.core-bot/bots/views.py b/samples/python_django/13.core-bot/bots/views.py deleted file mode 100644 index 04f354424..000000000 --- a/samples/python_django/13.core-bot/bots/views.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -This sample shows how to create a bot that demonstrates the following: -- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. -- Implement a multi-turn conversation using Dialogs. -- Handle user interruptions for such things as `Help` or `Cancel`. -- Prompt for and validate requests for information from the user. -""" - -import asyncio -import json -from django.http import HttpResponse -from django.apps import apps -from botbuilder.schema import Activity - -# pylint: disable=line-too-long -def home(): - """Default handler.""" - return HttpResponse("Hello!") - - -def messages(request): - """Main bot message handler.""" - if "application/json" in request.headers["Content-Type"]: - body = json.loads(request.body.decode("utf-8")) - else: - return HttpResponse(status=415) - - activity = Activity().deserialize(body) - auth_header = ( - request.headers["Authorization"] if "Authorization" in request.headers else "" - ) - - bot_app = apps.get_app_config("bots") - bot = bot_app.bot - loop = bot_app.LOOP - adapter = bot_app.ADAPTER - - async def aux_func(turn_context): - await bot.on_turn(turn_context) - - try: - task = asyncio.ensure_future( - adapter.process_activity(activity, auth_header, aux_func), loop=loop - ) - loop.run_until_complete(task) - return HttpResponse(status=201) - except Exception as exception: - raise exception - return HttpResponse("This is message processing!") diff --git a/samples/python_django/13.core-bot/bots/wsgi.py b/samples/python_django/13.core-bot/bots/wsgi.py deleted file mode 100644 index 869e12e78..000000000 --- a/samples/python_django/13.core-bot/bots/wsgi.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" -WSGI config for bots project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ -""" - -import os -from django.core.wsgi import get_wsgi_application - -# pylint:disable=invalid-name -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bots.settings") -application = get_wsgi_application() diff --git a/samples/python_django/13.core-bot/cognitiveModels/FlightBooking.json b/samples/python_django/13.core-bot/cognitiveModels/FlightBooking.json deleted file mode 100644 index 0a0d6c4a7..000000000 --- a/samples/python_django/13.core-bot/cognitiveModels/FlightBooking.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "luis_schema_version": "3.2.0", - "versionId": "0.1", - "name": "Airline Reservation", - "desc": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "tokenizerVersion": "1.0.0", - "intents": [ - { - "name": "Book flight" - }, - { - "name": "Cancel" - }, - { - "name": "None" - } - ], - "entities": [], - "composites": [ - { - "name": "From", - "children": [ - "Airport" - ], - "roles": [] - }, - { - "name": "To", - "children": [ - "Airport" - ], - "roles": [] - } - ], - "closedLists": [ - { - "name": "Airport", - "subLists": [ - { - "canonicalForm": "Paris", - "list": [ - "paris" - ] - }, - { - "canonicalForm": "London", - "list": [ - "london" - ] - }, - { - "canonicalForm": "Berlin", - "list": [ - "berlin" - ] - }, - { - "canonicalForm": "New York", - "list": [ - "new york" - ] - } - ], - "roles": [] - } - ], - "patternAnyEntities": [], - "regex_entities": [], - "prebuiltEntities": [ - { - "name": "datetimeV2", - "roles": [] - } - ], - "model_features": [], - "regex_features": [], - "patterns": [], - "utterances": [ - { - "text": "book flight from london to paris on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 27, - "endPos": 31 - }, - { - "entity": "From", - "startPos": 17, - "endPos": 22 - } - ] - }, - { - "text": "book flight to berlin on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 15, - "endPos": 20 - } - ] - }, - { - "text": "book me a flight from london to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "From", - "startPos": 22, - "endPos": 27 - }, - { - "entity": "To", - "startPos": 32, - "endPos": 36 - } - ] - }, - { - "text": "bye", - "intent": "Cancel", - "entities": [] - }, - { - "text": "cancel booking", - "intent": "Cancel", - "entities": [] - }, - { - "text": "exit", - "intent": "Cancel", - "entities": [] - }, - { - "text": "flight to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "flight to paris from london on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - }, - { - "entity": "From", - "startPos": 21, - "endPos": 26 - } - ] - }, - { - "text": "fly from berlin to paris on may 5th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 19, - "endPos": 23 - }, - { - "entity": "From", - "startPos": 9, - "endPos": 14 - } - ] - }, - { - "text": "go to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 6, - "endPos": 10 - } - ] - }, - { - "text": "going from paris to berlin", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 20, - "endPos": 25 - }, - { - "entity": "From", - "startPos": 11, - "endPos": 15 - } - ] - }, - { - "text": "ignore", - "intent": "Cancel", - "entities": [] - }, - { - "text": "travel to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - } - ], - "settings": [] -} \ No newline at end of file diff --git a/samples/python_django/13.core-bot/config.py b/samples/python_django/13.core-bot/config.py deleted file mode 100644 index c2dbd7827..000000000 --- a/samples/python_django/13.core-bot/config.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -""" Bot Configuration """ - - -class DefaultConfig(object): - """ Bot Configuration """ - - PORT = 3978 - APP_ID = "" - APP_PASSWORD = "" - - LUIS_APP_ID = "" - # LUIS authoring key from LUIS portal or LUIS Cognitive Service subscription key - LUIS_API_KEY = "" - # LUIS endpoint host name, ie "https://westus.api.cognitive.microsoft.com" - LUIS_API_HOST_NAME = "" diff --git a/samples/python_django/13.core-bot/db.sqlite3 b/samples/python_django/13.core-bot/db.sqlite3 deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python_django/13.core-bot/dialogs/__init__.py b/samples/python_django/13.core-bot/dialogs/__init__.py deleted file mode 100644 index 88d9489fd..000000000 --- a/samples/python_django/13.core-bot/dialogs/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Dialogs module""" -from .booking_dialog import BookingDialog -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog -from .main_dialog import MainDialog - -__all__ = ["BookingDialog", "CancelAndHelpDialog", "DateResolverDialog", "MainDialog"] diff --git a/samples/python_django/13.core-bot/dialogs/booking_dialog.py b/samples/python_django/13.core-bot/dialogs/booking_dialog.py deleted file mode 100644 index 8b345fd7c..000000000 --- a/samples/python_django/13.core-bot/dialogs/booking_dialog.py +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Flight booking dialog.""" - -from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult -from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions -from botbuilder.core import MessageFactory -from datatypes_date_time.timex import Timex -from .cancel_and_help_dialog import CancelAndHelpDialog -from .date_resolver_dialog import DateResolverDialog - - -class BookingDialog(CancelAndHelpDialog): - """Flight booking implementation.""" - - def __init__(self, dialog_id: str = None): - super(BookingDialog, self).__init__(dialog_id or BookingDialog.__name__) - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - # self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - self.add_dialog(DateResolverDialog(DateResolverDialog.__name__)) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__, - [ - self.destination_step, - self.origin_step, - self.travel_date_step, - # self.confirm_step, - self.final_step, - ], - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ - - async def destination_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Prompt for destination.""" - booking_details = step_context.options - - if booking_details.destination is None: - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("To what city would you like to travel?") - ), - ) # pylint: disable=line-too-long,bad-continuation - else: - return await step_context.next(booking_details.destination) - - async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Prompt for origin city.""" - booking_details = step_context.options - - # Capture the response to the previous step's prompt - booking_details.destination = step_context.result - if booking_details.origin is None: - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("From what city will you be travelling?") - ), - ) # pylint: disable=line-too-long,bad-continuation - else: - return await step_context.next(booking_details.origin) - - async def travel_date_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Prompt for travel date. - This will use the DATE_RESOLVER_DIALOG.""" - - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.origin = step_context.result - if not booking_details.travel_date or self.is_ambiguous( - booking_details.travel_date - ): - return await step_context.begin_dialog( - DateResolverDialog.__name__, booking_details.travel_date - ) # pylint: disable=line-too-long - else: - return await step_context.next(booking_details.travel_date) - - async def confirm_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Confirm the information the user has provided.""" - booking_details = step_context.options - - # Capture the results of the previous step - booking_details.travel_date = step_context.result - msg = ( - f"Please confirm, I have you traveling to: { booking_details.destination }" - f" from: { booking_details.origin } on: { booking_details.travel_date}." - ) - - # Offer a YES/NO prompt. - return await step_context.prompt( - ConfirmPrompt.__name__, PromptOptions(prompt=MessageFactory.text(msg)) - ) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Complete the interaction and end the dialog.""" - if step_context.result: - booking_details = step_context.options - booking_details.travel_date = step_context.result - - return await step_context.end_dialog(booking_details) - else: - return await step_context.end_dialog() - - def is_ambiguous(self, timex: str) -> bool: - """Ensure time is correct.""" - timex_property = Timex(timex) - return "definite" not in timex_property.types diff --git a/samples/python_django/13.core-bot/dialogs/cancel_and_help_dialog.py b/samples/python_django/13.core-bot/dialogs/cancel_and_help_dialog.py deleted file mode 100644 index 35cb15ec2..000000000 --- a/samples/python_django/13.core-bot/dialogs/cancel_and_help_dialog.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Handle cancel and help intents.""" -from botbuilder.dialogs import ( - ComponentDialog, - DialogContext, - DialogTurnResult, - DialogTurnStatus, -) -from botbuilder.schema import ActivityTypes - - -class CancelAndHelpDialog(ComponentDialog): - """Implementation of handling cancel and help.""" - - async def on_begin_dialog( - self, inner_dc: DialogContext, options: object - ) -> DialogTurnResult: - result = await self.interrupt(inner_dc) - if result is not None: - return result - - return await super(CancelAndHelpDialog, self).on_begin_dialog(inner_dc, options) - - async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: - result = await self.interrupt(inner_dc) - if result is not None: - return result - - return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) - - async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: - """Detect interruptions.""" - if inner_dc.context.activity.type == ActivityTypes.message: - text = inner_dc.context.activity.text.lower() - - if text == "help" or text == "?": - await inner_dc.context.send_activity("Show Help...") - return DialogTurnResult(DialogTurnStatus.Waiting) - - if text == "cancel" or text == "quit": - await inner_dc.context.send_activity("Cancelling") - return await inner_dc.cancel_all_dialogs() - - return None diff --git a/samples/python_django/13.core-bot/dialogs/date_resolver_dialog.py b/samples/python_django/13.core-bot/dialogs/date_resolver_dialog.py deleted file mode 100644 index 6dc683c91..000000000 --- a/samples/python_django/13.core-bot/dialogs/date_resolver_dialog.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Handle date/time resolution for booking dialog.""" -from botbuilder.core import MessageFactory -from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext -from botbuilder.dialogs.prompts import ( - DateTimePrompt, - PromptValidatorContext, - PromptOptions, - DateTimeResolution, -) -from datatypes_date_time.timex import Timex -from .cancel_and_help_dialog import CancelAndHelpDialog - - -class DateResolverDialog(CancelAndHelpDialog): - """Resolve the date""" - - def __init__(self, dialog_id: str = None): - super(DateResolverDialog, self).__init__( - dialog_id or DateResolverDialog.__name__ - ) - - self.add_dialog( - DateTimePrompt( - DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator - ) - ) - self.add_dialog( - WaterfallDialog( - WaterfallDialog.__name__ + "2", [self.initial_step, self.final_step] - ) - ) - - self.initial_dialog_id = WaterfallDialog.__name__ + "2" - - async def initial_step( - self, step_context: WaterfallStepContext - ) -> DialogTurnResult: - """Prompt for the date.""" - timex = step_context.options - - prompt_msg = "On what date would you like to travel?" - reprompt_msg = ( - "I'm sorry, for best results, please enter your travel " - "date including the month, day and year." - ) - - if timex is None: - # We were not given any date at all so prompt the user. - return await step_context.prompt( - DateTimePrompt.__name__, - PromptOptions( # pylint: disable=bad-continuation - prompt=MessageFactory.text(prompt_msg), - retry_prompt=MessageFactory.text(reprompt_msg), - ), - ) - else: - # We have a Date we just need to check it is unambiguous. - if "definite" in Timex(timex).types: - # This is essentially a "reprompt" of the data we were given up front. - return await step_context.prompt( - DateTimePrompt.__name__, PromptOptions(prompt=reprompt_msg) - ) - else: - return await step_context.next(DateTimeResolution(timex=timex)) - - async def final_step(self, step_context: WaterfallStepContext): - """Cleanup - set final return value and end dialog.""" - timex = step_context.result[0].timex - return await step_context.end_dialog(timex) - - @staticmethod - async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: - """ Validate the date provided is in proper form. """ - if prompt_context.recognized.succeeded: - timex = prompt_context.recognized.value[0].timex.split("T")[0] - - # TODO: Needs TimexProperty - return "definite" in Timex(timex).types - - return False diff --git a/samples/python_django/13.core-bot/dialogs/main_dialog.py b/samples/python_django/13.core-bot/dialogs/main_dialog.py deleted file mode 100644 index e92fe58a4..000000000 --- a/samples/python_django/13.core-bot/dialogs/main_dialog.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Main dialog. """ -from botbuilder.dialogs import ( - ComponentDialog, - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.dialogs.prompts import TextPrompt, PromptOptions -from botbuilder.core import MessageFactory -from booking_details import BookingDetails -from helpers.luis_helper import LuisHelper -from .booking_dialog import BookingDialog - - -class MainDialog(ComponentDialog): - """Main dialog. """ - - def __init__(self, configuration: dict, dialog_id: str = None): - super(MainDialog, self).__init__(dialog_id or MainDialog.__name__) - - self._configuration = configuration - - self.add_dialog(TextPrompt(TextPrompt.__name__)) - self.add_dialog(BookingDialog()) - self.add_dialog( - WaterfallDialog( - "WFDialog", [self.intro_step, self.act_step, self.final_step] - ) - ) - - self.initial_dialog_id = "WFDialog" - - async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Initial prompt.""" - return await step_context.prompt( - TextPrompt.__name__, - PromptOptions( - prompt=MessageFactory.text("What can I help you with today?") - ), - ) - - async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Use language understanding to gather details about booking.""" - - # In this sample we only have a single Intent we are concerned with. - # However, typically a scenario will have multiple different Intents - # each corresponding to starting a different child Dialog. - booking_details = ( - await LuisHelper.execute_luis_query( - self._configuration, step_context.context - ) - if step_context.result is not None - else BookingDetails() - ) - - # Run the BookingDialog giving it whatever details we have from the - # model. The dialog will prompt to find out the remaining details. - return await step_context.begin_dialog(BookingDialog.__name__, booking_details) - - async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - """Complete dialog. - At this step, with details from the user, display the completed - flight booking to the user. - """ - # If the child dialog ("BookingDialog") was cancelled or the user failed - # to confirm, the Result here will be null. - if step_context.result is not None: - result = step_context.result - - # Now we have all the booking details call the booking service. - # If the call to the booking service was successful tell the user. - # time_property = Timex(result.travel_date) - # travel_date_msg = time_property.to_natural_language(datetime.now()) - msg = ( - f"I have you booked to {result.destination} from" - f" {result.origin} on {result.travel_date}." - ) - await step_context.context.send_activity(MessageFactory.text(msg)) - else: - await step_context.context.send_activity(MessageFactory.text("Thank you.")) - return await step_context.end_dialog() diff --git a/samples/python_django/13.core-bot/helpers/__init__.py b/samples/python_django/13.core-bot/helpers/__init__.py deleted file mode 100644 index 1ef1e54a6..000000000 --- a/samples/python_django/13.core-bot/helpers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Helpers module.""" -from . import activity_helper, luis_helper, dialog_helper - -__all__ = ["activity_helper", "dialog_helper", "luis_helper"] diff --git a/samples/python_django/13.core-bot/helpers/activity_helper.py b/samples/python_django/13.core-bot/helpers/activity_helper.py deleted file mode 100644 index 78353902e..000000000 --- a/samples/python_django/13.core-bot/helpers/activity_helper.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Helper to create reply object.""" - -from datetime import datetime -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, -) - - -def create_activity_reply(activity: Activity, text: str = None, locale: str = None): - """Helper to create reply object.""" - return Activity( - type=ActivityTypes.message, - timestamp=datetime.utcnow(), - from_property=ChannelAccount( - id=getattr(activity.recipient, "id", None), - name=getattr(activity.recipient, "name", None), - ), - 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 "", - locale=locale or "", - attachments=[], - entities=[], - ) diff --git a/samples/python_django/13.core-bot/helpers/dialog_helper.py b/samples/python_django/13.core-bot/helpers/dialog_helper.py deleted file mode 100644 index 7c896d18c..000000000 --- a/samples/python_django/13.core-bot/helpers/dialog_helper.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Utility to run dialogs.""" -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - """Dialog Helper implementation.""" - - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): # pylint: disable=line-too-long - """Run dialog.""" - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) diff --git a/samples/python_django/13.core-bot/helpers/luis_helper.py b/samples/python_django/13.core-bot/helpers/luis_helper.py deleted file mode 100644 index 45a3ab5e5..000000000 --- a/samples/python_django/13.core-bot/helpers/luis_helper.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Helper to call LUIS service.""" -from botbuilder.ai.luis import LuisRecognizer, LuisApplication -from botbuilder.core import TurnContext - -from booking_details import BookingDetails - -# pylint: disable=line-too-long -class LuisHelper: - """LUIS helper implementation.""" - - @staticmethod - async def execute_luis_query( - configuration, turn_context: TurnContext - ) -> BookingDetails: - """Invoke LUIS service to perform prediction/evaluation of utterance.""" - booking_details = BookingDetails() - - # pylint:disable=broad-except - try: - luis_application = LuisApplication( - configuration.LUIS_APP_ID, - configuration.LUIS_API_KEY, - configuration.LUIS_API_HOST_NAME, - ) - - recognizer = LuisRecognizer(luis_application) - recognizer_result = await recognizer.recognize(turn_context) - - if recognizer_result.intents: - intent = sorted( - recognizer_result.intents, - key=recognizer_result.intents.get, - reverse=True, - )[:1][0] - if intent == "Book_flight": - # We need to get the result from the LUIS JSON which at every level returns an array. - to_entities = recognizer_result.entities.get("$instance", {}).get( - "To", [] - ) - if to_entities: - booking_details.destination = to_entities[0]["text"] - from_entities = recognizer_result.entities.get("$instance", {}).get( - "From", [] - ) - if from_entities: - booking_details.origin = from_entities[0]["text"] - - # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. - # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. - date_entities = recognizer_result.entities.get("$instance", {}).get( - "datetime", [] - ) - if date_entities: - booking_details.travel_date = ( - None - ) # Set when we get a timex format - except Exception as exception: - print(exception) - - return booking_details diff --git a/samples/python_django/13.core-bot/manage.py b/samples/python_django/13.core-bot/manage.py deleted file mode 100644 index 5b6b9621b..000000000 --- a/samples/python_django/13.core-bot/manage.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Django's command-line utility for administrative tasks.""" -import os -import sys -from django.core.management.commands.runserver import Command as runserver -import config - - -def main(): - """Django's command-line utility for administrative tasks.""" - runserver.default_port = config.DefaultConfig.PORT - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bots.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/samples/python_django/13.core-bot/requirements.txt b/samples/python_django/13.core-bot/requirements.txt deleted file mode 100644 index bc7fd496e..000000000 --- a/samples/python_django/13.core-bot/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -Django>=2.2.1 -requests>=2.18.1 -botframework-connector>=4.4.0.b1 -botbuilder-schema>=4.4.0.b1 -botbuilder-core>=4.4.0.b1 -botbuilder-dialogs>=4.4.0.b1 -botbuilder-ai>=4.4.0.b1 -datatypes-date-time>=1.0.0.a1 -azure-cognitiveservices-language-luis>=0.2.0 \ No newline at end of file From c411e940578720ec499eaac08ad04f716f9eec34 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Fri, 13 Dec 2019 10:51:57 -0800 Subject: [PATCH 112/616] adding the space back --- .../botbuilder/core/teams/teams_activity_extensions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py index 14b7546ea..1a9fd36bb 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -1,6 +1,7 @@ from botbuilder.schema import Activity from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo + def teams_get_channel_id(activity: Activity) -> str: if not activity: return None From 1efa62b166be92553635e3cf1ad20f761ea5a26d Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 11:24:19 -0800 Subject: [PATCH 113/616] return invoke response from process_activity --- .../botbuilder/core/bot_framework_adapter.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a13607d83..7309bdbef 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -38,6 +38,7 @@ from .bot_adapter import BotAdapter from .turn_context import TurnContext from .user_token_provider import UserTokenProvider +from .invoke_response import InvokeResponse from .conversation_reference_extension import get_continuation_activity USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" @@ -263,11 +264,17 @@ async def process_activity(self, req, auth_header: str, logic: Callable): teams_channel_data["tenant"]["id"] ) - pipeline_result = await self.run_pipeline(context, logic) + await self.run_pipeline(context, logic) - return pipeline_result or context.turn_state.get( - BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access - ) + if activity.type == ActivityTypes.invoke: + invoke_response = context.turn_state.get( + BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access + ) + if invoke_response is None: + return InvokeResponse(status=501) + return invoke_response.value + + return None async def authenticate_request( self, request: Activity, auth_header: str @@ -287,7 +294,7 @@ async def authenticate_request( ) if not claims.is_authenticated: - raise Exception("Unauthorized Access. Request is not authorized") + raise PermissionError("Unauthorized Access. Request is not authorized") return claims From 1015d99713235177214671e70e426b3e0675c2c5 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Thu, 12 Dec 2019 12:29:56 -0800 Subject: [PATCH 114/616] adding link unfurling bot --- scenarios/link-unfurling/README.md | 30 ++++++ scenarios/link-unfurling/app.py | 92 ++++++++++++++++++ scenarios/link-unfurling/bots/__init__.py | 6 ++ .../link-unfurling/bots/link_unfurling_bot.py | 57 +++++++++++ scenarios/link-unfurling/config.py | 13 +++ scenarios/link-unfurling/requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 67 +++++++++++++ .../teams_app_manifest/manifest.zip | Bin 0 -> 2461 bytes .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes 10 files changed, 267 insertions(+) create mode 100644 scenarios/link-unfurling/README.md create mode 100644 scenarios/link-unfurling/app.py create mode 100644 scenarios/link-unfurling/bots/__init__.py create mode 100644 scenarios/link-unfurling/bots/link_unfurling_bot.py create mode 100644 scenarios/link-unfurling/config.py create mode 100644 scenarios/link-unfurling/requirements.txt create mode 100644 scenarios/link-unfurling/teams_app_manifest/color.png create mode 100644 scenarios/link-unfurling/teams_app_manifest/manifest.json create mode 100644 scenarios/link-unfurling/teams_app_manifest/manifest.zip create mode 100644 scenarios/link-unfurling/teams_app_manifest/outline.png diff --git a/scenarios/link-unfurling/README.md b/scenarios/link-unfurling/README.md new file mode 100644 index 000000000..39f77916c --- /dev/null +++ b/scenarios/link-unfurling/README.md @@ -0,0 +1,30 @@ +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/link-unfurling/app.py b/scenarios/link-unfurling/app.py new file mode 100644 index 000000000..608452d8f --- /dev/null +++ b/scenarios/link-unfurling/app.py @@ -0,0 +1,92 @@ +# 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 LinkUnfurlingBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = LinkUnfurlingBot() + +# 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/link-unfurling/bots/__init__.py b/scenarios/link-unfurling/bots/__init__.py new file mode 100644 index 000000000..7dc2c44a9 --- /dev/null +++ b/scenarios/link-unfurling/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .link_unfurling_bot import LinkUnfurlingBot + +__all__ = ["LinkUnfurlingBot"] diff --git a/scenarios/link-unfurling/bots/link_unfurling_bot.py b/scenarios/link-unfurling/bots/link_unfurling_bot.py new file mode 100644 index 000000000..1c1888375 --- /dev/null +++ b/scenarios/link-unfurling/bots/link_unfurling_bot.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment +from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +class LinkUnfurlingBot(TeamsActivityHandler): + async def on_teams_app_based_link_query(self, turn_context: TurnContext, query: AppBasedLinkQuery): + hero_card = ThumbnailCard( + title="Thumnnail card", + text=query.url, + images=[ + CardImage( + url="https://raw.githubusercontent.com/microsoft/botframework-sdk/master/icon.png" + ) + ] + ) + attachments = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card) + result = MessagingExtensionResult( + attachment_layout="list", + type="result", + attachments=[attachments] + ) + return MessagingExtensionResponse(compose_extension=result) + + async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): + if query.command_id == "searchQuery": + card = HeroCard( + title="This is a Link Unfurling Sample", + subtitle="It will unfurl links from *.botframework.com", + text="This sample demonstrates how to handle link unfurling in Teams. Please review the readme for more information." + ) + attachment = Attachment( + content_type=CardFactory.content_types.hero_card, + content=card + ) + msg_ext_atc = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=attachment + ) + msg_ext_res = MessagingExtensionResult( + attachment_layout="list", + type="result", + attachments=[msg_ext_atc] + ) + response = MessagingExtensionResponse( + compose_extension=msg_ext_res + ) + + return response + + raise NotImplementedError(f"Invalid command: {query.command_id}") \ No newline at end of file diff --git a/scenarios/link-unfurling/config.py b/scenarios/link-unfurling/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/link-unfurling/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/link-unfurling/requirements.txt b/scenarios/link-unfurling/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/link-unfurling/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/link-unfurling/teams_app_manifest/color.png b/scenarios/link-unfurling/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.teams.sample.linkunfurling", + "developer": { + "name": "Link Unfurling", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Link Unfurling", + "full": "Link Unfurling" + }, + "description": { + "short": "Link Unfurling", + "full": "Link Unfurling" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ "personal", "team" ] + } + ], + "composeExtensions": [ + { + "botId": "<>", + "commands": [ + { + "id": "searchQuery", + "context": [ "compose", "commandBox" ], + "description": "Test command to run query", + "title": "Search", + "type": "query", + "parameters": [ + { + "name": "searchQuery", + "title": "Search Query", + "description": "Your search query", + "inputType": "text" + } + ] + } + ], + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "microsoft.com", + "github.com", + "linkedin.com", + "bing.com" + ] + } + } + ] + } + ] +} diff --git a/scenarios/link-unfurling/teams_app_manifest/manifest.zip b/scenarios/link-unfurling/teams_app_manifest/manifest.zip new file mode 100644 index 0000000000000000000000000000000000000000..aaedf42c4f052fa16794c7dd224681d5c9c3a4e6 GIT binary patch literal 2461 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 literal 0 HcmV?d00001 diff --git a/scenarios/link-unfurling/teams_app_manifest/outline.png b/scenarios/link-unfurling/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Fri, 13 Dec 2019 13:02:15 -0800 Subject: [PATCH 115/616] updating app.py --- scenarios/link-unfurling/app.py | 57 ++++++++++++++------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/scenarios/link-unfurling/app.py b/scenarios/link-unfurling/app.py index 608452d8f..5be8bb376 100644 --- a/scenarios/link-unfurling/app.py +++ b/scenarios/link-unfurling/app.py @@ -1,36 +1,29 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio +import json import sys from datetime import datetime -from types import MethodType -from flask import Flask, request, Response +from aiohttp import web +from aiohttp.web import Request, Response, json_response + from botbuilder.core import ( BotFrameworkAdapterSettings, TurnContext, BotFrameworkAdapter, ) -from botbuilder.schema import Activity, ActivityTypes +from botbuilder.schema import Activity, ActivityTypes from bots import LinkUnfurlingBot +from config import DefaultConfig -# Create the loop and Flask app -LOOP = asyncio.get_event_loop() -APP = Flask(__name__, instance_relative_config=True) -APP.config.from_object("config.DefaultConfig") +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"]) +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) ADAPTER = BotFrameworkAdapter(SETTINGS) - # Catch-all for errors. -async def on_error( # pylint: disable=unused-argument - self, context: TurnContext, error: Exception -): +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. @@ -52,41 +45,39 @@ async def on_error( # pylint: disable=unused-argument 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 = MethodType(on_error, ADAPTER) +ADAPTER.on_turn_error = on_error # Create the Bot BOT = LinkUnfurlingBot() -# Listen for incoming requests on /api/messages.s -@APP.route("/api/messages", methods=["POST"]) -def messages(): +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: # Main bot message handler. - if "application/json" in request.headers["Content-Type"]: - body = request.json + if "application/json" in req.headers["Content-Type"]: + body = await req.json() else: return Response(status=415) activity = Activity().deserialize(body) - auth_header = ( - request.headers["Authorization"] if "Authorization" in request.headers else "" - ) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" try: - task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, BOT.on_turn) - ) - LOOP.run_until_complete(task) + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) return Response(status=201) except Exception as exception: raise exception +APP = web.Application() +APP.router.add_post("/api/messages", messages) if __name__ == "__main__": try: - APP.run(debug=False, port=APP.config["PORT"]) # nosec debug - except Exception as exception: - raise exception + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error From ff83d094246fee1556949aa52767b8ed42a439db Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 14:54:35 -0800 Subject: [PATCH 116/616] add task module fetch scenario (#508) --- scenarios/task-module/app.py | 93 ++++++++++++++++++ scenarios/task-module/bots/__init__.py | 6 ++ .../task-module/bots/teams_task_module_bot.py | 90 +++++++++++++++++ scenarios/task-module/config.py | 15 +++ scenarios/task-module/requirements.txt | 2 + .../teams_app_manifest/icon-color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/icon-outline.png | Bin 0 -> 383 bytes .../teams_app_manifest/manifest.json | 42 ++++++++ 8 files changed, 248 insertions(+) create mode 100644 scenarios/task-module/app.py create mode 100644 scenarios/task-module/bots/__init__.py create mode 100644 scenarios/task-module/bots/teams_task_module_bot.py create mode 100644 scenarios/task-module/config.py create mode 100644 scenarios/task-module/requirements.txt create mode 100644 scenarios/task-module/teams_app_manifest/icon-color.png create mode 100644 scenarios/task-module/teams_app_manifest/icon-outline.png create mode 100644 scenarios/task-module/teams_app_manifest/manifest.json diff --git a/scenarios/task-module/app.py b/scenarios/task-module/app.py new file mode 100644 index 000000000..4fa136703 --- /dev/null +++ b/scenarios/task-module/app.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes +from bots import TaskModuleBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = TaskModuleBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + invoke_response = await ADAPTER.process_activity( + activity, auth_header, BOT.on_turn + ) + if invoke_response: + return json_response( + data=invoke_response.body, status=invoke_response.status + ) + return Response(status=201) + except PermissionError: + return Response(status=401) + except Exception: + return Response(status=500) + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/task-module/bots/__init__.py b/scenarios/task-module/bots/__init__.py new file mode 100644 index 000000000..464ebfcd1 --- /dev/null +++ b/scenarios/task-module/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .teams_task_module_bot import TaskModuleBot + +__all__ = ["TaskModuleBot"] diff --git a/scenarios/task-module/bots/teams_task_module_bot.py b/scenarios/task-module/bots/teams_task_module_bot.py new file mode 100644 index 000000000..be0e8bf08 --- /dev/null +++ b/scenarios/task-module/bots/teams_task_module_bot.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corp. All rights reserved. +# Licensed under the MIT License. + +import json +from typing import List +import random +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ( + ChannelAccount, + HeroCard, + CardAction, + CardImage, + Attachment, +) +from botbuilder.schema.teams import ( + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionResult, + TaskModuleResponse, + TaskModuleResponseBase, + TaskModuleContinueResponse, + TaskModuleMessageResponse, + TaskModuleTaskInfo, + TaskModuleRequest, +) +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo +from botbuilder.azure import CosmosDbPartitionedStorage +from botbuilder.core.teams.teams_helper import deserializer_helper + +class TaskModuleBot(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + reply = MessageFactory.attachment(self._get_task_module_hero_card()) + await turn_context.send_activity(reply) + + def _get_task_module_hero_card(self) -> Attachment: + task_module_action = CardAction( + type="invoke", + title="Adaptive Card", + value={"type": "task/fetch", "data": "adaptivecard"}, + ) + card = HeroCard( + title="Task Module Invocation from Hero Card", + subtitle="This is a hero card with a Task Module Action button. Click the button to show an Adaptive Card within a Task Module.", + buttons=[task_module_action], + ) + return CardFactory.hero_card(card) + + async def on_teams_task_module_fetch( + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ) -> TaskModuleResponse: + reply = MessageFactory.text( + f"OnTeamsTaskModuleFetchAsync TaskModuleRequest: {json.dumps(task_module_request.data)}" + ) + await turn_context.send_activity(reply) + + # base_response = TaskModuleResponseBase(type='continue') + card = CardFactory.adaptive_card( + { + "version": "1.0.0", + "type": "AdaptiveCard", + "body": [ + {"type": "TextBlock", "text": "Enter Text Here",}, + { + "type": "Input.Text", + "id": "usertext", + "placeholder": "add some text and submit", + "IsMultiline": "true", + }, + ], + "actions": [{"type": "Action.Submit", "title": "Submit",}], + } + ) + + task_info = TaskModuleTaskInfo( + card=card, title="Adaptive Card: Inputs", height=200, width=400 + ) + continue_response = TaskModuleContinueResponse(type="continue", value=task_info) + return TaskModuleResponse(task=continue_response) + + async def on_teams_task_module_submit( + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ) -> TaskModuleResponse: + reply = MessageFactory.text( + f"on_teams_messaging_extension_submit_action_activity MessagingExtensionAction: {json.dumps(task_module_request.data)}" + ) + await turn_context.send_activity(reply) + + message_response = TaskModuleMessageResponse(type="message", value="Thanks!") + return TaskModuleResponse(task=message_response) diff --git a/scenarios/task-module/config.py b/scenarios/task-module/config.py new file mode 100644 index 000000000..9496963d3 --- /dev/null +++ b/scenarios/task-module/config.py @@ -0,0 +1,15 @@ +#!/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/task-module/requirements.txt b/scenarios/task-module/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/task-module/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/task-module/teams_app_manifest/icon-color.png b/scenarios/task-module/teams_app_manifest/icon-color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZPx$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z>", + "packageName": "com.microsoft.teams.samples", + "developer": { + "name": "Microsoft", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Task Module", + "full": "Simple Task Module" + }, + "description": { + "short": "Test Task Module Scenario", + "full": "Simple Task Module Scenario Test" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "personal", + "team", + "groupchat" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ] +} \ No newline at end of file From 0b34d0e366ffbe619ed675d2f281b48f006b6981 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 13 Dec 2019 15:11:51 -0800 Subject: [PATCH 117/616] [Teams] action based messaging extension scenario (#503) * action based messaging extension scenario * cleanup and app.py fixes --- .../action-based-messaging-extension/app.py | 89 +++++++++++++++++ .../bots/__init__.py | 6 ++ .../teams_messaging_extensions_action_bot.py | 92 ++++++++++++++++++ .../config.py | 13 +++ .../requirements.txt | 2 + .../teams_app_manifest/icon-color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/icon-outline.png | Bin 0 -> 383 bytes .../teams_app_manifest/manifest.json | 78 +++++++++++++++ 8 files changed, 280 insertions(+) create mode 100644 scenarios/action-based-messaging-extension/app.py create mode 100644 scenarios/action-based-messaging-extension/bots/__init__.py create mode 100644 scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py create mode 100644 scenarios/action-based-messaging-extension/config.py create mode 100644 scenarios/action-based-messaging-extension/requirements.txt create mode 100644 scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png create mode 100644 scenarios/action-based-messaging-extension/teams_app_manifest/icon-outline.png create mode 100644 scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json diff --git a/scenarios/action-based-messaging-extension/app.py b/scenarios/action-based-messaging-extension/app.py new file mode 100644 index 000000000..4643ee6af --- /dev/null +++ b/scenarios/action-based-messaging-extension/app.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes +from bots import TeamsMessagingExtensionsActionBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = TeamsMessagingExtensionsActionBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + invoke_response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if invoke_response: + return json_response(data=invoke_response.body, status=invoke_response.status) + return Response(status=201) + except PermissionError: + return Response(status=401) + except Exception: + return Response(status=500) + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/action-based-messaging-extension/bots/__init__.py b/scenarios/action-based-messaging-extension/bots/__init__.py new file mode 100644 index 000000000..daea6bcda --- /dev/null +++ b/scenarios/action-based-messaging-extension/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .teams_messaging_extensions_action_bot import TeamsMessagingExtensionsActionBot + +__all__ = ["TeamsMessagingExtensionsActionBot"] diff --git a/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py b/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py new file mode 100644 index 000000000..aea850e2a --- /dev/null +++ b/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corp. All rights reserved. +# Licensed under the MIT License. + +from typing import List +import random +from botbuilder.core import ( + CardFactory, + MessageFactory, + TurnContext, + UserState, + ConversationState, + PrivateConversationState, +) +from botbuilder.schema import ChannelAccount, HeroCard, CardAction, CardImage +from botbuilder.schema.teams import ( + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionResult, +) +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo +from botbuilder.azure import CosmosDbPartitionedStorage + + +class TeamsMessagingExtensionsActionBot(TeamsActivityHandler): + async def on_teams_messaging_extension_submit_action_dispatch( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + if action.command_id == "createCard": + return await self.create_card_command(turn_context, action) + elif action.command_id == "shareMessage": + return await self.share_message_command(turn_context, action) + + async def create_card_command( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + title = action.data["title"] + subTitle = action.data["subTitle"] + text = action.data["text"] + + card = HeroCard(title=title, subtitle=subTitle, text=text) + cardAttachment = CardFactory.hero_card(card) + attachment = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=cardAttachment, + ) + attachments = [attachment] + + extension_result = MessagingExtensionResult( + attachment_layout="list", type="result", attachments=attachments + ) + return MessagingExtensionActionResponse(compose_extension=extension_result) + + async def share_message_command( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + # The user has chosen to share a message by choosing the 'Share Message' context menu command. + + # TODO: .user is None + title = "Shared Message" # f'{action.message_payload.from_property.user.display_name} orignally sent this message:' + text = action.message_payload.body.content + card = HeroCard(title=title, text=text) + + if not action.message_payload.attachments is None: + # This sample does not add the MessagePayload Attachments. This is left as an + # exercise for the user. + card.subtitle = ( + f"({len(action.message_payload.attachments)} Attachments not included)" + ) + + # This Messaging Extension example allows the user to check a box to include an image with the + # shared message. This demonstrates sending custom parameters along with the message payload. + include_image = action.data["includeImage"] + if include_image == "true": + image = CardImage( + url="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + ) + card.images = [image] + + cardAttachment = CardFactory.hero_card(card) + attachment = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=cardAttachment, + ) + attachments = [attachment] + + extension_result = MessagingExtensionResult( + attachment_layout="list", type="result", attachments=attachments + ) + return MessagingExtensionActionResponse(compose_extension=extension_result) diff --git a/scenarios/action-based-messaging-extension/config.py b/scenarios/action-based-messaging-extension/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/action-based-messaging-extension/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/action-based-messaging-extension/requirements.txt b/scenarios/action-based-messaging-extension/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/action-based-messaging-extension/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png b/scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZPx$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Fri, 13 Dec 2019 15:19:03 -0800 Subject: [PATCH 118/616] adding search extension --- .../README.md | 30 +++ .../search-based-messaging-extension/app.py | 83 +++++++++ .../bots/__init__.py | 6 + .../bots/search_based_messaging_extension.py | 175 ++++++++++++++++++ .../config.py | 13 ++ .../requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 49 +++++ .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes 9 files changed, 358 insertions(+) create mode 100644 scenarios/search-based-messaging-extension/README.md create mode 100644 scenarios/search-based-messaging-extension/app.py create mode 100644 scenarios/search-based-messaging-extension/bots/__init__.py create mode 100644 scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py create mode 100644 scenarios/search-based-messaging-extension/config.py create mode 100644 scenarios/search-based-messaging-extension/requirements.txt create mode 100644 scenarios/search-based-messaging-extension/teams_app_manifest/color.png create mode 100644 scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json create mode 100644 scenarios/search-based-messaging-extension/teams_app_manifest/outline.png diff --git a/scenarios/search-based-messaging-extension/README.md b/scenarios/search-based-messaging-extension/README.md new file mode 100644 index 000000000..39f77916c --- /dev/null +++ b/scenarios/search-based-messaging-extension/README.md @@ -0,0 +1,30 @@ +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/search-based-messaging-extension/app.py b/scenarios/search-based-messaging-extension/app.py new file mode 100644 index 000000000..4b0440729 --- /dev/null +++ b/scenarios/search-based-messaging-extension/app.py @@ -0,0 +1,83 @@ +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response + +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) + +from botbuilder.schema import Activity, ActivityTypes +from bots import SearchBasedMessagingExtension +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = SearchBasedMessagingExtension() + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + except Exception as exception: + raise exception + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/search-based-messaging-extension/bots/__init__.py b/scenarios/search-based-messaging-extension/bots/__init__.py new file mode 100644 index 000000000..d35ade2a7 --- /dev/null +++ b/scenarios/search-based-messaging-extension/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .search_based_messaging_extension import SearchBasedMessagingExtension + +__all__ = ["SearchBasedMessagingExtension"] diff --git a/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py b/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py new file mode 100644 index 000000000..ff576fd85 --- /dev/null +++ b/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment, CardAction +from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +from typing import List +import requests + +class SearchBasedMessagingExtension(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + await turn_context.send_activities(MessageFactory.text(f"Echo: {turn_context.activity.text}")) + + async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): + search_query = str(query.parameters[0].value) + response = requests.get(f"http://registry.npmjs.com/-/v1/search",params={"text":search_query}) + data = response.json() + + attachments = [] + + for obj in data["objects"]: + hero_card = HeroCard( + title=obj["package"]["name"], + tap=CardAction( + type="invoke", + value=obj["package"] + ), + preview=[CardImage(url=obj["package"]["links"]["npm"])] + ) + + attachment = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=HeroCard(title=obj["package"]["name"]), + preview=CardFactory.hero_card(hero_card) + ) + attachments.append(attachment) + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type="result", + attachment_layout="list", + attachments=attachments + ) + ) + + + + async def on_teams_messaging_extension_select_item(self, turn_context: TurnContext, query) -> MessagingExtensionResponse: + hero_card = HeroCard( + title=query["name"], + subtitle=query["description"], + buttons=[ + CardAction( + type="openUrl", + value=query["links"]["npm"] + ) + ] + ) + attachment = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card + ) + + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type="result", + attachment_layout="list", + attachments=[attachment] + ) + ) + + def _create_messaging_extension_result(self, attachments: List[MessagingExtensionAttachment]) -> MessagingExtensionResult: + return MessagingExtensionResult( + type="result", + attachment_layout="list", + attachments=attachments + ) + + def _create_search_result_attachment(self, search_query: str) -> MessagingExtensionAttachment: + card_text = f"You said {search_query}" + bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + + button = CardAction( + type="openUrl", + title="Click for more Information", + value="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + ) + + images = [CardImage(url=bf_logo)] + buttons = [button] + + hero_card = HeroCard( + title="You searched for:", + text=card_text, + images=images, + buttons=buttons + ) + + return MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card, + preview=CardFactory.hero_card(hero_card) + ) + + def _create_dummy_search_result_attachment(self) -> MessagingExtensionAttachment: + card_text = "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + + button = CardAction( + type = "openUrl", + title = "Click for more Information", + value = "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + ) + + images = [CardImage(url=bf_logo)] + + buttons = [button] + + + hero_card = HeroCard( + title="Learn more about Teams:", + text=card_text, images=images, + buttons=buttons + ) + + preview = HeroCard( + title="Learn more about Teams:", + text=card_text, + images=images + ) + + return MessagingExtensionAttachment( + content_type = CardFactory.content_types.hero_card, + content = hero_card, + preview = CardFactory.hero_card(preview) + ) + + def _create_select_items_result_attachment(self, search_query: str) -> MessagingExtensionAttachment: + card_text = f"You said {search_query}" + bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + + buttons = CardAction( + type="openUrl", + title="Click for more Information", + value="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + ) + + images = [CardImage(url=bf_logo)] + buttons = [buttons] + + select_item_tap = CardAction( + type="invoke", + value={"query": search_query} + ) + + hero_card = HeroCard( + title="You searched for:", + text=card_text, + images=images, + buttons=buttons + ) + + preview = HeroCard( + title=card_text, + text=card_text, + images=images, + tap=select_item_tap + ) + + return MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card, + preview=CardFactory.hero_card(preview) + ) \ No newline at end of file diff --git a/scenarios/search-based-messaging-extension/config.py b/scenarios/search-based-messaging-extension/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/search-based-messaging-extension/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/search-based-messaging-extension/requirements.txt b/scenarios/search-based-messaging-extension/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/search-based-messaging-extension/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/search-based-messaging-extension/teams_app_manifest/color.png b/scenarios/search-based-messaging-extension/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.microsoft.teams.samples.searchExtension", + "developer": { + "name": "Microsoft Corp", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "name": { + "short": "search-extension-settings", + "full": "Microsoft Teams V4 Search Messaging Extension Bot and settings" + }, + "description": { + "short": "Microsoft Teams V4 Search Messaging Extension Bot and settings", + "full": "Sample Search Messaging Extension Bot using V4 Bot Builder SDK and V4 Microsoft Teams Extension SDK" + }, + "icons": { + "outline": "icon-outline.png", + "color": "icon-color.png" + }, + "accentColor": "#abcdef", + "composeExtensions": [ + { + "botId": "<>", + "canUpdateConfiguration": true, + "commands": [ + { + "id": "searchQuery", + "context": [ "compose", "commandBox" ], + "description": "Test command to run query", + "title": "Search", + "type": "query", + "parameters": [ + { + "name": "searchQuery", + "title": "Search Query", + "description": "Your search query", + "inputType": "text" + } + ] + } + ] + } + ] + } \ No newline at end of file diff --git a/scenarios/search-based-messaging-extension/teams_app_manifest/outline.png b/scenarios/search-based-messaging-extension/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Fri, 13 Dec 2019 16:25:53 -0800 Subject: [PATCH 119/616] Tests for skill handler and added missing dependency --- .../tests/skills/test_skill_handler.py | 363 ++++++++++++++++++ .../tests/test_bot_framework_http_client.py | 8 + libraries/botframework-connector/setup.py | 1 + 3 files changed, 372 insertions(+) create mode 100644 libraries/botbuilder-core/tests/skills/test_skill_handler.py create mode 100644 libraries/botbuilder-core/tests/test_bot_framework_http_client.py diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py new file mode 100644 index 000000000..9ecdb93a8 --- /dev/null +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -0,0 +1,363 @@ +import hashlib +import json +from uuid import uuid4 +from asyncio import Future +from typing import Dict, List + +import aiounittest +from unittest.mock import Mock, MagicMock +from botbuilder.core import TurnContext, BotActionNotImplementedError +from botbuilder.core.skills import ConversationIdFactoryBase, SkillHandler +from botbuilder.schema import ( + Activity, + ActivityTypes, + AttachmentData, + ChannelAccount, + ConversationAccount, + ConversationParameters, + ConversationsResult, + ConversationResourceResponse, + ConversationReference, + PagedMembersResult, + ResourceResponse, + Transcript, +) +from botframework.connector.auth import ( + AuthenticationConfiguration, + AuthenticationConstants, + ClaimsIdentity, +) + + +class ConversationIdFactoryForTest(ConversationIdFactoryBase): + def __init__(self): + self._conversation_refs: Dict[str, str] = {} + + async def create_skill_conversation_id( + self, conversation_reference: ConversationReference + ) -> str: + cr_json = json.dumps(conversation_reference.serialize()) + + key = hashlib.md5( + f"{conversation_reference.conversation.id}{conversation_reference.service_url}".encode() + ).hexdigest() + + if key not in self._conversation_refs: + self._conversation_refs[key] = cr_json + + return key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + conversation_reference = ConversationReference().deserialize( + json.loads(self._conversation_refs[skill_conversation_id]) + ) + return conversation_reference + + async def delete_conversation_reference(self, skill_conversation_id: str): + raise NotImplementedError() + + +class SkillHandlerInstanceForTests(SkillHandler): + async def test_on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + return await self.on_get_conversations(claims_identity, continuation_token) + + async def test_on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + return await self.on_create_conversation(claims_identity, parameters) + + async def test_on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + return await self.on_send_to_conversation( + claims_identity, conversation_id, activity + ) + + async def test_on_send_conversation_history( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + transcript: Transcript, + ) -> ResourceResponse: + return await self.on_send_conversation_history( + claims_identity, conversation_id, transcript + ) + + async def test_on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + return await self.on_update_activity( + claims_identity, conversation_id, activity_id, activity + ) + + async def test_on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + return await self.on_reply_to_activity( + claims_identity, conversation_id, activity_id, activity + ) + + async def test_on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + return await self.on_delete_activity( + claims_identity, conversation_id, activity_id + ) + + async def test_on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + return await self.on_get_conversation_members(claims_identity, conversation_id) + + async def test_on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + return await self.on_get_conversation_paged_members( + claims_identity, conversation_id, page_size, continuation_token + ) + + async def test_on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + return await self.on_delete_conversation_member( + claims_identity, conversation_id, member_id + ) + + async def test_on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + return await self.on_get_activity_members( + claims_identity, conversation_id, activity_id + ) + + async def test_on_upload_attachment( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + attachment_upload: AttachmentData, + ) -> ResourceResponse: + return await self.on_upload_attachment( + claims_identity, conversation_id, attachment_upload + ) + + +class TestSkillHandler(aiounittest.AsyncTestCase): + @classmethod + def setUpClass(cls): + bot_id = str(uuid4()) + skill_id = str(uuid4()) + + cls._test_id_factory = ConversationIdFactoryForTest() + + cls._claims_identity = ClaimsIdentity({}, False) + + cls._claims_identity.claims[AuthenticationConstants.AUDIENCE_CLAIM] = bot_id + cls._claims_identity.claims[AuthenticationConstants.APP_ID_CLAIM] = skill_id + cls._claims_identity.claims[ + AuthenticationConstants.SERVICE_URL_CLAIM + ] = "http://testbot.com/api/messages" + cls._conversation_reference = ConversationReference( + conversation=ConversationAccount(id=str(uuid4())), + service_url="http://testbot.com/api/messages", + ) + + def create_skill_handler_for_testing(self, adapter) -> SkillHandlerInstanceForTests: + return SkillHandlerInstanceForTests( + adapter, + Mock(), + self._test_id_factory, + Mock(), + AuthenticationConfiguration(), + ) + + async def test_on_send_to_conversation(self): + self._conversation_id = await self._test_id_factory.create_skill_conversation_id( + self._conversation_reference + ) + + mock_adapter = Mock() + mock_adapter.continue_conversation = MagicMock(return_value=Future()) + mock_adapter.continue_conversation.return_value.set_result(Mock()) + + sut = self.create_skill_handler_for_testing(mock_adapter) + + activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) + TurnContext.apply_conversation_reference(activity, self._conversation_reference) + + await sut.test_on_send_to_conversation( + self._claims_identity, self._conversation_id, activity + ) + + args, kwargs = mock_adapter.continue_conversation.call_args_list[0] + + assert isinstance(args[0], ConversationReference) + assert callable(args[1]) + assert isinstance(kwargs["claims_identity"], ClaimsIdentity) + + async def test_on_reply_to_activity(self): + self._conversation_id = await self._test_id_factory.create_skill_conversation_id( + self._conversation_reference + ) + + mock_adapter = Mock() + mock_adapter.continue_conversation = MagicMock(return_value=Future()) + mock_adapter.continue_conversation.return_value.set_result(Mock()) + + sut = self.create_skill_handler_for_testing(mock_adapter) + + activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) + activity_id = str(uuid4()) + TurnContext.apply_conversation_reference(activity, self._conversation_reference) + + await sut.test_on_reply_to_activity( + self._claims_identity, self._conversation_id, activity_id, activity + ) + + args, kwargs = mock_adapter.continue_conversation.call_args_list[0] + + assert isinstance(args[0], ConversationReference) + assert callable(args[1]) + assert isinstance(kwargs["claims_identity"], ClaimsIdentity) + + async def test_on_update_activity(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + + activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) + activity_id = str(uuid4()) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_update_activity( + self._claims_identity, self._conversation_id, activity_id, activity + ) + + async def test_on_delete_activity(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + activity_id = str(uuid4()) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_delete_activity( + self._claims_identity, self._conversation_id, activity_id + ) + + async def test_on_get_activity_members(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + activity_id = str(uuid4()) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_get_activity_members( + self._claims_identity, self._conversation_id, activity_id + ) + + async def test_on_create_conversation(self): + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + conversation_parameters = ConversationParameters() + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_create_conversation( + self._claims_identity, conversation_parameters + ) + + async def test_on_get_conversations(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_get_conversations( + self._claims_identity, self._conversation_id + ) + + async def test_on_get_conversation_members(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_get_conversation_members( + self._claims_identity, self._conversation_id + ) + + async def test_on_get_conversation_paged_members(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_get_conversation_paged_members( + self._claims_identity, self._conversation_id + ) + + async def test_on_delete_conversation_member(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + member_id = str(uuid4()) + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_delete_conversation_member( + self._claims_identity, self._conversation_id, member_id + ) + + async def test_on_send_conversation_history(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + transcript = Transcript() + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_send_conversation_history( + self._claims_identity, self._conversation_id, transcript + ) + + async def test_on_upload_attachment(self): + self._conversation_id = "" + + mock_adapter = Mock() + + sut = self.create_skill_handler_for_testing(mock_adapter) + attachment_data = AttachmentData() + + with self.assertRaises(BotActionNotImplementedError): + await sut.test_on_upload_attachment( + self._claims_identity, self._conversation_id, attachment_data + ) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_http_client.py b/libraries/botbuilder-core/tests/test_bot_framework_http_client.py new file mode 100644 index 000000000..b2b5894d2 --- /dev/null +++ b/libraries/botbuilder-core/tests/test_bot_framework_http_client.py @@ -0,0 +1,8 @@ +import aiounittest +from botbuilder.core import BotFrameworkHttpClient + + +class TestBotFrameworkHttpClient(aiounittest.AsyncTestCase): + async def test_should_create_connector_client(self): + with self.assertRaises(TypeError): + BotFrameworkHttpClient(None) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index c7cf11edc..b447c4294 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -11,6 +11,7 @@ "cryptography==2.8.0", "PyJWT==1.5.3", "botbuilder-schema>=4.4.0b1", + "adal==1.2.1", ] root = os.path.abspath(os.path.dirname(__file__)) From 273cc3a674d16a427dc8dd32c860aa9ddcdf6cbc Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 13 Dec 2019 16:28:29 -0800 Subject: [PATCH 120/616] pylint: Tests for skill handler and added missing dependency --- libraries/botbuilder-core/tests/skills/test_skill_handler.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 9ecdb93a8..4b496066e 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -157,6 +157,10 @@ async def test_on_upload_attachment( ) +# pylint: disable=invalid-name +# pylint: disable=attribute-defined-outside-init + + class TestSkillHandler(aiounittest.AsyncTestCase): @classmethod def setUpClass(cls): From 406f4c90e30f44693f855c262ad28543dc141429 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 13 Dec 2019 16:29:13 -0800 Subject: [PATCH 121/616] pylint: Tests for skill handler and added missing dependency --- libraries/botbuilder-core/tests/skills/test_skill_handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 4b496066e..cf0de8570 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -4,8 +4,9 @@ from asyncio import Future from typing import Dict, List -import aiounittest from unittest.mock import Mock, MagicMock +import aiounittest + from botbuilder.core import TurnContext, BotActionNotImplementedError from botbuilder.core.skills import ConversationIdFactoryBase, SkillHandler from botbuilder.schema import ( From a1a4ed6aa029bc90e9ec408d7952a9aa13e18334 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Fri, 13 Dec 2019 16:31:36 -0800 Subject: [PATCH 122/616] cleaning up names (#510) * cleaning up names * black updates * adding copyright * adding types * black updates * removing unneeded serialization cast * black --- .../botbuilder/core/activity_handler.py | 4 +- .../core/auto_save_state_middleware.py | 3 + .../botbuilder/core/bot_state_set.py | 3 + ...tp_channel_service_exception_middleware.py | 3 + .../core/private_conversation_state.py | 3 + .../botbuilder/core/show_typing_middleware.py | 3 + .../core/teams/teams_activity_extensions.py | 3 + .../core/teams/teams_activity_handler.py | 59 +++++++------ .../botbuilder/core/teams/teams_helper.py | 3 + .../teams/test_teams_activity_handler.py | 84 ++++++++----------- .../tests/teams/test_teams_extension.py | 3 + .../tests/teams/test_teams_helper.py | 3 + .../botbuilder/testing/storage_base_tests.py | 3 + samples/01.console-echo/bot.py | 3 + scenarios/link-unfurling/app.py | 3 + scenarios/message-reactions/activity_log.py | 3 + 16 files changed, 106 insertions(+), 80 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index fed53bb45..40c06d91e 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -33,7 +33,7 @@ async def on_turn(self, turn_context: TurnContext): elif turn_context.activity.type == ActivityTypes.event: await self.on_event_activity(turn_context) elif turn_context.activity.type == ActivityTypes.end_of_conversation: - await self.on_end_of_conversation(turn_context) + await self.on_end_of_conversation_activity(turn_context) else: await self.on_unrecognized_activity_type(turn_context) @@ -106,7 +106,7 @@ async def on_event( # pylint: disable=unused-argument ): return - async def on_end_of_conversation( # pylint: disable=unused-argument + async def on_end_of_conversation_activity( # pylint: disable=unused-argument self, turn_context: TurnContext ): return diff --git a/libraries/botbuilder-core/botbuilder/core/auto_save_state_middleware.py b/libraries/botbuilder-core/botbuilder/core/auto_save_state_middleware.py index 561adab27..c137d59b9 100644 --- a/libraries/botbuilder-core/botbuilder/core/auto_save_state_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/auto_save_state_middleware.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Awaitable, Callable, List, Union from .bot_state import BotState diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state_set.py b/libraries/botbuilder-core/botbuilder/core/bot_state_set.py index 8a86aaba0..99016af48 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state_set.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state_set.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from asyncio import wait from typing import List from .bot_state import BotState diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py index d1c6f77e6..2d7069492 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from aiohttp.web import ( middleware, HTTPNotImplemented, diff --git a/libraries/botbuilder-core/botbuilder/core/private_conversation_state.py b/libraries/botbuilder-core/botbuilder/core/private_conversation_state.py index 6b13bc5f5..137d57b0a 100644 --- a/libraries/botbuilder-core/botbuilder/core/private_conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/private_conversation_state.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .bot_state import BotState from .turn_context import TurnContext from .storage import Storage diff --git a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py index 42846b086..ea10e3fac 100644 --- a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import time from functools import wraps from typing import Awaitable, Callable diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py index 1a9fd36bb..23d907e09 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from botbuilder.schema import Activity from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 174f7e9b8..841a41f0e 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -58,7 +58,7 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: not turn_context.activity.name and turn_context.activity.channel_id == Channels.ms_teams ): - return await self.on_teams_card_action_invoke_activity(turn_context) + return await self.on_teams_card_action_invoke(turn_context) if turn_context.activity.name == "signin/verifyState": await self.on_teams_signin_verify_state(turn_context) @@ -174,7 +174,7 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: except _InvokeResponseException as err: return err.create_invoke_response() - async def on_teams_card_action_invoke_activity( + async def on_teams_card_action_invoke( self, turn_context: TurnContext ) -> InvokeResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) @@ -188,13 +188,13 @@ async def on_teams_file_consent( file_consent_card_response: FileConsentCardResponse, ) -> InvokeResponse: if file_consent_card_response.action == "accept": - await self.on_teams_file_consent_accept_activity( + await self.on_teams_file_consent_accept( turn_context, file_consent_card_response ) return self._create_invoke_response() if file_consent_card_response.action == "decline": - await self.on_teams_file_consent_decline_activity( + await self.on_teams_file_consent_decline( turn_context, file_consent_card_response ) return self._create_invoke_response() @@ -204,14 +204,14 @@ async def on_teams_file_consent( f"{file_consent_card_response.action} is not a supported Action.", ) - async def on_teams_file_consent_accept_activity( # pylint: disable=unused-argument + async def on_teams_file_consent_accept( # pylint: disable=unused-argument self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, ): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argument + async def on_teams_file_consent_decline( # pylint: disable=unused-argument self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, @@ -242,17 +242,17 @@ async def on_teams_messaging_extension_submit_action_dispatch( self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: if not action.bot_message_preview_action: - return await self.on_teams_messaging_extension_submit_action_activity( + return await self.on_teams_messaging_extension_submit_action( turn_context, action ) if action.bot_message_preview_action == "edit": - return await self.on_teams_messaging_extension_bot_message_preview_edit_activity( + return await self.on_teams_messaging_extension_bot_message_preview_edit( turn_context, action ) if action.bot_message_preview_action == "send": - return await self.on_teams_messaging_extension_bot_message_preview_send_activity( + return await self.on_teams_messaging_extension_bot_message_preview_send( turn_context, action ) @@ -261,17 +261,17 @@ async def on_teams_messaging_extension_submit_action_dispatch( body=f"{action.bot_message_preview_action} is not a supported BotMessagePreviewAction", ) - async def on_teams_messaging_extension_bot_message_preview_edit_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, action + async def on_teams_messaging_extension_bot_message_preview_edit( # pylint: disable=unused-argument + self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_bot_message_preview_send_activity( # pylint: disable=unused-argument - self, turn_context: TurnContext, action + async def on_teams_messaging_extension_bot_message_preview_send( # pylint: disable=unused-argument + self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - async def on_teams_messaging_extension_submit_action_activity( # pylint: disable=unused-argument + async def on_teams_messaging_extension_submit_action( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) @@ -313,12 +313,12 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): turn_context.activity.channel_data ) if turn_context.activity.members_added: - return await self.on_teams_members_added_dispatch_activity( + return await self.on_teams_members_added_dispatch( turn_context.activity.members_added, channel_data.team, turn_context ) if turn_context.activity.members_removed: - return await self.on_teams_members_removed_dispatch_activity( + return await self.on_teams_members_removed_dispatch( turn_context.activity.members_removed, channel_data.team, turn_context, @@ -326,28 +326,27 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): if channel_data: if channel_data.event_type == "channelCreated": - return await self.on_teams_channel_created_activity( + return await self.on_teams_channel_created( ChannelInfo().deserialize(channel_data.channel), channel_data.team, turn_context, ) if channel_data.event_type == "channelDeleted": - return await self.on_teams_channel_deleted_activity( + return await self.on_teams_channel_deleted( channel_data.channel, channel_data.team, turn_context ) if channel_data.event_type == "channelRenamed": - return await self.on_teams_channel_renamed_activity( + return await self.on_teams_channel_renamed( channel_data.channel, channel_data.team, turn_context ) if channel_data.event_type == "teamRenamed": return await self.on_teams_team_renamed_activity( channel_data.team, turn_context ) - return await super().on_conversation_update_activity(turn_context) return await super().on_conversation_update_activity(turn_context) - async def on_teams_channel_created_activity( # pylint: disable=unused-argument + async def on_teams_channel_created( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): return @@ -357,7 +356,7 @@ async def on_teams_team_renamed_activity( # pylint: disable=unused-argument ): return - async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-argument + async def on_teams_members_added_dispatch( # pylint: disable=unused-argument self, members_added: [ChannelAccount], team_info: TeamInfo, @@ -387,11 +386,11 @@ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-ar ) team_members_added.append(new_teams_channel_account) - return await self.on_teams_members_added_activity( + return await self.on_teams_members_added( team_members_added, team_info, turn_context ) - async def on_teams_members_added_activity( # pylint: disable=unused-argument + async def on_teams_members_added( # pylint: disable=unused-argument self, teams_members_added: [TeamsChannelAccount], team_info: TeamInfo, @@ -405,7 +404,7 @@ async def on_teams_members_added_activity( # pylint: disable=unused-argument teams_members_added, turn_context ) - async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument + async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument self, members_removed: [ChannelAccount], team_info: TeamInfo, @@ -421,11 +420,9 @@ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused- TeamsChannelAccount().deserialize(new_account_json) ) - return await self.on_teams_members_removed_activity( - teams_members_removed, turn_context - ) + return await self.on_teams_members_removed(teams_members_removed, turn_context) - async def on_teams_members_removed_activity( + async def on_teams_members_removed( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): members_removed = [ @@ -434,12 +431,12 @@ async def on_teams_members_removed_activity( ] return await super().on_members_removed_activity(members_removed, turn_context) - async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument + async def on_teams_channel_deleted( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): return # Task.CompleteTask - async def on_teams_channel_renamed_activity( # pylint: disable=unused-argument + async def on_teams_channel_renamed( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): return # Task.CompleteTask diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index f9e8c65e8..4fd3c8ed4 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from inspect import getmembers from typing import Type from enum import Enum diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index d9eabcb68..8de03909c 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -35,11 +35,11 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): self.record.append("on_conversation_update_activity") return await super().on_conversation_update_activity(turn_context) - async def on_teams_members_removed_activity( + async def on_teams_members_removed( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): - self.record.append("on_teams_members_removed_activity") - return await super().on_teams_members_removed_activity( + self.record.append("on_teams_members_removed") + return await super().on_teams_members_removed( teams_members_removed, turn_context ) @@ -59,27 +59,27 @@ async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) - async def on_teams_channel_created_activity( + async def on_teams_channel_created( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - self.record.append("on_teams_channel_created_activity") - return await super().on_teams_channel_created_activity( + self.record.append("on_teams_channel_created") + return await super().on_teams_channel_created( channel_info, team_info, turn_context ) - async def on_teams_channel_renamed_activity( + async def on_teams_channel_renamed( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - self.record.append("on_teams_channel_renamed_activity") - return await super().on_teams_channel_renamed_activity( + self.record.append("on_teams_channel_renamed") + return await super().on_teams_channel_renamed( channel_info, team_info, turn_context ) - async def on_teams_channel_deleted_activity( + async def on_teams_channel_deleted( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - self.record.append("on_teams_channel_deleted_activity") - return await super().on_teams_channel_renamed_activity( + self.record.append("on_teams_channel_deleted") + return await super().on_teams_channel_renamed( channel_info, team_info, turn_context ) @@ -107,23 +107,23 @@ async def on_teams_file_consent( turn_context, file_consent_card_response ) - async def on_teams_file_consent_accept_activity( + async def on_teams_file_consent_accept( self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, ): - self.record.append("on_teams_file_consent_accept_activity") - return await super().on_teams_file_consent_accept_activity( + self.record.append("on_teams_file_consent_accept") + return await super().on_teams_file_consent_accept( turn_context, file_consent_card_response ) - async def on_teams_file_consent_decline_activity( + async def on_teams_file_consent_decline( self, turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, ): - self.record.append("on_teams_file_consent_decline_activity") - return await super().on_teams_file_consent_decline_activity( + self.record.append("on_teams_file_consent_decline") + return await super().on_teams_file_consent_decline( turn_context, file_consent_card_response ) @@ -153,31 +153,27 @@ async def on_teams_messaging_extension_submit_action_dispatch( turn_context, action ) - async def on_teams_messaging_extension_submit_action_activity( + async def on_teams_messaging_extension_submit_action( self, turn_context: TurnContext, action: MessagingExtensionAction ): - self.record.append("on_teams_messaging_extension_submit_action_activity") - return await super().on_teams_messaging_extension_submit_action_activity( + self.record.append("on_teams_messaging_extension_submit_action") + return await super().on_teams_messaging_extension_submit_action( turn_context, action ) - async def on_teams_messaging_extension_bot_message_preview_edit_activity( + async def on_teams_messaging_extension_bot_message_preview_edit( self, turn_context: TurnContext, action: MessagingExtensionAction ): - self.record.append( - "on_teams_messaging_extension_bot_message_preview_edit_activity" - ) - return await super().on_teams_messaging_extension_bot_message_preview_edit_activity( + self.record.append("on_teams_messaging_extension_bot_message_preview_edit") + return await super().on_teams_messaging_extension_bot_message_preview_edit( turn_context, action ) - async def on_teams_messaging_extension_bot_message_preview_send_activity( + async def on_teams_messaging_extension_bot_message_preview_send( self, turn_context: TurnContext, action: MessagingExtensionAction ): - self.record.append( - "on_teams_messaging_extension_bot_message_preview_send_activity" - ) - return await super().on_teams_messaging_extension_bot_message_preview_send_activity( + self.record.append("on_teams_messaging_extension_bot_message_preview_send") + return await super().on_teams_messaging_extension_bot_message_preview_send( turn_context, action ) @@ -268,7 +264,7 @@ async def test_on_teams_channel_created_activity(self): # Assert assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_channel_created_activity" + assert bot.record[1] == "on_teams_channel_created" async def test_on_teams_channel_renamed_activity(self): # arrange @@ -290,7 +286,7 @@ async def test_on_teams_channel_renamed_activity(self): # Assert assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_channel_renamed_activity" + assert bot.record[1] == "on_teams_channel_renamed" async def test_on_teams_channel_deleted_activity(self): # arrange @@ -312,7 +308,7 @@ async def test_on_teams_channel_deleted_activity(self): # Assert assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_channel_deleted_activity" + assert bot.record[1] == "on_teams_channel_deleted" async def test_on_teams_team_renamed_activity(self): # arrange @@ -361,7 +357,7 @@ async def test_on_teams_members_removed_activity(self): # Assert assert len(bot.record) == 2 assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_members_removed_activity" + assert bot.record[1] == "on_teams_members_removed" async def test_on_signin_verify_state(self): # arrange @@ -396,7 +392,7 @@ async def test_on_file_consent_accept_activity(self): assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_file_consent" - assert bot.record[2] == "on_teams_file_consent_accept_activity" + assert bot.record[2] == "on_teams_file_consent_accept" async def test_on_file_consent_decline_activity(self): # Arrange @@ -416,7 +412,7 @@ async def test_on_file_consent_decline_activity(self): assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_file_consent" - assert bot.record[2] == "on_teams_file_consent_decline_activity" + assert bot.record[2] == "on_teams_file_consent_decline" async def test_on_file_consent_bad_action_activity(self): # Arrange @@ -502,10 +498,7 @@ async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(se assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert ( - bot.record[2] - == "on_teams_messaging_extension_bot_message_preview_edit_activity" - ) + assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_edit" async def test_on_teams_messaging_extension_bot_message_send_activity(self): # Arrange @@ -533,10 +526,7 @@ async def test_on_teams_messaging_extension_bot_message_send_activity(self): assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert ( - bot.record[2] - == "on_teams_messaging_extension_bot_message_preview_send_activity" - ) + assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_send" async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( self, @@ -566,7 +556,7 @@ async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_submit_action_activity" + assert bot.record[2] == "on_teams_messaging_extension_submit_action" async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty_string( self, @@ -596,7 +586,7 @@ async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty assert len(bot.record) == 3 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_submit_action_activity" + assert bot.record[2] == "on_teams_messaging_extension_submit_action" async def test_on_teams_messaging_extension_fetch_task(self): # Arrange diff --git a/libraries/botbuilder-core/tests/teams/test_teams_extension.py b/libraries/botbuilder-core/tests/teams/test_teams_extension.py index 8ac96a491..e4ebb4449 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_extension.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_extension.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import aiounittest from botbuilder.schema import Activity diff --git a/libraries/botbuilder-core/tests/teams/test_teams_helper.py b/libraries/botbuilder-core/tests/teams/test_teams_helper.py index 21f074a73..782973f0a 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_helper.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_helper.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import aiounittest from botbuilder.core.teams.teams_helper import deserializer_helper diff --git a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py index defa5040f..afd17b905 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py +++ b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + """ Base tests that all storage providers should implement in their own tests. They handle the storage-based assertions, internally. diff --git a/samples/01.console-echo/bot.py b/samples/01.console-echo/bot.py index 226f0d963..d54eb0394 100644 --- a/samples/01.console-echo/bot.py +++ b/samples/01.console-echo/bot.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from sys import exit diff --git a/scenarios/link-unfurling/app.py b/scenarios/link-unfurling/app.py index 5be8bb376..8bbf90feb 100644 --- a/scenarios/link-unfurling/app.py +++ b/scenarios/link-unfurling/app.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import json import sys from datetime import datetime diff --git a/scenarios/message-reactions/activity_log.py b/scenarios/message-reactions/activity_log.py index e4dbe477a..0ef5a837d 100644 --- a/scenarios/message-reactions/activity_log.py +++ b/scenarios/message-reactions/activity_log.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from botbuilder.core import MemoryStorage from botbuilder.schema import Activity From 3465df203ee6152d50585cb283572f6e7ba0c7df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 16 Dec 2019 13:24:46 -0800 Subject: [PATCH 123/616] Updated Classifiers tags in setup.py (#516) (#517) * Updated Classifiers tags in setup.py * Pinned regex version, remove this dependency when fixed in recognizers --- libraries/botbuilder-ai/setup.py | 2 +- libraries/botbuilder-applicationinsights/setup.py | 2 +- libraries/botbuilder-azure/setup.py | 2 +- libraries/botbuilder-core/setup.py | 2 +- libraries/botbuilder-dialogs/setup.py | 3 ++- libraries/botbuilder-schema/setup.py | 2 +- libraries/botbuilder-testing/setup.py | 2 +- libraries/botframework-connector/setup.py | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index 1297ac6dc..bd8de2101 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -48,7 +48,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 5dffcffc5..d84118224 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -57,7 +57,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index 4f0fe9630..c9a1f3bb7 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -41,7 +41,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index 3ecc37f53..cd49fcaa0 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -44,7 +44,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index 1df9df6c2..ded5b6df3 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -5,6 +5,7 @@ from setuptools import setup REQUIRES = [ + "regex<=2019.08.19", "recognizers-text-date-time>=1.0.2a1", "recognizers-text-number-with-unit>=1.0.2a1", "recognizers-text-number>=1.0.2a1", @@ -51,7 +52,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botbuilder-schema/setup.py b/libraries/botbuilder-schema/setup.py index 422295912..764ff7116 100644 --- a/libraries/botbuilder-schema/setup.py +++ b/libraries/botbuilder-schema/setup.py @@ -31,7 +31,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py index ab0a3f572..aae2d6dd2 100644 --- a/libraries/botbuilder-testing/setup.py +++ b/libraries/botbuilder-testing/setup.py @@ -41,7 +41,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index b447c4294..7ad8af60a 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -49,7 +49,7 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Topic :: Scientific/Engineering :: Artificial Intelligence", ], ) From 1d48723cb64718a0ab5c5bc90808886e3724b3a8 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 16 Dec 2019 13:31:12 -0800 Subject: [PATCH 124/616] [Teams] Draft action based fetch task scenario (#512) * add crude action based fetch task scenario * add ExampleData and some cleanup of preview extension * black cleanup --- .../app.py | 93 +++++++ .../bots/__init__.py | 8 + ...ased_messaging_extension_fetch_task_bot.py | 229 ++++++++++++++++++ .../config.py | 13 + .../example_data.py | 18 ++ .../requirements.txt | 2 + .../teams_app_manifest/icon-color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/icon-outline.png | Bin 0 -> 383 bytes .../teams_app_manifest/manifest.json | 67 +++++ 9 files changed, 430 insertions(+) create mode 100644 scenarios/action-based-messaging-extension-fetch-task/app.py create mode 100644 scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py create mode 100644 scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py create mode 100644 scenarios/action-based-messaging-extension-fetch-task/config.py create mode 100644 scenarios/action-based-messaging-extension-fetch-task/example_data.py create mode 100644 scenarios/action-based-messaging-extension-fetch-task/requirements.txt create mode 100644 scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png create mode 100644 scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-outline.png create mode 100644 scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/manifest.json diff --git a/scenarios/action-based-messaging-extension-fetch-task/app.py b/scenarios/action-based-messaging-extension-fetch-task/app.py new file mode 100644 index 000000000..103c5f31a --- /dev/null +++ b/scenarios/action-based-messaging-extension-fetch-task/app.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes +from bots import ActionBasedMessagingExtensionFetchTaskBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = ActionBasedMessagingExtensionFetchTaskBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + invoke_response = await ADAPTER.process_activity( + activity, auth_header, BOT.on_turn + ) + if invoke_response: + return json_response( + data=invoke_response.body, status=invoke_response.status + ) + return Response(status=201) + except PermissionError: + return Response(status=401) + except Exception: + return Response(status=500) + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py b/scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py new file mode 100644 index 000000000..fe9caf948 --- /dev/null +++ b/scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .action_based_messaging_extension_fetch_task_bot import ( + ActionBasedMessagingExtensionFetchTaskBot, +) + +__all__ = ["ActionBasedMessagingExtensionFetchTaskBot"] diff --git a/scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py b/scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py new file mode 100644 index 000000000..9e9c13fa9 --- /dev/null +++ b/scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py @@ -0,0 +1,229 @@ +# Copyright (c) Microsoft Corp. All rights reserved. +# Licensed under the MIT License. + +from typing import List +import random +from botbuilder.core import ( + CardFactory, + MessageFactory, + TurnContext, +) +from botbuilder.schema import Attachment +from botbuilder.schema.teams import ( + MessagingExtensionAction, + MessagingExtensionActionResponse, + TaskModuleContinueResponse, + MessagingExtensionResult, + TaskModuleTaskInfo, +) +from botbuilder.core.teams import TeamsActivityHandler +from example_data import ExampleData + + +class ActionBasedMessagingExtensionFetchTaskBot(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + value = turn_context.activity.value + if value is not None: + # This was a message from the card. + answer = value["Answer"] + choices = value["Choices"] + reply = MessageFactory.text( + f"{turn_context.activity.from_property.name} answered '{answer}' and chose '{choices}'." + ) + await turn_context.send_activity(reply) + else: + # This is a regular text message. + reply = MessageFactory.text( + "Hello from ActionBasedMessagingExtensionFetchTaskBot." + ) + await turn_context.send_activity(reply) + + async def on_teams_messaging_extension_fetch_task( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + card = self._create_adaptive_card_editor() + task_info = TaskModuleTaskInfo( + card=card, height=450, title="Task Module Fetch Example", width=500 + ) + continue_response = TaskModuleContinueResponse(type="continue", value=task_info) + return MessagingExtensionActionResponse(task=continue_response) + + async def on_teams_messaging_extension_submit_action( # pylint: disable=unused-argument + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + question = action.data["Question"] + multi_select = action.data["MultiSelect"] + option1 = action.data["Option1"] + option2 = action.data["Option2"] + option3 = action.data["Option3"] + preview_card = self._create_adaptive_card_preview( + user_text=question, + is_multi_select=multi_select, + option1=option1, + option2=option2, + option3=option3, + ) + + extension_result = MessagingExtensionResult( + type="botMessagePreview", + activity_preview=MessageFactory.attachment(preview_card), + ) + return MessagingExtensionActionResponse(compose_extension=extension_result) + + async def on_teams_messaging_extension_bot_message_preview_edit( # pylint: disable=unused-argument + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + activity_preview = action.bot_activity_preview[0] + content = activity_preview.attachments[0].content + data = self._get_example_data(content) + card = self._create_adaptive_card_editor( + data.question, + data.is_multi_select, + data.option1, + data.option2, + data.option3, + ) + task_info = TaskModuleTaskInfo( + card=card, height=450, title="Task Module Fetch Example", width=500 + ) + continue_response = TaskModuleContinueResponse(type="continue", value=task_info) + return MessagingExtensionActionResponse(task=continue_response) + + async def on_teams_messaging_extension_bot_message_preview_send( # pylint: disable=unused-argument + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + activity_preview = action.bot_activity_preview[0] + content = activity_preview.attachments[0].content + data = self._get_example_data(content) + card = self._create_adaptive_card_preview( + data.question, + data.is_multi_select, + data.option1, + data.option2, + data.option3, + ) + message = MessageFactory.attachment(card) + await turn_context.send_activity(message) + + def _get_example_data(self, content: dict) -> ExampleData: + body = content["body"] + question = body[1]["text"] + choice_set = body[3] + multi_select = "isMultiSelect" in choice_set + option1 = choice_set["choices"][0]["value"] + option2 = choice_set["choices"][1]["value"] + option3 = choice_set["choices"][2]["value"] + return ExampleData(question, multi_select, option1, option2, option3) + + def _create_adaptive_card_editor( + self, + user_text: str = None, + is_multi_select: bool = False, + option1: str = None, + option2: str = None, + option3: str = None, + ) -> Attachment: + return CardFactory.adaptive_card( + { + "actions": [ + { + "data": {"submitLocation": "messagingExtensionFetchTask"}, + "title": "Submit", + "type": "Action.Submit", + } + ], + "body": [ + { + "text": "This is an Adaptive Card within a Task Module", + "type": "TextBlock", + "weight": "bolder", + }, + {"type": "TextBlock", "text": "Enter text for Question:"}, + { + "id": "Question", + "placeholder": "Question text here", + "type": "Input.Text", + "value": user_text, + }, + {"type": "TextBlock", "text": "Options for Question:"}, + {"type": "TextBlock", "text": "Is Multi-Select:"}, + { + "choices": [ + {"title": "True", "value": "true"}, + {"title": "False", "value": "false"}, + ], + "id": "MultiSelect", + "isMultiSelect": "false", + "style": "expanded", + "type": "Input.ChoiceSet", + "value": "true" if is_multi_select else "false", + }, + { + "id": "Option1", + "placeholder": "Option 1 here", + "type": "Input.Text", + "value": option1, + }, + { + "id": "Option2", + "placeholder": "Option 2 here", + "type": "Input.Text", + "value": option2, + }, + { + "id": "Option3", + "placeholder": "Option 3 here", + "type": "Input.Text", + "value": option3, + }, + ], + "type": "AdaptiveCard", + "version": "1.0", + } + ) + + def _create_adaptive_card_preview( + self, + user_text: str = None, + is_multi_select: bool = False, + option1: str = None, + option2: str = None, + option3: str = None, + ) -> Attachment: + return CardFactory.adaptive_card( + { + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": {"submitLocation": "messagingExtensionSubmit"}, + } + ], + "body": [ + { + "text": "Adaptive Card from Task Module", + "type": "TextBlock", + "weight": "bolder", + }, + {"text": user_text, "type": "TextBlock", "id": "Question"}, + { + "id": "Answer", + "placeholder": "Answer here...", + "type": "Input.Text", + }, + { + "choices": [ + {"title": option1, "value": option1}, + {"title": option2, "value": option2}, + {"title": option3, "value": option3}, + ], + "id": "Choices", + "isMultiSelect": is_multi_select, + "style": "expanded", + "type": "Input.ChoiceSet", + }, + ], + "type": "AdaptiveCard", + "version": "1.0", + } + ) diff --git a/scenarios/action-based-messaging-extension-fetch-task/config.py b/scenarios/action-based-messaging-extension-fetch-task/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/action-based-messaging-extension-fetch-task/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/action-based-messaging-extension-fetch-task/example_data.py b/scenarios/action-based-messaging-extension-fetch-task/example_data.py new file mode 100644 index 000000000..79dede038 --- /dev/null +++ b/scenarios/action-based-messaging-extension-fetch-task/example_data.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class ExampleData(object): + def __init__( + self, + question: str = None, + is_multi_select: bool = False, + option1: str = None, + option2: str = None, + option3: str = None, + ): + self.question = question + self.is_multi_select = is_multi_select + self.option1 = option1 + self.option2 = option2 + self.option3 = option3 diff --git a/scenarios/action-based-messaging-extension-fetch-task/requirements.txt b/scenarios/action-based-messaging-extension-fetch-task/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/action-based-messaging-extension-fetch-task/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png b/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZPx$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z>", + "packageName": "com.microsoft.teams.samples", + "developer": { + "name": "Microsoft", + "websiteUrl": "https://dev.botframework.com", + "privacyUrl": "https://privacy.microsoft.com", + "termsOfUseUrl": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright/default.aspx" + }, + "icons": { + "color": "icon-color.png", + "outline": "icon-outline.png" + }, + "name": { + "short": "Preview Messaging Extension", + "full": "Microsoft Teams Action Based Messaging Extension with Preview" + }, + "description": { + "short": "Sample demonstrating an Action Based Messaging Extension with Preview", + "full": "Sample Action Messaging Extension built with the Bot Builder SDK demonstrating Preview" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "team" + ] + } + ], + "composeExtensions": [ + { + "botId": "<>", + "canUpdateConfiguration": false, + "commands": [ + { + "id": "createWithPreview", + "type": "action", + "title": "Create Card", + "description": "Example of creating a Card", + "initialRun": false, + "fetchTask": true, + "context": [ + "commandBox", + "compose", + "message" + ], + "parameters": [ + { + "name": "param", + "title": "param", + "description": "" + } + ] + } + ] + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] +} \ No newline at end of file From 71fc00cfba57430e52bb2239138c4c03b8c000ec Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 16 Dec 2019 14:22:20 -0800 Subject: [PATCH 125/616] fix teams verify state handler in OAuthPrompt (#518) --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 843fe9e3a..49ab9624d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -278,7 +278,7 @@ async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult if OAuthPrompt._is_token_response_event(context): token = context.activity.value elif OAuthPrompt._is_teams_verification_invoke(context): - code = context.activity.value.state + code = context.activity.value["state"] try: token = await self.get_user_token(context, code) if token is not None: From f8b93a9a692a5b98411d1df82a828213cc034c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 16 Dec 2019 15:38:15 -0800 Subject: [PATCH 126/616] exporting teams from botbuilder schema (#520) (#521) * exporting teams from botbuilder schema * black:exporting teams from botbuilder schema --- libraries/botbuilder-schema/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/setup.py b/libraries/botbuilder-schema/setup.py index 764ff7116..a7fc298b2 100644 --- a/libraries/botbuilder-schema/setup.py +++ b/libraries/botbuilder-schema/setup.py @@ -24,7 +24,7 @@ long_description_content_type="text/x-rst", license="MIT", install_requires=REQUIRES, - packages=["botbuilder.schema"], + packages=["botbuilder.schema", "botbuilder.schema.teams",], include_package_data=True, classifiers=[ "Programming Language :: Python :: 3.7", From 9d8e2facfd5ab0f7697017f17bf8c66ef2cbf1a6 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Mon, 16 Dec 2019 15:54:43 -0800 Subject: [PATCH 127/616] adding teams to the setup.py (#523) --- libraries/botbuilder-core/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index cd49fcaa0..d49a5f00a 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -37,6 +37,7 @@ "botbuilder.core.inspection", "botbuilder.core.integration", "botbuilder.core.skills", + "botbuilder.core.teams", ], install_requires=REQUIRES, classifiers=[ From bc42684ed4964b19535b071c6bbc43299ebedae2 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 17 Dec 2019 10:08:17 -0800 Subject: [PATCH 128/616] adding teams to connector --- libraries/botframework-connector/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 7ad8af60a..0b612233a 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -35,6 +35,7 @@ "botframework.connector.models", "botframework.connector.aio", "botframework.connector.aio.operations_async", + "botframework.connector.teams", "botframework.connector.token_api", "botframework.connector.token_api.aio", "botframework.connector.token_api.models", From c6da323d5d37a336d70f0abaca57088591b796d2 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 17 Dec 2019 11:57:58 -0800 Subject: [PATCH 129/616] Update comments in teams_helper (#531) * change comments * black fix --- .../botbuilder/core/teams/teams_activity_handler.py | 1 - .../botbuilder-core/botbuilder/core/teams/teams_helper.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 841a41f0e..04725db13 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -412,7 +412,6 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument ): teams_members_removed = [] for member in members_removed: - # TODO: fix this new_account_json = member.serialize() if "additional_properties" in new_account_json: del new_account_json["additional_properties"] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index 4fd3c8ed4..f9d294c39 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -10,6 +10,9 @@ import botbuilder.schema as schema import botbuilder.schema.teams as teams_schema +# Optimization: The dependencies dictionary could be cached here, +# and shared between the two methods. + def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> Model: dependencies = [ @@ -27,9 +30,6 @@ def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> M return deserializer(msrest_cls.__name__, dict_to_deserialize) -# TODO consolidate these two methods - - def serializer_helper(object_to_serialize: Model) -> dict: if object_to_serialize is None: return None From e02cfc4677b10973ec65f220b67bcb33f8c50d3f Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Tue, 17 Dec 2019 13:23:12 -0800 Subject: [PATCH 130/616] Cherry-picking helpers into master (#535) * adding teams to connector * makign helpers private * removing unneeded comments * fixing linting * black updates --- libraries/botbuilder-core/botbuilder/core/teams/__init__.py | 3 --- .../botbuilder/core/teams/teams_activity_handler.py | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py index 9acc2a250..d9d4847e8 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py @@ -7,7 +7,6 @@ from .teams_activity_handler import TeamsActivityHandler from .teams_info import TeamsInfo -from .teams_helper import deserializer_helper, serializer_helper from .teams_activity_extensions import ( teams_get_channel_id, teams_get_team_info, @@ -15,11 +14,9 @@ ) __all__ = [ - "deserializer_helper", "TeamsActivityHandler", "TeamsInfo", "teams_get_channel_id", "teams_get_team_info", "teams_notify_user", - "serializer_helper", ] diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 04725db13..6b541d7ff 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -5,7 +5,6 @@ from botbuilder.schema import Activity, ActivityTypes, ChannelAccount from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter from botbuilder.core.turn_context import TurnContext -from botbuilder.core.teams.teams_helper import deserializer_helper, serializer_helper from botbuilder.core.teams.teams_info import TeamsInfo from botbuilder.schema.teams import ( AppBasedLinkQuery, @@ -23,6 +22,7 @@ TaskModuleResponse, ) from botframework.connector import Channels +from .teams_helper import deserializer_helper, serializer_helper class TeamsActivityHandler(ActivityHandler): @@ -433,12 +433,12 @@ async def on_teams_members_removed( async def on_teams_channel_deleted( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - return # Task.CompleteTask + return async def on_teams_channel_renamed( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): - return # Task.CompleteTask + return @staticmethod def _create_invoke_response(body: object = None) -> InvokeResponse: From 82ab72d33ecd6557e5184ad2068b4a6c1fabe868 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 17 Dec 2019 13:56:21 -0800 Subject: [PATCH 131/616] access _credentials from turncontext adapter when creating teams connector client --- .../botbuilder-core/botbuilder/core/teams/teams_info.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index ca1e71a43..0b9cb9471 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -73,14 +73,11 @@ async def get_members(turn_context: TurnContext) -> List[TeamsChannelAccount]: async def get_teams_connector_client( turn_context: TurnContext, ) -> TeamsConnectorClient: - connector_client = await TeamsInfo._get_connector_client(turn_context) return TeamsConnectorClient( - connector_client.config.credentials, turn_context.activity.service_url + turn_context.adapter._credentials, # pylint: disable=protected-access + turn_context.activity.service_url, ) - # TODO: should have access to adapter's credentials - # return TeamsConnectorClient(turn_context.adapter._credentials, turn_context.activity.service_url) - @staticmethod def get_team_id(turn_context: TurnContext): channel_data = TeamsChannelData(**turn_context.activity.channel_data) From e31677d69fe4967f6083cfcf775aff5bd44158dd Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 17 Dec 2019 16:38:47 -0600 Subject: [PATCH 132/616] Added botframework.connector.teams.operations to setup.py (#538) --- libraries/botframework-connector/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 0b612233a..f9d7e5bd6 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -36,6 +36,7 @@ "botframework.connector.aio", "botframework.connector.aio.operations_async", "botframework.connector.teams", + "botframework.connector.teams.operations", "botframework.connector.token_api", "botframework.connector.token_api.aio", "botframework.connector.token_api.models", From 6f4b66b4c86a6ad774a4c7542aaf2fc4abb861c2 Mon Sep 17 00:00:00 2001 From: Christopher Anderson Date: Wed, 8 Jan 2020 09:25:33 -0800 Subject: [PATCH 133/616] docs: remove preview notes - remove preview mentions now that Python is GA --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5981ef94f..3a9392f33 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ ### [Click here to find out what's new with Bot Framework](https://github.com/Microsoft/botframework/blob/master/whats-new.md#whats-new) -# Bot Framework SDK v4 for Python (Preview) +# Bot Framework SDK v4 for Python [![Build status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=431) [![roadmap badge](https://img.shields.io/badge/visit%20the-roadmap-blue.svg)](https://github.com/Microsoft/botbuilder-python/wiki/Roadmap) [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botbuilder). The Bot Framework SDK v4 enables developers to model conversation and build sophisticated bot applications using Python. This Python version is in **Preview** state and is being actively developed. +This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botbuilder). The Bot Framework SDK v4 enables developers to model conversation and build sophisticated bot applications using Python. This Python version is GA and ready for production usage. This repo is part the [Microsoft Bot Framework](https://github.com/Microsoft/botframework) - a comprehensive framework for building enterprise-grade conversational AI experiences. From 0731f78e78c01f33248d9ca9104a598222ec8806 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 8 Jan 2020 13:31:19 -0800 Subject: [PATCH 134/616] Raising PermissionError for auth failures. (#571) * Raising PermissionError for auth failures. * Black corrections --- ...tp_channel_service_exception_middleware.py | 56 +-- .../connector/auth/channel_validation.py | 278 ++++++------ .../connector/auth/emulator_validation.py | 372 ++++++++-------- .../auth/enterprise_channel_validation.py | 238 +++++----- .../auth/government_channel_validation.py | 216 ++++----- .../connector/auth/jwt_token_validation.py | 416 +++++++++--------- 6 files changed, 792 insertions(+), 784 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py index 2d7069492..7c5091121 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py @@ -1,27 +1,29 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from aiohttp.web import ( - middleware, - HTTPNotImplemented, - HTTPUnauthorized, - HTTPNotFound, - HTTPInternalServerError, -) - -from botbuilder.core import BotActionNotImplementedError - - -@middleware -async def aiohttp_error_middleware(request, handler): - try: - response = await handler(request) - return response - except BotActionNotImplementedError: - raise HTTPNotImplemented() - except PermissionError: - raise HTTPUnauthorized() - except KeyError: - raise HTTPNotFound() - except Exception: - raise HTTPInternalServerError() +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp.web import ( + middleware, + HTTPNotImplemented, + HTTPUnauthorized, + HTTPNotFound, + HTTPInternalServerError, +) + +from botbuilder.core import BotActionNotImplementedError + + +@middleware +async def aiohttp_error_middleware(request, handler): + try: + response = await handler(request) + return response + except BotActionNotImplementedError: + raise HTTPNotImplemented() + except NotImplementedError: + raise HTTPNotImplemented() + except PermissionError: + raise HTTPUnauthorized() + except KeyError: + raise HTTPNotFound() + except Exception: + raise HTTPInternalServerError() diff --git a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py index 7e9344c79..fde7f1144 100644 --- a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py @@ -1,138 +1,140 @@ -import asyncio - -from .authentication_configuration import AuthenticationConfiguration -from .verify_options import VerifyOptions -from .authentication_constants import AuthenticationConstants -from .jwt_token_extractor import JwtTokenExtractor -from .claims_identity import ClaimsIdentity -from .credential_provider import CredentialProvider - - -class ChannelValidation: - open_id_metadata_endpoint: str = None - - # This claim is ONLY used in the Channel Validation, and not in the emulator validation - SERVICE_URL_CLAIM = "serviceurl" - - # - # TO BOT FROM CHANNEL: Token validation parameters when connecting to a bot - # - TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( - issuer=[AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], - # Audience validation takes place manually in code. - audience=None, - clock_tolerance=5 * 60, - ignore_expiration=False, - ) - - @staticmethod - async def authenticate_channel_token_with_service_url( - auth_header: str, - credentials: CredentialProvider, - service_url: str, - channel_id: str, - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - """ Validate the incoming Auth Header - - Validate the incoming Auth Header as a token sent from the Bot Framework Service. - A token issued by the Bot Framework emulator will FAIL this check. - - :param auth_header: The raw HTTP header in the format: 'Bearer [longString]' - :type auth_header: str - :param credentials: The user defined set of valid credentials, such as the AppId. - :type credentials: CredentialProvider - :param service_url: Claim value that must match in the identity. - :type service_url: str - - :return: A valid ClaimsIdentity. - :raises Exception: - """ - identity = await ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) - - service_url_claim = identity.get_claim_value( - ChannelValidation.SERVICE_URL_CLAIM - ) - if service_url_claim != service_url: - # Claim must match. Not Authorized. - raise Exception("Unauthorized. service_url claim do not match.") - - return identity - - @staticmethod - async def authenticate_channel_token( - auth_header: str, - credentials: CredentialProvider, - channel_id: str, - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - """ Validate the incoming Auth Header - - Validate the incoming Auth Header as a token sent from the Bot Framework Service. - A token issued by the Bot Framework emulator will FAIL this check. - - :param auth_header: The raw HTTP header in the format: 'Bearer [longString]' - :type auth_header: str - :param credentials: The user defined set of valid credentials, such as the AppId. - :type credentials: CredentialProvider - - :return: A valid ClaimsIdentity. - :raises Exception: - """ - auth_configuration = auth_configuration or AuthenticationConfiguration() - metadata_endpoint = ( - ChannelValidation.open_id_metadata_endpoint - if ChannelValidation.open_id_metadata_endpoint - else AuthenticationConstants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL - ) - - token_extractor = JwtTokenExtractor( - ChannelValidation.TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS, - metadata_endpoint, - AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, - ) - - identity = await token_extractor.get_identity_from_auth_header( - auth_header, channel_id, auth_configuration.required_endorsements - ) - - return await ChannelValidation.validate_identity(identity, credentials) - - @staticmethod - async def validate_identity( - identity: ClaimsIdentity, credentials: CredentialProvider - ) -> ClaimsIdentity: - if not identity: - # No valid identity. Not Authorized. - raise Exception("Unauthorized. No valid identity.") - - if not identity.is_authenticated: - # The token is in some way invalid. Not Authorized. - raise Exception("Unauthorized. Is not authenticated") - - # Now check that the AppID in the claimset matches - # what we're looking for. Note that in a multi-tenant bot, this value - # comes from developer code that may be reaching out to a service, hence the - # Async validation. - - # Look for the "aud" claim, but only if issued from the Bot Framework - if ( - identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) - != AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER - ): - # The relevant Audience Claim MUST be present. Not Authorized. - raise Exception("Unauthorized. Audience Claim MUST be present.") - - # The AppId from the claim in the token must match the AppId specified by the developer. - # Note that the Bot Framework uses the Audience claim ("aud") to pass the AppID. - aud_claim = identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM) - is_valid_app_id = await asyncio.ensure_future( - credentials.is_valid_appid(aud_claim or "") - ) - if not is_valid_app_id: - # The AppId is not valid or not present. Not Authorized. - raise Exception("Unauthorized. Invalid AppId passed on token: ", aud_claim) - - return identity +import asyncio + +from .authentication_configuration import AuthenticationConfiguration +from .verify_options import VerifyOptions +from .authentication_constants import AuthenticationConstants +from .jwt_token_extractor import JwtTokenExtractor +from .claims_identity import ClaimsIdentity +from .credential_provider import CredentialProvider + + +class ChannelValidation: + open_id_metadata_endpoint: str = None + + # This claim is ONLY used in the Channel Validation, and not in the emulator validation + SERVICE_URL_CLAIM = "serviceurl" + + # + # TO BOT FROM CHANNEL: Token validation parameters when connecting to a bot + # + TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( + issuer=[AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], + # Audience validation takes place manually in code. + audience=None, + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + @staticmethod + async def authenticate_channel_token_with_service_url( + auth_header: str, + credentials: CredentialProvider, + service_url: str, + channel_id: str, + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + """ Validate the incoming Auth Header + + Validate the incoming Auth Header as a token sent from the Bot Framework Service. + A token issued by the Bot Framework emulator will FAIL this check. + + :param auth_header: The raw HTTP header in the format: 'Bearer [longString]' + :type auth_header: str + :param credentials: The user defined set of valid credentials, such as the AppId. + :type credentials: CredentialProvider + :param service_url: Claim value that must match in the identity. + :type service_url: str + + :return: A valid ClaimsIdentity. + :raises Exception: + """ + identity = await ChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration + ) + + service_url_claim = identity.get_claim_value( + ChannelValidation.SERVICE_URL_CLAIM + ) + if service_url_claim != service_url: + # Claim must match. Not Authorized. + raise PermissionError("Unauthorized. service_url claim do not match.") + + return identity + + @staticmethod + async def authenticate_channel_token( + auth_header: str, + credentials: CredentialProvider, + channel_id: str, + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + """ Validate the incoming Auth Header + + Validate the incoming Auth Header as a token sent from the Bot Framework Service. + A token issued by the Bot Framework emulator will FAIL this check. + + :param auth_header: The raw HTTP header in the format: 'Bearer [longString]' + :type auth_header: str + :param credentials: The user defined set of valid credentials, such as the AppId. + :type credentials: CredentialProvider + + :return: A valid ClaimsIdentity. + :raises Exception: + """ + auth_configuration = auth_configuration or AuthenticationConfiguration() + metadata_endpoint = ( + ChannelValidation.open_id_metadata_endpoint + if ChannelValidation.open_id_metadata_endpoint + else AuthenticationConstants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL + ) + + token_extractor = JwtTokenExtractor( + ChannelValidation.TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS, + metadata_endpoint, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements + ) + + return await ChannelValidation.validate_identity(identity, credentials) + + @staticmethod + async def validate_identity( + identity: ClaimsIdentity, credentials: CredentialProvider + ) -> ClaimsIdentity: + if not identity: + # No valid identity. Not Authorized. + raise PermissionError("Unauthorized. No valid identity.") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Unauthorized. Is not authenticated") + + # Now check that the AppID in the claimset matches + # what we're looking for. Note that in a multi-tenant bot, this value + # comes from developer code that may be reaching out to a service, hence the + # Async validation. + + # Look for the "aud" claim, but only if issued from the Bot Framework + if ( + identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) + != AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + ): + # The relevant Audience Claim MUST be present. Not Authorized. + raise PermissionError("Unauthorized. Audience Claim MUST be present.") + + # The AppId from the claim in the token must match the AppId specified by the developer. + # Note that the Bot Framework uses the Audience claim ("aud") to pass the AppID. + aud_claim = identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM) + is_valid_app_id = await asyncio.ensure_future( + credentials.is_valid_appid(aud_claim or "") + ) + if not is_valid_app_id: + # The AppId is not valid or not present. Not Authorized. + raise PermissionError( + "Unauthorized. Invalid AppId passed on token: ", aud_claim + ) + + return identity diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 1178db7bc..0e2d7fcaa 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -1,184 +1,188 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -from typing import Union - -import jwt - -from .jwt_token_extractor import JwtTokenExtractor -from .verify_options import VerifyOptions -from .authentication_constants import AuthenticationConstants -from .credential_provider import CredentialProvider -from .claims_identity import ClaimsIdentity -from .government_constants import GovernmentConstants -from .channel_provider import ChannelProvider - - -class EmulatorValidation: - APP_ID_CLAIM = "appid" - VERSION_CLAIM = "ver" - - TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( - issuer=[ - # Auth v3.1, 1.0 token - "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", - # Auth v3.1, 2.0 token - "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", - # Auth v3.2, 1.0 token - "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", - # Auth v3.2, 2.0 token - "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", - # ??? - "https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/", - # Auth for US Gov, 1.0 token - "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", - # Auth for US Gov, 2.0 token - "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", - ], - audience=None, - clock_tolerance=5 * 60, - ignore_expiration=False, - ) - - @staticmethod - def is_token_from_emulator(auth_header: str) -> bool: - """ Determines if a given Auth header is from the Bot Framework Emulator - - :param auth_header: Bearer Token, in the 'Bearer [Long String]' Format. - :type auth_header: str - - :return: True, if the token was issued by the Emulator. Otherwise, false. - """ - from .jwt_token_validation import ( # pylint: disable=import-outside-toplevel - JwtTokenValidation, - ) - - if not JwtTokenValidation.is_valid_token_format(auth_header): - return False - - bearer_token = auth_header.split(" ")[1] - - # Parse the Big Long String into an actual token. - token = jwt.decode(bearer_token, verify=False) - if not token: - return False - - # Is there an Issuer? - issuer = token["iss"] - if not issuer: - # No Issuer, means it's not from the Emulator. - return False - - # Is the token issues by a source we consider to be the emulator? - issuer_list = ( - EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS.issuer - ) - if issuer_list and not issuer in issuer_list: - # Not a Valid Issuer. This is NOT a Bot Framework Emulator Token. - return False - - # The Token is from the Bot Framework Emulator. Success! - return True - - @staticmethod - async def authenticate_emulator_token( - auth_header: str, - credentials: CredentialProvider, - channel_service_or_provider: Union[str, ChannelProvider], - channel_id: str, - ) -> ClaimsIdentity: - """ Validate the incoming Auth Header - - Validate the incoming Auth Header as a token sent from the Bot Framework Service. - A token issued by the Bot Framework emulator will FAIL this check. - - :param auth_header: The raw HTTP header in the format: 'Bearer [longString]' - :type auth_header: str - :param credentials: The user defined set of valid credentials, such as the AppId. - :type credentials: CredentialProvider - - :return: A valid ClaimsIdentity. - :raises Exception: - """ - # pylint: disable=import-outside-toplevel - from .jwt_token_validation import JwtTokenValidation - - if isinstance(channel_service_or_provider, ChannelProvider): - is_gov = channel_service_or_provider.is_government() - else: - is_gov = JwtTokenValidation.is_government(channel_service_or_provider) - - open_id_metadata = ( - GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL - if is_gov - else AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL - ) - - token_extractor = JwtTokenExtractor( - EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS, - open_id_metadata, - AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, - ) - - identity = await token_extractor.get_identity_from_auth_header( - auth_header, channel_id - ) - if not identity: - # No valid identity. Not Authorized. - raise Exception("Unauthorized. No valid identity.") - - if not identity.is_authenticated: - # The token is in some way invalid. Not Authorized. - raise Exception("Unauthorized. Is not authenticated") - - # Now check that the AppID in the claimset matches - # what we're looking for. Note that in a multi-tenant bot, this value - # comes from developer code that may be reaching out to a service, hence the - # Async validation. - version_claim = identity.get_claim_value(EmulatorValidation.VERSION_CLAIM) - if version_claim is None: - raise Exception('Unauthorized. "ver" claim is required on Emulator Tokens.') - - app_id = "" - - # The Emulator, depending on Version, sends the AppId via either the - # appid claim (Version 1) or the Authorized Party claim (Version 2). - if not version_claim or version_claim == "1.0": - # either no Version or a version of "1.0" means we should look for - # the claim in the "appid" claim. - app_id_claim = identity.get_claim_value(EmulatorValidation.APP_ID_CLAIM) - if not app_id_claim: - # No claim around AppID. Not Authorized. - raise Exception( - "Unauthorized. " - '"appid" claim is required on Emulator Token version "1.0".' - ) - - app_id = app_id_claim - elif version_claim == "2.0": - # Emulator, "2.0" puts the AppId in the "azp" claim. - app_authz_claim = identity.get_claim_value( - AuthenticationConstants.AUTHORIZED_PARTY - ) - if not app_authz_claim: - # No claim around AppID. Not Authorized. - raise Exception( - "Unauthorized. " - '"azp" claim is required on Emulator Token version "2.0".' - ) - - app_id = app_authz_claim - else: - # Unknown Version. Not Authorized. - raise Exception( - "Unauthorized. Unknown Emulator Token version ", version_claim, "." - ) - - is_valid_app_id = await asyncio.ensure_future( - credentials.is_valid_appid(app_id) - ) - if not is_valid_app_id: - raise Exception("Unauthorized. Invalid AppId passed on token: ", app_id) - - return identity +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +from typing import Union + +import jwt + +from .jwt_token_extractor import JwtTokenExtractor +from .verify_options import VerifyOptions +from .authentication_constants import AuthenticationConstants +from .credential_provider import CredentialProvider +from .claims_identity import ClaimsIdentity +from .government_constants import GovernmentConstants +from .channel_provider import ChannelProvider + + +class EmulatorValidation: + APP_ID_CLAIM = "appid" + VERSION_CLAIM = "ver" + + TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( + issuer=[ + # Auth v3.1, 1.0 token + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + # Auth v3.1, 2.0 token + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + # Auth v3.2, 1.0 token + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + # Auth v3.2, 2.0 token + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + # ??? + "https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/", + # Auth for US Gov, 1.0 token + "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", + # Auth for US Gov, 2.0 token + "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", + ], + audience=None, + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + @staticmethod + def is_token_from_emulator(auth_header: str) -> bool: + """ Determines if a given Auth header is from the Bot Framework Emulator + + :param auth_header: Bearer Token, in the 'Bearer [Long String]' Format. + :type auth_header: str + + :return: True, if the token was issued by the Emulator. Otherwise, false. + """ + from .jwt_token_validation import ( # pylint: disable=import-outside-toplevel + JwtTokenValidation, + ) + + if not JwtTokenValidation.is_valid_token_format(auth_header): + return False + + bearer_token = auth_header.split(" ")[1] + + # Parse the Big Long String into an actual token. + token = jwt.decode(bearer_token, verify=False) + if not token: + return False + + # Is there an Issuer? + issuer = token["iss"] + if not issuer: + # No Issuer, means it's not from the Emulator. + return False + + # Is the token issues by a source we consider to be the emulator? + issuer_list = ( + EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS.issuer + ) + if issuer_list and not issuer in issuer_list: + # Not a Valid Issuer. This is NOT a Bot Framework Emulator Token. + return False + + # The Token is from the Bot Framework Emulator. Success! + return True + + @staticmethod + async def authenticate_emulator_token( + auth_header: str, + credentials: CredentialProvider, + channel_service_or_provider: Union[str, ChannelProvider], + channel_id: str, + ) -> ClaimsIdentity: + """ Validate the incoming Auth Header + + Validate the incoming Auth Header as a token sent from the Bot Framework Service. + A token issued by the Bot Framework emulator will FAIL this check. + + :param auth_header: The raw HTTP header in the format: 'Bearer [longString]' + :type auth_header: str + :param credentials: The user defined set of valid credentials, such as the AppId. + :type credentials: CredentialProvider + + :return: A valid ClaimsIdentity. + :raises Exception: + """ + # pylint: disable=import-outside-toplevel + from .jwt_token_validation import JwtTokenValidation + + if isinstance(channel_service_or_provider, ChannelProvider): + is_gov = channel_service_or_provider.is_government() + else: + is_gov = JwtTokenValidation.is_government(channel_service_or_provider) + + open_id_metadata = ( + GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL + if is_gov + else AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL + ) + + token_extractor = JwtTokenExtractor( + EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS, + open_id_metadata, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id + ) + if not identity: + # No valid identity. Not Authorized. + raise PermissionError("Unauthorized. No valid identity.") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Unauthorized. Is not authenticated") + + # Now check that the AppID in the claimset matches + # what we're looking for. Note that in a multi-tenant bot, this value + # comes from developer code that may be reaching out to a service, hence the + # Async validation. + version_claim = identity.get_claim_value(EmulatorValidation.VERSION_CLAIM) + if version_claim is None: + raise PermissionError( + 'Unauthorized. "ver" claim is required on Emulator Tokens.' + ) + + app_id = "" + + # The Emulator, depending on Version, sends the AppId via either the + # appid claim (Version 1) or the Authorized Party claim (Version 2). + if not version_claim or version_claim == "1.0": + # either no Version or a version of "1.0" means we should look for + # the claim in the "appid" claim. + app_id_claim = identity.get_claim_value(EmulatorValidation.APP_ID_CLAIM) + if not app_id_claim: + # No claim around AppID. Not Authorized. + raise PermissionError( + "Unauthorized. " + '"appid" claim is required on Emulator Token version "1.0".' + ) + + app_id = app_id_claim + elif version_claim == "2.0": + # Emulator, "2.0" puts the AppId in the "azp" claim. + app_authz_claim = identity.get_claim_value( + AuthenticationConstants.AUTHORIZED_PARTY + ) + if not app_authz_claim: + # No claim around AppID. Not Authorized. + raise PermissionError( + "Unauthorized. " + '"azp" claim is required on Emulator Token version "2.0".' + ) + + app_id = app_authz_claim + else: + # Unknown Version. Not Authorized. + raise PermissionError( + "Unauthorized. Unknown Emulator Token version ", version_claim, "." + ) + + is_valid_app_id = await asyncio.ensure_future( + credentials.is_valid_appid(app_id) + ) + if not is_valid_app_id: + raise PermissionError( + "Unauthorized. Invalid AppId passed on token: ", app_id + ) + + return identity diff --git a/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py index 54eb9ab80..48a93ba5d 100644 --- a/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/enterprise_channel_validation.py @@ -1,119 +1,119 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC -from typing import Union - -from .authentication_configuration import AuthenticationConfiguration -from .authentication_constants import AuthenticationConstants -from .channel_validation import ChannelValidation -from .channel_provider import ChannelProvider -from .claims_identity import ClaimsIdentity -from .credential_provider import CredentialProvider -from .jwt_token_extractor import JwtTokenExtractor -from .verify_options import VerifyOptions - - -class EnterpriseChannelValidation(ABC): - - TO_BOT_FROM_ENTERPRISE_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( - issuer=[AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], - audience=None, - clock_tolerance=5 * 60, - ignore_expiration=False, - ) - - @staticmethod - async def authenticate_channel_token( - auth_header: str, - credentials: CredentialProvider, - channel_id: str, - channel_service_or_provider: Union[str, ChannelProvider], - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - channel_service = channel_service_or_provider - if isinstance(channel_service_or_provider, ChannelProvider): - channel_service = await channel_service_or_provider.get_channel_service() - - endpoint = ( - ChannelValidation.open_id_metadata_endpoint - if ChannelValidation.open_id_metadata_endpoint - else AuthenticationConstants.TO_BOT_FROM_ENTERPRISE_CHANNEL_OPEN_ID_METADATA_URL_FORMAT.replace( - "{channelService}", channel_service - ) - ) - token_extractor = JwtTokenExtractor( - EnterpriseChannelValidation.TO_BOT_FROM_ENTERPRISE_CHANNEL_TOKEN_VALIDATION_PARAMETERS, - endpoint, - AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, - ) - - identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header( - auth_header, channel_id, auth_configuration.required_endorsements - ) - return await EnterpriseChannelValidation.validate_identity( - identity, credentials - ) - - @staticmethod - async def authenticate_channel_token_with_service_url( - auth_header: str, - credentials: CredentialProvider, - service_url: str, - channel_id: str, - channel_service_or_provider: Union[str, ChannelProvider], - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - identity: ClaimsIdentity = await EnterpriseChannelValidation.authenticate_channel_token( - auth_header, - credentials, - channel_id, - channel_service_or_provider, - auth_configuration, - ) - - service_url_claim: str = identity.get_claim_value( - AuthenticationConstants.SERVICE_URL_CLAIM - ) - if service_url_claim != service_url: - raise Exception("Unauthorized. service_url claim do not match.") - - return identity - - @staticmethod - async def validate_identity( - identity: ClaimsIdentity, credentials: CredentialProvider - ) -> ClaimsIdentity: - if identity is None: - # No valid identity. Not Authorized. - raise Exception("Unauthorized. No valid identity.") - - if not identity.is_authenticated: - # The token is in some way invalid. Not Authorized. - raise Exception("Unauthorized. Is not authenticated.") - - # Now check that the AppID in the claim set matches - # what we're looking for. Note that in a multi-tenant bot, this value - # comes from developer code that may be reaching out to a service, hence the - # Async validation. - - # Look for the "aud" claim, but only if issued from the Bot Framework - if ( - identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) - != AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER - ): - # The relevant Audience Claim MUST be present. Not Authorized. - raise Exception("Unauthorized. Issuer claim MUST be present.") - - # The AppId from the claim in the token must match the AppId specified by the developer. - # In this case, the token is destined for the app, so we find the app ID in the audience claim. - aud_claim: str = identity.get_claim_value( - AuthenticationConstants.AUDIENCE_CLAIM - ) - if not await credentials.is_valid_appid(aud_claim or ""): - # The AppId is not valid or not present. Not Authorized. - raise Exception( - f"Unauthorized. Invalid AppId passed on token: { aud_claim }" - ) - - return identity +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from typing import Union + +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants +from .channel_validation import ChannelValidation +from .channel_provider import ChannelProvider +from .claims_identity import ClaimsIdentity +from .credential_provider import CredentialProvider +from .jwt_token_extractor import JwtTokenExtractor +from .verify_options import VerifyOptions + + +class EnterpriseChannelValidation(ABC): + + TO_BOT_FROM_ENTERPRISE_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( + issuer=[AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], + audience=None, + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + @staticmethod + async def authenticate_channel_token( + auth_header: str, + credentials: CredentialProvider, + channel_id: str, + channel_service_or_provider: Union[str, ChannelProvider], + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + channel_service = channel_service_or_provider + if isinstance(channel_service_or_provider, ChannelProvider): + channel_service = await channel_service_or_provider.get_channel_service() + + endpoint = ( + ChannelValidation.open_id_metadata_endpoint + if ChannelValidation.open_id_metadata_endpoint + else AuthenticationConstants.TO_BOT_FROM_ENTERPRISE_CHANNEL_OPEN_ID_METADATA_URL_FORMAT.replace( + "{channelService}", channel_service + ) + ) + token_extractor = JwtTokenExtractor( + EnterpriseChannelValidation.TO_BOT_FROM_ENTERPRISE_CHANNEL_TOKEN_VALIDATION_PARAMETERS, + endpoint, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements + ) + return await EnterpriseChannelValidation.validate_identity( + identity, credentials + ) + + @staticmethod + async def authenticate_channel_token_with_service_url( + auth_header: str, + credentials: CredentialProvider, + service_url: str, + channel_id: str, + channel_service_or_provider: Union[str, ChannelProvider], + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + identity: ClaimsIdentity = await EnterpriseChannelValidation.authenticate_channel_token( + auth_header, + credentials, + channel_id, + channel_service_or_provider, + auth_configuration, + ) + + service_url_claim: str = identity.get_claim_value( + AuthenticationConstants.SERVICE_URL_CLAIM + ) + if service_url_claim != service_url: + raise PermissionError("Unauthorized. service_url claim do not match.") + + return identity + + @staticmethod + async def validate_identity( + identity: ClaimsIdentity, credentials: CredentialProvider + ) -> ClaimsIdentity: + if identity is None: + # No valid identity. Not Authorized. + raise PermissionError("Unauthorized. No valid identity.") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Unauthorized. Is not authenticated.") + + # Now check that the AppID in the claim set matches + # what we're looking for. Note that in a multi-tenant bot, this value + # comes from developer code that may be reaching out to a service, hence the + # Async validation. + + # Look for the "aud" claim, but only if issued from the Bot Framework + if ( + identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) + != AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + ): + # The relevant Audience Claim MUST be present. Not Authorized. + raise PermissionError("Unauthorized. Issuer claim MUST be present.") + + # The AppId from the claim in the token must match the AppId specified by the developer. + # In this case, the token is destined for the app, so we find the app ID in the audience claim. + aud_claim: str = identity.get_claim_value( + AuthenticationConstants.AUDIENCE_CLAIM + ) + if not await credentials.is_valid_appid(aud_claim or ""): + # The AppId is not valid or not present. Not Authorized. + raise PermissionError( + f"Unauthorized. Invalid AppId passed on token: { aud_claim }" + ) + + return identity diff --git a/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py index 6bfd9e012..3c2285393 100644 --- a/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/government_channel_validation.py @@ -1,108 +1,108 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC - -from .authentication_configuration import AuthenticationConfiguration -from .authentication_constants import AuthenticationConstants -from .claims_identity import ClaimsIdentity -from .credential_provider import CredentialProvider -from .government_constants import GovernmentConstants -from .jwt_token_extractor import JwtTokenExtractor -from .verify_options import VerifyOptions - - -class GovernmentChannelValidation(ABC): - - OPEN_ID_METADATA_ENDPOINT = "" - - TO_BOT_FROM_GOVERNMENT_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( - issuer=[GovernmentConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], - audience=None, - clock_tolerance=5 * 60, - ignore_expiration=False, - ) - - @staticmethod - async def authenticate_channel_token( - auth_header: str, - credentials: CredentialProvider, - channel_id: str, - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - auth_configuration = auth_configuration or AuthenticationConfiguration() - endpoint = ( - GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT - if GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT - else GovernmentConstants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL - ) - token_extractor = JwtTokenExtractor( - GovernmentChannelValidation.TO_BOT_FROM_GOVERNMENT_CHANNEL_TOKEN_VALIDATION_PARAMETERS, - endpoint, - AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, - ) - - identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header( - auth_header, channel_id, auth_configuration.required_endorsements - ) - return await GovernmentChannelValidation.validate_identity( - identity, credentials - ) - - @staticmethod - async def authenticate_channel_token_with_service_url( - auth_header: str, - credentials: CredentialProvider, - service_url: str, - channel_id: str, - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - identity: ClaimsIdentity = await GovernmentChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) - - service_url_claim: str = identity.get_claim_value( - AuthenticationConstants.SERVICE_URL_CLAIM - ) - if service_url_claim != service_url: - raise Exception("Unauthorized. service_url claim do not match.") - - return identity - - @staticmethod - async def validate_identity( - identity: ClaimsIdentity, credentials: CredentialProvider - ) -> ClaimsIdentity: - if identity is None: - # No valid identity. Not Authorized. - raise Exception("Unauthorized. No valid identity.") - - if not identity.is_authenticated: - # The token is in some way invalid. Not Authorized. - raise Exception("Unauthorized. Is not authenticated.") - - # Now check that the AppID in the claim set matches - # what we're looking for. Note that in a multi-tenant bot, this value - # comes from developer code that may be reaching out to a service, hence the - # Async validation. - - # Look for the "aud" claim, but only if issued from the Bot Framework - if ( - identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) - != GovernmentConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER - ): - # The relevant Audience Claim MUST be present. Not Authorized. - raise Exception("Unauthorized. Issuer claim MUST be present.") - - # The AppId from the claim in the token must match the AppId specified by the developer. - # In this case, the token is destined for the app, so we find the app ID in the audience claim. - aud_claim: str = identity.get_claim_value( - AuthenticationConstants.AUDIENCE_CLAIM - ) - if not await credentials.is_valid_appid(aud_claim or ""): - # The AppId is not valid or not present. Not Authorized. - raise Exception( - f"Unauthorized. Invalid AppId passed on token: { aud_claim }" - ) - - return identity +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants +from .claims_identity import ClaimsIdentity +from .credential_provider import CredentialProvider +from .government_constants import GovernmentConstants +from .jwt_token_extractor import JwtTokenExtractor +from .verify_options import VerifyOptions + + +class GovernmentChannelValidation(ABC): + + OPEN_ID_METADATA_ENDPOINT = "" + + TO_BOT_FROM_GOVERNMENT_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( + issuer=[GovernmentConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], + audience=None, + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + @staticmethod + async def authenticate_channel_token( + auth_header: str, + credentials: CredentialProvider, + channel_id: str, + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + auth_configuration = auth_configuration or AuthenticationConfiguration() + endpoint = ( + GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT + if GovernmentChannelValidation.OPEN_ID_METADATA_ENDPOINT + else GovernmentConstants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL + ) + token_extractor = JwtTokenExtractor( + GovernmentChannelValidation.TO_BOT_FROM_GOVERNMENT_CHANNEL_TOKEN_VALIDATION_PARAMETERS, + endpoint, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + identity: ClaimsIdentity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements + ) + return await GovernmentChannelValidation.validate_identity( + identity, credentials + ) + + @staticmethod + async def authenticate_channel_token_with_service_url( + auth_header: str, + credentials: CredentialProvider, + service_url: str, + channel_id: str, + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + identity: ClaimsIdentity = await GovernmentChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration + ) + + service_url_claim: str = identity.get_claim_value( + AuthenticationConstants.SERVICE_URL_CLAIM + ) + if service_url_claim != service_url: + raise PermissionError("Unauthorized. service_url claim do not match.") + + return identity + + @staticmethod + async def validate_identity( + identity: ClaimsIdentity, credentials: CredentialProvider + ) -> ClaimsIdentity: + if identity is None: + # No valid identity. Not Authorized. + raise PermissionError("Unauthorized. No valid identity.") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Unauthorized. Is not authenticated.") + + # Now check that the AppID in the claim set matches + # what we're looking for. Note that in a multi-tenant bot, this value + # comes from developer code that may be reaching out to a service, hence the + # Async validation. + + # Look for the "aud" claim, but only if issued from the Bot Framework + if ( + identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) + != GovernmentConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + ): + # The relevant Audience Claim MUST be present. Not Authorized. + raise PermissionError("Unauthorized. Issuer claim MUST be present.") + + # The AppId from the claim in the token must match the AppId specified by the developer. + # In this case, the token is destined for the app, so we find the app ID in the audience claim. + aud_claim: str = identity.get_claim_value( + AuthenticationConstants.AUDIENCE_CLAIM + ) + if not await credentials.is_valid_appid(aud_claim or ""): + # The AppId is not valid or not present. Not Authorized. + raise PermissionError( + f"Unauthorized. Invalid AppId passed on token: { aud_claim }" + ) + + return identity diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index c4a0b26e3..70bfba050 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -1,208 +1,208 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from typing import Dict, List, Union - -from botbuilder.schema import Activity - -from .authentication_configuration import AuthenticationConfiguration -from .authentication_constants import AuthenticationConstants -from .emulator_validation import EmulatorValidation -from .enterprise_channel_validation import EnterpriseChannelValidation -from .channel_validation import ChannelValidation -from .microsoft_app_credentials import MicrosoftAppCredentials -from .credential_provider import CredentialProvider -from .claims_identity import ClaimsIdentity -from .government_constants import GovernmentConstants -from .government_channel_validation import GovernmentChannelValidation -from .skill_validation import SkillValidation -from .channel_provider import ChannelProvider - - -class JwtTokenValidation: - - # TODO remove the default value on channel_service - @staticmethod - async def authenticate_request( - activity: Activity, - auth_header: str, - credentials: CredentialProvider, - channel_service_or_provider: Union[str, ChannelProvider] = "", - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - """Authenticates the request and sets the service url in the set of trusted urls. - :param activity: The incoming Activity from the Bot Framework or the Emulator - :type activity: ~botframework.connector.models.Activity - :param auth_header: The Bearer token included as part of the request - :type auth_header: str - :param credentials: The set of valid credentials, such as the Bot Application ID - :param channel_service_or_provider: String for the channel service - :param auth_configuration: Authentication configuration - :type credentials: CredentialProvider - - :raises Exception: - """ - if not auth_header: - # No auth header was sent. We might be on the anonymous code path. - is_auth_disabled = await credentials.is_authentication_disabled() - if is_auth_disabled: - # We are on the anonymous code path. - return ClaimsIdentity({}, True) - - # No Auth Header. Auth is required. Request is not authorized. - raise Exception("Unauthorized Access. Request is not authorized") - - claims_identity = await JwtTokenValidation.validate_auth_header( - auth_header, - credentials, - channel_service_or_provider, - activity.channel_id, - activity.service_url, - auth_configuration, - ) - - # On the standard Auth path, we need to trust the URL that was incoming. - MicrosoftAppCredentials.trust_service_url(activity.service_url) - - return claims_identity - - @staticmethod - async def validate_auth_header( - auth_header: str, - credentials: CredentialProvider, - channel_service_or_provider: Union[str, ChannelProvider], - channel_id: str, - service_url: str = None, - auth_configuration: AuthenticationConfiguration = None, - ) -> ClaimsIdentity: - if not auth_header: - raise ValueError("argument auth_header is null") - - async def get_claims() -> ClaimsIdentity: - if SkillValidation.is_skill_token(auth_header): - return await SkillValidation.authenticate_channel_token( - auth_header, - credentials, - channel_service_or_provider, - channel_id, - auth_configuration, - ) - - if EmulatorValidation.is_token_from_emulator(auth_header): - return await EmulatorValidation.authenticate_emulator_token( - auth_header, credentials, channel_service_or_provider, channel_id - ) - - is_public = ( - not channel_service_or_provider - or isinstance(channel_service_or_provider, ChannelProvider) - and channel_service_or_provider.is_public_azure() - ) - is_gov = ( - isinstance(channel_service_or_provider, ChannelProvider) - and channel_service_or_provider.is_public_azure() - or isinstance(channel_service_or_provider, str) - and JwtTokenValidation.is_government(channel_service_or_provider) - ) - - # If the channel is Public Azure - if is_public: - if service_url: - return await ChannelValidation.authenticate_channel_token_with_service_url( - auth_header, - credentials, - service_url, - channel_id, - auth_configuration, - ) - - return await ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) - - if is_gov: - if service_url: - return await GovernmentChannelValidation.authenticate_channel_token_with_service_url( - auth_header, - credentials, - service_url, - channel_id, - auth_configuration, - ) - - return await GovernmentChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) - - # Otherwise use Enterprise Channel Validation - if service_url: - return await EnterpriseChannelValidation.authenticate_channel_token_with_service_url( - auth_header, - credentials, - service_url, - channel_id, - channel_service_or_provider, - auth_configuration, - ) - - return await EnterpriseChannelValidation.authenticate_channel_token( - auth_header, - credentials, - channel_id, - channel_service_or_provider, - auth_configuration, - ) - - claims = await get_claims() - - if claims: - await JwtTokenValidation.validate_claims(auth_configuration, claims.claims) - - return claims - - @staticmethod - async def validate_claims( - auth_config: AuthenticationConfiguration, claims: List[Dict] - ): - if auth_config and auth_config.claims_validator: - await auth_config.claims_validator(claims) - - @staticmethod - def is_government(channel_service: str) -> bool: - return ( - channel_service - and channel_service.lower() == GovernmentConstants.CHANNEL_SERVICE - ) - - @staticmethod - def get_app_id_from_claims(claims: Dict[str, object]) -> bool: - app_id = None - - # Depending on Version, the is either in the - # appid claim (Version 1) or the Authorized Party claim (Version 2). - token_version = claims.get(AuthenticationConstants.VERSION_CLAIM) - - if not token_version or token_version == "1.0": - # either no Version or a version of "1.0" means we should look for - # the claim in the "appid" claim. - app_id = claims.get(AuthenticationConstants.APP_ID_CLAIM) - elif token_version == "2.0": - app_id = claims.get(AuthenticationConstants.AUTHORIZED_PARTY) - - return app_id - - @staticmethod - def is_valid_token_format(auth_header: str) -> bool: - if not auth_header: - # No token. Can't be an emulator token. - return False - - parts = auth_header.split(" ") - if len(parts) != 2: - # Emulator tokens MUST have exactly 2 parts. - # If we don't have 2 parts, it's not an emulator token - return False - - auth_scheme = parts[0] - - # The scheme MUST be "Bearer" - return auth_scheme == "Bearer" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Dict, List, Union + +from botbuilder.schema import Activity + +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants +from .emulator_validation import EmulatorValidation +from .enterprise_channel_validation import EnterpriseChannelValidation +from .channel_validation import ChannelValidation +from .microsoft_app_credentials import MicrosoftAppCredentials +from .credential_provider import CredentialProvider +from .claims_identity import ClaimsIdentity +from .government_constants import GovernmentConstants +from .government_channel_validation import GovernmentChannelValidation +from .skill_validation import SkillValidation +from .channel_provider import ChannelProvider + + +class JwtTokenValidation: + + # TODO remove the default value on channel_service + @staticmethod + async def authenticate_request( + activity: Activity, + auth_header: str, + credentials: CredentialProvider, + channel_service_or_provider: Union[str, ChannelProvider] = "", + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + """Authenticates the request and sets the service url in the set of trusted urls. + :param activity: The incoming Activity from the Bot Framework or the Emulator + :type activity: ~botframework.connector.models.Activity + :param auth_header: The Bearer token included as part of the request + :type auth_header: str + :param credentials: The set of valid credentials, such as the Bot Application ID + :param channel_service_or_provider: String for the channel service + :param auth_configuration: Authentication configuration + :type credentials: CredentialProvider + + :raises Exception: + """ + if not auth_header: + # No auth header was sent. We might be on the anonymous code path. + is_auth_disabled = await credentials.is_authentication_disabled() + if is_auth_disabled: + # We are on the anonymous code path. + return ClaimsIdentity({}, True) + + # No Auth Header. Auth is required. Request is not authorized. + raise PermissionError("Unauthorized Access. Request is not authorized") + + claims_identity = await JwtTokenValidation.validate_auth_header( + auth_header, + credentials, + channel_service_or_provider, + activity.channel_id, + activity.service_url, + auth_configuration, + ) + + # On the standard Auth path, we need to trust the URL that was incoming. + MicrosoftAppCredentials.trust_service_url(activity.service_url) + + return claims_identity + + @staticmethod + async def validate_auth_header( + auth_header: str, + credentials: CredentialProvider, + channel_service_or_provider: Union[str, ChannelProvider], + channel_id: str, + service_url: str = None, + auth_configuration: AuthenticationConfiguration = None, + ) -> ClaimsIdentity: + if not auth_header: + raise ValueError("argument auth_header is null") + + async def get_claims() -> ClaimsIdentity: + if SkillValidation.is_skill_token(auth_header): + return await SkillValidation.authenticate_channel_token( + auth_header, + credentials, + channel_service_or_provider, + channel_id, + auth_configuration, + ) + + if EmulatorValidation.is_token_from_emulator(auth_header): + return await EmulatorValidation.authenticate_emulator_token( + auth_header, credentials, channel_service_or_provider, channel_id + ) + + is_public = ( + not channel_service_or_provider + or isinstance(channel_service_or_provider, ChannelProvider) + and channel_service_or_provider.is_public_azure() + ) + is_gov = ( + isinstance(channel_service_or_provider, ChannelProvider) + and channel_service_or_provider.is_public_azure() + or isinstance(channel_service_or_provider, str) + and JwtTokenValidation.is_government(channel_service_or_provider) + ) + + # If the channel is Public Azure + if is_public: + if service_url: + return await ChannelValidation.authenticate_channel_token_with_service_url( + auth_header, + credentials, + service_url, + channel_id, + auth_configuration, + ) + + return await ChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration + ) + + if is_gov: + if service_url: + return await GovernmentChannelValidation.authenticate_channel_token_with_service_url( + auth_header, + credentials, + service_url, + channel_id, + auth_configuration, + ) + + return await GovernmentChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration + ) + + # Otherwise use Enterprise Channel Validation + if service_url: + return await EnterpriseChannelValidation.authenticate_channel_token_with_service_url( + auth_header, + credentials, + service_url, + channel_id, + channel_service_or_provider, + auth_configuration, + ) + + return await EnterpriseChannelValidation.authenticate_channel_token( + auth_header, + credentials, + channel_id, + channel_service_or_provider, + auth_configuration, + ) + + claims = await get_claims() + + if claims: + await JwtTokenValidation.validate_claims(auth_configuration, claims.claims) + + return claims + + @staticmethod + async def validate_claims( + auth_config: AuthenticationConfiguration, claims: List[Dict] + ): + if auth_config and auth_config.claims_validator: + await auth_config.claims_validator(claims) + + @staticmethod + def is_government(channel_service: str) -> bool: + return ( + channel_service + and channel_service.lower() == GovernmentConstants.CHANNEL_SERVICE + ) + + @staticmethod + def get_app_id_from_claims(claims: Dict[str, object]) -> bool: + app_id = None + + # Depending on Version, the is either in the + # appid claim (Version 1) or the Authorized Party claim (Version 2). + token_version = claims.get(AuthenticationConstants.VERSION_CLAIM) + + if not token_version or token_version == "1.0": + # either no Version or a version of "1.0" means we should look for + # the claim in the "appid" claim. + app_id = claims.get(AuthenticationConstants.APP_ID_CLAIM) + elif token_version == "2.0": + app_id = claims.get(AuthenticationConstants.AUTHORIZED_PARTY) + + return app_id + + @staticmethod + def is_valid_token_format(auth_header: str) -> bool: + if not auth_header: + # No token. Can't be an emulator token. + return False + + parts = auth_header.split(" ") + if len(parts) != 2: + # Emulator tokens MUST have exactly 2 parts. + # If we don't have 2 parts, it's not an emulator token + return False + + auth_scheme = parts[0] + + # The scheme MUST be "Bearer" + return auth_scheme == "Bearer" From fbe71baea5dc63c2ee812c64804f9b9bf685cf7e Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 10 Jan 2020 11:14:18 -0800 Subject: [PATCH 135/616] Test Line break to test PR build --- .../botbuilder-testing/botbuilder/testing/dialog_test_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py index 4ead12a3e..2ca60fa07 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py +++ b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import List, Union from botbuilder.core import ( From 43cec24b9e434eeb9a61f474d900459c897f5554 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 10 Jan 2020 15:45:47 -0800 Subject: [PATCH 136/616] Update prompt.py (#591) Added ref documentation --- .../botbuilder/dialogs/prompts/prompt.py | 156 ++++++++++++++---- 1 file changed, 128 insertions(+), 28 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 0ab60ba17..09f43bdbd 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -23,22 +23,30 @@ class Prompt(Dialog): - """ Base class for all prompts.""" - + """ Prompts base class + Defines the core behavior of prompt dialogs. Extends `Dialog` base class. + + .. remarks:: + When the prompt ends, it should return an object that represents the + value that was prompted for. + Use `DialogSet.Add(Dialog)` or `ComponentDialog.AddDialog(Dialog)` to add a prompt + to a dialog set or component dialog, respectively. + Use `DialogContext.PromptAsync(string, PromptOptions, CancellationToken)` or + `DialogContext.BeginDialogAsync(string, object, CancellationToken)` to start the prompt. + If you start a prompt from a `WaterfallStep` in a `WaterfallDialog`, then the prompt + result will be available in the next step of the waterfall. + """ ATTEMPT_COUNT_KEY = "AttemptCount" persisted_options = "options" persisted_state = "state" def __init__(self, dialog_id: str, validator: object = None): - """Creates a new Prompt instance. - Parameters - ---------- - dialog_id - Unique ID of the prompt within its parent `DialogSet` or - `ComponentDialog`. - validator - (Optional) custom validator used to provide additional validation and - re-prompting logic for the prompt. + """Creates a new Prompt instance + :param dialog_id: Unique ID of the prompt within its parent :class:DialogSet or + :class:ComponentDialog. + :type dialog_id: str + :param validator: Optional custom validator used to provide additional validation and re-prompting logic for the prompt. + :type validator: object """ super(Prompt, self).__init__(dialog_id) @@ -47,6 +55,20 @@ def __init__(self, dialog_id: str, validator: object = None): async def begin_dialog( self, dialog_context: DialogContext, options: object = None ) -> DialogTurnResult: + """ Starts a prompt dialog. + Called when a prompt dialog is pushed onto the dialog stack and is being activated. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:DialogContext + :param options: Optional, additional information to pass to the prompt being started. + :type options: object + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + + .. remarks:: + If the task is successful, the result indicates whether the prompt is still active + after the turn has been processed by the prompt. + """ if not dialog_context: raise TypeError("Prompt(): dc cannot be None.") if not isinstance(options, PromptOptions): @@ -74,6 +96,21 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext): + """ Continues a dialog. + Called when a prompt dialog is the active dialog and the user replied with a new activity. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:DialogContext + + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + + .. remarks:: + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. + The prompt generally continues to receive the user's replies until it accepts the + user's reply as valid input for the prompt. + """ if not dialog_context: raise TypeError("Prompt(): dc cannot be None.") @@ -111,15 +148,42 @@ async def continue_dialog(self, dialog_context: DialogContext): async def resume_dialog( self, dialog_context: DialogContext, reason: DialogReason, result: object ) -> DialogTurnResult: - # Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs - # on top of the stack which will result in the prompt receiving an unexpected call to - # dialog_resume() when the pushed on dialog ends. - # To avoid the prompt prematurely ending we need to implement this method and - # simply re-prompt the user. + """ Resumes a dialog. + Called when a prompt dialog resumes being the active dialog on the dialog stack, such as + when the previous active dialog on the stack completes. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:DialogContext + :param reason: An enum indicating why the dialog resumed. + :type reason: :class:DialogReason + :param result: Optional, value returned from the previous dialog on the stack. + The type of the value returned is dependent on the previous dialog. + :type result: object + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + + .. remarks:: + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. + Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs + on top of the stack which will result in the prompt receiving an unexpected call to + :meth:resume_dialog() when the pushed on dialog ends. + To avoid the prompt prematurely ending we need to simply re-prompt the user. + """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) return Dialog.end_of_turn async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): + """ Reprompts user for input. + Called when a prompt dialog has been requested to reprompt the user for input. + + :param context: Context for the current turn of conversation with the user. + :type context: :class:TurnContext + :param instance: The instance of the dialog on the stack. + :type instance: :class:DialogInstance + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + """ state = instance.state[self.persisted_state] options = instance.state[self.persisted_options] await self.on_prompt(context, state, options, False) @@ -132,6 +196,23 @@ async def on_prompt( options: PromptOptions, is_retry: bool, ): + """ Prompts user for input. + When overridden in a derived class, prompts the user for input. + + :param turn_context: Context for the current turn of conversation with the user. + :type turn_context: :class:TurnContext + :param state: Contains state for the current instance of the prompt on the dialog stack. + :type state: :class:Dict + :param options: A prompt options object constructed from the options initially provided + in the call :meth:DialogContext.PromptAsync(string, PromptOptions, CancellationToken). + :type options: :class:PromptOptions + :param is_retry: true if this is the first time this prompt dialog instance on the stack is prompting + the user for input; otherwise, false. + :type is_retry: bool + + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + """ pass @abstractmethod @@ -141,6 +222,20 @@ async def on_recognize( state: Dict[str, object], options: PromptOptions, ): + """ Recognizes the user's input. + When overridden in a derived class, attempts to recognize the user's input. + + :param turn_context: Context for the current turn of conversation with the user. + :type turn_context: :class:TurnContext + :param state: Contains state for the current instance of the prompt on the dialog stack. + :type state: :class:Dict + :param options: A prompt options object constructed from the options initially provided + in the call :meth:DialogContext.PromptAsync(string, PromptOptions, CancellationToken). + :type options: :class:PromptOptions + + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + """ pass def append_choices( @@ -151,20 +246,25 @@ def append_choices( style: ListStyle, options: ChoiceFactoryOptions = None, ) -> Activity: - """ + """ Composes an output activity containing a set of choices. + When overridden in a derived class, appends choices to the activity when the user is prompted for input. Helper function to compose an output activity containing a set of choices. - Parameters: - ----------- - prompt: The prompt to append the user's choice to. - - channel_id: ID of the channel the prompt is being sent to. - - choices: List of choices to append. - - style: Configured style for the list of choices. - - options: (Optional) options to configure the underlying `ChoiceFactory` call. + :param prompt: The prompt to append the user's choice to. + :type prompt: + :param channel_id: ID of the channel the prompt is being sent to. + :type channel_id: str + :param: choices: List of choices to append. + :type choices: :class:List + :param: style: Configured style for the list of choices. + :type style: :class:ListStyle + :param: options: Optional formatting options to use when presenting the choices. + :type style: :class:ChoiceFactoryOptions + :return: A :class:Task representing the asynchronous operation. + :rtype: :class:Task + + .. remarks:: + If the task is successful, the result contains the updated activity. """ # Get base prompt text (if any) text = prompt.text if prompt is not None and prompt.text else "" From f6f69b74015040c2d96aee033f30b8e941aaa77e Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 10 Jan 2020 15:46:33 -0800 Subject: [PATCH 137/616] Update conversation_state.py (#590) Added ref documentation. --- .../botbuilder/core/conversation_state.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 94bc8fedb..11f6b328d 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -7,16 +7,41 @@ class ConversationState(BotState): - """Conversation State - Reads and writes conversation state for your bot to storage. + """Conversation state + Defines a state management object for conversation state. + Extends `BootState` base class. + + .. remarks:: + Conversation state is available in any turn in a specific conversation, regardless of user, such as in a group conversation. """ no_key_error_message = "ConversationState: channelId and/or conversation missing from context.activity." def __init__(self, storage: Storage): + """ Creates a :class:ConversationState instance + Creates a new instance of the :class:ConversationState class. + :param storage: The storage containing the conversation state. + :type storage: Storage + """ super(ConversationState, self).__init__(storage, "ConversationState") def get_storage_key(self, turn_context: TurnContext) -> object: + """ Get storage key + Gets the key to use when reading and writing state to and from storage. + + :param turn_context: The context object for this turn. + :type turn_context: TurnContext + + :raise: `TypeError` if the `ITurnContext.Activity` for the current turn is missing + :any::Schema.Activity.ChannelId or :any::Schema.Activity.Conversation information, or + the conversation's :any::Schema.ConversationAccount.Id is missing. + + :return: The storage key. + :rtype: str + + .. remarks:: + Conversation state includes the channel ID and conversation ID as part of its storage key. + """ channel_id = turn_context.activity.channel_id or self.__raise_type_error( "invalid activity-missing channel_id" ) @@ -31,4 +56,7 @@ def get_storage_key(self, turn_context: TurnContext) -> object: return storage_key def __raise_type_error(self, err: str = "NoneType found while expecting value"): + """ Raise type error + :raises: :class:TypeError This function raises exception. + """ raise TypeError(err) From 8037ff2ec40de49e7ba0607628d60d8622050dbb Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 13 Jan 2020 14:02:54 -0800 Subject: [PATCH 138/616] Update conversation_state.py Added reference documentation. --- .../botbuilder/core/conversation_state.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 11f6b328d..954620de1 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -9,19 +9,18 @@ class ConversationState(BotState): """Conversation state Defines a state management object for conversation state. - Extends `BootState` base class. + Extends :class:`BootState` base class. .. remarks:: Conversation state is available in any turn in a specific conversation, regardless of user, such as in a group conversation. """ - no_key_error_message = "ConversationState: channelId and/or conversation missing from context.activity." def __init__(self, storage: Storage): - """ Creates a :class:ConversationState instance - Creates a new instance of the :class:ConversationState class. + """ Creates a :class:`ConversationState` instance + Creates a new instance of the :class:`ConversationState` class. :param storage: The storage containing the conversation state. - :type storage: Storage + :type storage: :class:`Storage` """ super(ConversationState, self).__init__(storage, "ConversationState") @@ -30,17 +29,17 @@ def get_storage_key(self, turn_context: TurnContext) -> object: Gets the key to use when reading and writing state to and from storage. :param turn_context: The context object for this turn. - :type turn_context: TurnContext + :type turn_context: :class:`TurnContext` - :raise: `TypeError` if the `ITurnContext.Activity` for the current turn is missing - :any::Schema.Activity.ChannelId or :any::Schema.Activity.Conversation information, or - the conversation's :any::Schema.ConversationAccount.Id is missing. + :raise: :class:`TypeError` if the :meth:`TurnContext.activity` for the current turn is missing + :class:`botbuilder.schema.Activity` channelId or conversation information or the conversation's + account id is missing. :return: The storage key. :rtype: str .. remarks:: - Conversation state includes the channel ID and conversation ID as part of its storage key. + Conversation state includes the channel Id and conversation Id as part of its storage key. """ channel_id = turn_context.activity.channel_id or self.__raise_type_error( "invalid activity-missing channel_id" @@ -56,7 +55,7 @@ def get_storage_key(self, turn_context: TurnContext) -> object: return storage_key def __raise_type_error(self, err: str = "NoneType found while expecting value"): - """ Raise type error - :raises: :class:TypeError This function raises exception. + """ Raise type error exception + :raises: :class:`TypeError` """ raise TypeError(err) From 98b47c74390f617d3d9d1baa033e515630c72781 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 13 Jan 2020 15:28:30 -0800 Subject: [PATCH 139/616] Update prompt.py Added reference documentation. --- .../botbuilder/dialogs/prompts/prompt.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 09f43bdbd..d3ab5a80f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -24,17 +24,18 @@ class Prompt(Dialog): """ Prompts base class - Defines the core behavior of prompt dialogs. Extends `Dialog` base class. + Defines the core behavior of prompt dialogs. Extends the :class:`Dialog` base class. .. remarks:: - When the prompt ends, it should return an object that represents the - value that was prompted for. - Use `DialogSet.Add(Dialog)` or `ComponentDialog.AddDialog(Dialog)` to add a prompt + When the prompt ends, it returns an object that represents the value it was prompted for. + Use :method:`DialogSet.add(self, dialog: Dialog)` or :method:`ComponentDialog.add_dialog(self, dialog: Dialog)` to add a prompt to a dialog set or component dialog, respectively. - Use `DialogContext.PromptAsync(string, PromptOptions, CancellationToken)` or - `DialogContext.BeginDialogAsync(string, object, CancellationToken)` to start the prompt. - If you start a prompt from a `WaterfallStep` in a `WaterfallDialog`, then the prompt - result will be available in the next step of the waterfall. + Use :method:`DialogContext.prompt(self, dialog_id: str, options)` or + :meth:`DialogContext.begin_dialog( + self, dialog_context: DialogContext, options: object = None)` to start the prompt. + .. note:: + If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result will be + available in the next step of the waterfall. """ ATTEMPT_COUNT_KEY = "AttemptCount" persisted_options = "options" @@ -42,11 +43,11 @@ class Prompt(Dialog): def __init__(self, dialog_id: str, validator: object = None): """Creates a new Prompt instance - :param dialog_id: Unique ID of the prompt within its parent :class:DialogSet or - :class:ComponentDialog. + :param dialog_id: Unique Id of the prompt within its parent :class:`DialogSet` or + :class:`ComponentDialog`. :type dialog_id: str :param validator: Optional custom validator used to provide additional validation and re-prompting logic for the prompt. - :type validator: object + :type validator: Object """ super(Prompt, self).__init__(dialog_id) @@ -59,11 +60,11 @@ async def begin_dialog( Called when a prompt dialog is pushed onto the dialog stack and is being activated. :param dialog_context: The dialog context for the current turn of the conversation. - :type dialog_context: :class:DialogContext + :type dialog_context: :class:`DialogContext` :param options: Optional, additional information to pass to the prompt being started. - :type options: object - :return: A :class:Task representing the asynchronous operation. - :rtype: :class:Task + :type options: Object + :return: The dialog turn result + :rtype: :class:`DialogTurnResult` .. remarks:: If the task is successful, the result indicates whether the prompt is still active @@ -100,10 +101,9 @@ async def continue_dialog(self, dialog_context: DialogContext): Called when a prompt dialog is the active dialog and the user replied with a new activity. :param dialog_context: The dialog context for the current turn of the conversation. - :type dialog_context: :class:DialogContext - - :return: A :class:Task representing the asynchronous operation. - :rtype: :class:Task + :type dialog_context: :class:`DialogContext` + :return: The dialog turn result + :rtype: :class:`DialogTurnResult` .. remarks:: If the task is successful, the result indicates whether the dialog is still @@ -159,8 +159,8 @@ async def resume_dialog( :param result: Optional, value returned from the previous dialog on the stack. The type of the value returned is dependent on the previous dialog. :type result: object - :return: A :class:Task representing the asynchronous operation. - :rtype: :class:Task + :return: The dialog turn result + :rtype: :class:`DialogTurnResult` .. remarks:: If the task is successful, the result indicates whether the dialog is still @@ -175,7 +175,7 @@ async def resume_dialog( async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): """ Reprompts user for input. - Called when a prompt dialog has been requested to reprompt the user for input. + Called when a prompt dialog has been requested to re-prompt the user for input. :param context: Context for the current turn of conversation with the user. :type context: :class:TurnContext @@ -200,12 +200,12 @@ async def on_prompt( When overridden in a derived class, prompts the user for input. :param turn_context: Context for the current turn of conversation with the user. - :type turn_context: :class:TurnContext + :type turn_context: :class:`TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack. :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided - in the call :meth:DialogContext.PromptAsync(string, PromptOptions, CancellationToken). - :type options: :class:PromptOptions + in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)`. + :type options: :class:`PromptOptions` :param is_retry: true if this is the first time this prompt dialog instance on the stack is prompting the user for input; otherwise, false. :type is_retry: bool @@ -226,11 +226,11 @@ async def on_recognize( When overridden in a derived class, attempts to recognize the user's input. :param turn_context: Context for the current turn of conversation with the user. - :type turn_context: :class:TurnContext + :type turn_context: :class:`TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack. :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided - in the call :meth:DialogContext.PromptAsync(string, PromptOptions, CancellationToken). + in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` :type options: :class:PromptOptions :return: A :class:Task representing the asynchronous operation. @@ -252,14 +252,14 @@ def append_choices( :param prompt: The prompt to append the user's choice to. :type prompt: - :param channel_id: ID of the channel the prompt is being sent to. + :param channel_id: Id of the channel the prompt is being sent to. :type channel_id: str :param: choices: List of choices to append. - :type choices: :class:List + :type choices: :class:`List` :param: style: Configured style for the list of choices. - :type style: :class:ListStyle + :type style: :class:`ListStyle` :param: options: Optional formatting options to use when presenting the choices. - :type style: :class:ChoiceFactoryOptions + :type style: :class:`ChoiceFactoryOptions` :return: A :class:Task representing the asynchronous operation. :rtype: :class:Task From bb83c8277aa146403acd09a88cba82ec5bf284dc Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 13 Jan 2020 16:06:46 -0800 Subject: [PATCH 140/616] Update oauth_prompt.py Added reference documentation. --- .../dialogs/prompts/oauth_prompt.py | 116 ++++++++++++++---- 1 file changed, 91 insertions(+), 25 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 49ab9624d..731ac6ecd 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -32,39 +32,54 @@ class OAuthPrompt(Dialog): - """ + """Creates a new prompt for the user to sign in Creates a new prompt that asks the user to sign in using the Bot Framework Single Sign On (SSO) service. - The prompt will attempt to retrieve the users current token and if the user isn't signed in, it - will send them an `OAuthCard` containing a button they can press to sign in. Depending on the channel, - the user will be sent through one of two possible sign-in flows: - - The automatic sign-in flow where once the user signs in, the SSO service will forward - the bot the users access token using either an `event` or `invoke` activity. - - The "magic code" flow where once the user signs in, they will be prompted by the SSO service - to send the bot a six digit code confirming their identity. This code will be sent as a - standard `message` activity. - Both flows are automatically supported by the `OAuthPrompt` and they only thing you need to be careful of - is that you don't block the `event` and `invoke` activities that the prompt might be waiting on. - Note: - You should avaoid persisting the access token with your bots other state. The Bot Frameworks SSO service - will securely store the token on your behalf. If you store it in your bots state, - it could expire or be revoked in between turns. - When calling the prompt from within a waterfall step, you should use the token within the step - following the prompt and then let the token go out of scope at the end of your function - Prompt Usage - When used with your bots `DialogSet`, you can simply add a new instance of the prompt as a named dialog using - `DialogSet.add()`. - You can then start the prompt from a waterfall step using either - `DialogContext.begin()` or `DialogContext.prompt()`. - The user will be prompted to sign in as needed and their access token will be passed as an argument to the callers - next waterfall step. - """ + .. remarks:: + The prompt will attempt to retrieve the users current token and if the user isn't signed in, it + will send them an `OAuthCard` containing a button they can press to sign in. Depending on the channel, + the user will be sent through one of two possible sign-in flows: + - The automatic sign-in flow where once the user signs in, the SSO service will forward + the bot the users access token using either an `event` or `invoke` activity. + - The "magic code" flow where once the user signs in, they will be prompted by the SSO service + to send the bot a six digit code confirming their identity. This code will be sent as a + standard `message` activity. + Both flows are automatically supported by the `OAuthPrompt` and they only thing you need to be careful of + is that you don't block the `event` and `invoke` activities that the prompt might be waiting on. + + .. note:: + You should avoid persisting the access token with your bots other state. The Bot Frameworks SSO service + will securely store the token on your behalf. If you store it in your bots state, + it could expire or be revoked in between turns. + When calling the prompt from within a waterfall step, you should use the token within the step + following the prompt and then let the token go out of scope at the end of your function. + + **Prompt Usage** + When used with your bots :class:`DialogSet`, you can simply add a new instance of the prompt as a named dialog using + :meth`DialogSet.add()`. + You can then start the prompt from a waterfall step using either :meth:`DialogContext.begin()` or :meth:`DialogContext.prompt()`. + The user will be prompted to sign in as needed and their access token will be passed as an argument to the callers + next waterfall step. + """ def __init__( self, dialog_id: str, settings: OAuthPromptSettings, validator: Callable[[PromptValidatorContext], Awaitable[bool]] = None, ): + """ Creates a :class:`OAuthPrompt` instance + Creates a new instance of the :class:`OAuthPrompt` class. + + :param dialogId: The Id to assign to this prompt. + :type dialogId: str + :param settings: Additional authentication settings to use with this instance of the prompt. + :type settings: :class:`OAuthPromptSettings` + :param validator: Optional, a :class:`PromptValidator` that contains additional, custom validation for this prompt. + :type validator: :class:`PromptValidatorContext` + + .. remarks:: + The value of :param dialogId: must be unique within the :class:`DialogSet`or :class:`ComponentDialog` to which the prompt is added. + """ super().__init__(dialog_id) self._validator = validator @@ -79,6 +94,20 @@ def __init__( async def begin_dialog( self, dialog_context: DialogContext, options: PromptOptions = None ) -> DialogTurnResult: + """ Starts an authentication prompt dialog. + Called when an authentication prompt dialog is pushed onto the dialog stack and is being activated. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :param options: Optional, additional information to pass to the prompt being started. + :type options: :class:PromptOptions + :return: Dialog turn result + :rtype: :class:DialogTurnResult + + .. remarks:: + If the task is successful, the result indicates whether the prompt is still active + after the turn has been processed by the prompt. + """ if dialog_context is None: raise TypeError( f"OAuthPrompt.begin_dialog: Expected DialogContext but got NoneType instead" @@ -120,6 +149,20 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: + """ Continues a dialog. + Called when a prompt dialog is the active dialog and the user replied with a new activity. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :return: Dialog turn result + :rtype: :class:DialogTurnResult + + .. remarks:: + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. + The prompt generally continues to receive the user's replies until it accepts the + user's reply as valid input for the prompt. + """ # Recognize token recognized = await self._recognize_token(dialog_context.context) @@ -167,6 +210,18 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu async def get_user_token( self, context: TurnContext, code: str = None ) -> TokenResponse: + """Gets the user's token + Attempts to get the user's token. + + :param context: Context for the current turn of conversation with the user. + :type context: :class:TurnContext + :return: A response that includes the user's token + :rtype: :class:TokenResponse + + .. remarks:: + If the task is successful and the user already has a token or the user successfully signs in, + the result contains the user's token. + """ adapter = context.adapter # Validate adapter type @@ -180,6 +235,17 @@ async def get_user_token( ) async def sign_out_user(self, context: TurnContext): + """Signs out the user + + :param context: Context for the current turn of conversation with the user. + :type context: :class:`TurnContext` + :return: A :class:`Task` representing the work queued to execute. + :rtype: :class:`Task` + + .. remarks:: + If the task is successful and the user already has a token or the user successfully signs in, + the result contains the user's token. + """ adapter = context.adapter # Validate adapter type From 9e24ae2580202b9db2b091fb07f90b8a62b9aa8e Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 13 Jan 2020 16:37:28 -0800 Subject: [PATCH 141/616] Update prompt_options.py Added ref documentation --- .../dialogs/prompts/prompt_options.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py index 8d9801424..2a3d6ef38 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py @@ -8,6 +8,10 @@ class PromptOptions: + """ Contains prompt settings + Contains settings to pass to a :class:`Prompt` object when the prompt is started. + """ + def __init__( self, prompt: Activity = None, @@ -17,6 +21,22 @@ def __init__( validations: object = None, number_of_attempts: int = 0, ): + """ Sets the initial prompt to send to the user as an :class:`botbuilder.schema.Activity`. + + :param prompt: The initial prompt to send to the user + :type prompt: :class:`botbuilder.schema.Activity` + :param retry_prompt: The retry prompt to send to the user + :type retry_prompt: :class:`botbuilder.schema.Activity` + :param choices: The choices to send to the user + :type choices: :class:`List` + :param style: The style of the list of choices to send to the user + :type style: :class:`ListStyle` + :param validations: The prompt validations + :type validations: :class:`Object` + :param number_of_attempts: The number of attempts allowed + :type number_of_attempts: :class:`int` + + """ self.prompt = prompt self.retry_prompt = retry_prompt self.choices = choices From 5749bc858df74c2ccd9a380578f2a6a88587abc0 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 13 Jan 2020 19:05:01 -0800 Subject: [PATCH 142/616] use get_access_token in signed_session, to ensure the Authorization token in the session is not expired (#600) --- .../botframework/connector/auth/microsoft_app_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 291414507..9da4d1d0a 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -105,7 +105,7 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: if not self.microsoft_app_id and not self.microsoft_app_password: session.headers.pop("Authorization", None) - elif not session.headers.get("Authorization"): + else: auth_token = self.get_access_token() header = "{} {}".format("Bearer", auth_token) session.headers["Authorization"] = header From 4d4ceb4b5f332cbf3b83d8838aff0cd0ef47bea1 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 14 Jan 2020 14:00:48 -0800 Subject: [PATCH 143/616] emolsh/api-ref-docs-dialogturnstatus --- .../botbuilder/dialogs/dialog_turn_status.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py index e734405a8..36441fe7c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -2,16 +2,23 @@ # Licensed under the MIT License. from enum import Enum - class DialogTurnStatus(Enum): - # Indicates that there is currently nothing on the dialog stack. + """Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` + + :var Empty: Indicates that there is currently nothing on the dialog stack. + :vartype Empty: int + :var Waiting: Indicates that the dialog on top is waiting for a response from the user. + :vartype Waiting: int + :var Complete: Indicates that the dialog completed successfully, the result is available, and the stack is empty. + :vartype Complete: int + :var Cancelled: Indicates that the dialog was cancelled and the stack is empty. + :vartype Cancelled: int + """ + Empty = 1 - # Indicates that the dialog on top is waiting for a response from the user. Waiting = 2 - # Indicates that the dialog completed successfully, the result is available, and the stack is empty. Complete = 3 - # Indicates that the dialog was cancelled and the stack is empty. Cancelled = 4 From 70ed08f6335e87847a69968c10da1e76722bca59 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Tue, 14 Jan 2020 16:02:19 -0800 Subject: [PATCH 144/616] Update bot_state.py Added ref documentation. --- .../botbuilder/core/bot_state.py | 112 +++++++++++++----- 1 file changed, 82 insertions(+), 30 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 4e615dda0..a05244832 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -12,11 +12,12 @@ class CachedBotState: - """ - Internal cached bot state. + """ + Internal cached bot state """ def __init__(self, state: Dict[str, object] = None): + self.state = state if state is not None else {} self.hash = self.compute_hash(state) @@ -29,17 +30,43 @@ def compute_hash(self, obj: object) -> str: class BotState(PropertyManager): + """ Defines a state management object + Defines a state management object and automates the reading and writing of + associated state properties to a storage layer + + .. remarks:: + Each state management object defines a scope for a storage layer. + State properties are created within a state management scope, and the Bot Framework + defines these scopes: :class:`ConversationState`, :class:`UserState`, and :class:`PrivateConversationState`. + You can define additional scopes for your bot. + """ + def __init__(self, storage: Storage, context_service_key: str): + """ Initializes a new instance of the :class:`BotState` class. + + :param storage: The storage layer this state management object will use to store and retrieve state + :type storage: :class:`bptbuilder.core.Storage` + :param context_service_key: The key for the state cache for this :class:`BotState` + :type context_service_key: str + + .. remarks:: + This constructor creates a state management object and associated scope. The object uses + the :param storage: to persist state property values and the :param context_service_key: to cache state + within the context for each turn. + + :raises: It raises an argument null exception + """ self.state_key = "state" self._storage = storage self._context_service_key = context_service_key def create_property(self, name: str) -> StatePropertyAccessor: """ - Create a property definition and register it with this BotState. - :param name: The name of the property. - :param force: + Create a property definition and register it with this :class:`BotState`. + :param name: The name of the property + :type name: str :return: If successful, the state property accessor created. + :rtype: :class:`StatePropertyAccessor` """ if not name: raise TypeError("BotState.create_property(): name cannot be None or empty.") @@ -52,9 +79,11 @@ def get(self, turn_context: TurnContext) -> Dict[str, object]: async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ - Reads in the current state object and caches it in the context object for this turm. - :param turn_context: The context object for this turn. - :param force: Optional. True to bypass the cache. + Reads in the current state object and caches it in the context object for this turn. + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param force: Optional, true to bypass the cache + :type force: bool """ if turn_context is None: raise TypeError("BotState.load(): turn_context cannot be None.") @@ -70,11 +99,13 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: async def save_changes( self, turn_context: TurnContext, force: bool = False ) -> None: - """ - If it has changed, writes to storage the state object that is cached in the current context object - for this turn. - :param turn_context: The context object for this turn. - :param force: Optional. True to save state to storage whether or not there are changes. + """Save the state cached in the current context for this turn + If it has changed, save the state cached in the current context for this turn. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param force: Optional, true to save state to storage whether or not there are changes. + :type force: bool """ if turn_context is None: raise TypeError("BotState.save_changes(): turn_context cannot be None.") @@ -88,11 +119,15 @@ async def save_changes( cached_state.hash = cached_state.compute_hash(cached_state.state) async def clear_state(self, turn_context: TurnContext): - """ - Clears any state currently stored in this state scope. - NOTE: that save_changes must be called in order for the cleared state to be persisted to the underlying store. - :param turn_context: The context object for this turn. + """Clears any state currently stored in this state scope + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :return: None + + .. notes:: + This function must be called in order for the cleared state to be persisted to the underlying store. """ if turn_context is None: raise TypeError("BotState.clear_state(): turn_context cannot be None.") @@ -103,9 +138,11 @@ async def clear_state(self, turn_context: TurnContext): turn_context.turn_state[self._context_service_key] = cache_value async def delete(self, turn_context: TurnContext) -> None: - """ - Delete any state currently stored in this state scope. - :param turn_context: The context object for this turn. + """Deletes any state currently stored in this state scope. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :return: None """ if turn_context is None: @@ -121,6 +158,15 @@ def get_storage_key(self, turn_context: TurnContext) -> str: raise NotImplementedError() async def get_property_value(self, turn_context: TurnContext, property_name: str): + """Gets the value of the specified property in the turn context + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The property name + :type property_name: str + + :return: The value of the property + """ if turn_context is None: raise TypeError( "BotState.get_property_value(): turn_context cannot be None." @@ -138,13 +184,15 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str async def delete_property_value( self, turn_context: TurnContext, property_name: str ) -> None: - """ - Deletes a property from the state cache in the turn context. - :param turn_context: The context object for this turn. - :param property_name: The name of the property to delete. + """Deletes a property from the state cache in the turn context + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The name of the property to delete + :type property_name: str + :return: None """ - if turn_context is None: raise TypeError("BotState.delete_property(): turn_context cannot be None.") if not property_name: @@ -155,13 +203,17 @@ async def delete_property_value( async def set_property_value( self, turn_context: TurnContext, property_name: str, value: object ) -> None: - """ - Deletes a property from the state cache in the turn context. - :param turn_context: The context object for this turn. - :param property_name: The value to set on the property. + """Sets a property to the specified value in the turn context + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The property name + :type property_name: str + :param value: The value to assign to the property + :type value: Object + :return: None """ - if turn_context is None: raise TypeError("BotState.delete_property(): turn_context cannot be None.") if not property_name: From 88a8ea14e86603ef07d542602e4b66008964996b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 15 Jan 2020 15:02:52 -0800 Subject: [PATCH 145/616] Axsuarez/move django import (#612) * Moved import of settings so doc build can pass * Moved import of settings so doc build can pass --- .../botbuilder/applicationinsights/django/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/common.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/common.py index a7f61588c..0479b3e22 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/common.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/common.py @@ -11,7 +11,6 @@ SynchronousQueue, TelemetryChannel, ) -from django.conf import settings from ..processor.telemetry_processor import TelemetryProcessor from .django_telemetry_processor import DjangoTelemetryProcessor @@ -34,6 +33,8 @@ def load_settings(): + from django.conf import settings # pylint: disable=import-outside-toplevel + if hasattr(settings, "APPLICATION_INSIGHTS"): config = settings.APPLICATION_INSIGHTS elif hasattr(settings, "APPLICATIONINSIGHTS"): From d01d46ba0a8f87143197f1442972703dde664e7a Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Wed, 15 Jan 2020 16:53:51 -0800 Subject: [PATCH 146/616] Update bot_framework_adapter.py Added ref documentation (WIP). --- .../botbuilder/core/bot_framework_adapter.py | 104 ++++++++++++++---- 1 file changed, 81 insertions(+), 23 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 7309bdbef..10b0c91c2 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -83,6 +83,12 @@ def __init__( channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, ): + """ + :param app_id: The application Id of the bot. This is the appId returned by the Azure portal registration, and is + generally found in the `MicrosoftAppId` parameter in the *config.py* file. + :type app_id: str + + """ self.app_id = app_id self.app_password = app_password self.channel_auth_tenant = channel_auth_tenant @@ -94,9 +100,27 @@ def __init__( class BotFrameworkAdapter(BotAdapter, UserTokenProvider): + """Defines an adapter to connect a bot to a service endpoint + + .. remarks:: + The bot adapter encapsulates authentication processes and sends activities to and + receives activities from the Bot Connector Service. When your bot receives an activity, + the adapter creates a context object, passes it to your bot's application logic, and + sends responses back to the user's channel. + The adapter processes and directs incoming activities in through the bot middleware + pipeline to your bot’s logic and then back out again. + As each activity flows in and out of the bot, each piece of middleware can inspect or act + upon the activity, both before and after the bot logic runs. + """ + _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" def __init__(self, settings: BotFrameworkAdapterSettings): + """ Initializes a new instance of the :class:`BotFrameworkAdapter` class + + :param settings: The settings to initialize the adapter + :type settings: :class:`BotFrameworkAdapterSettings` + """ super(BotFrameworkAdapter, self).__init__() self.settings = settings or BotFrameworkAdapterSettings("", "") self.settings.channel_service = self.settings.channel_service or os.environ.get( @@ -140,16 +164,23 @@ async def continue_conversation( bot_id: str = None, claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): - """ - Continues a conversation with a user. This is often referred to as the bots "Proactive Messaging" - flow as its lets the bot proactively send messages to a conversation or user that its already - communicated with. Scenarios like sending notifications or coupons to a user are enabled by this - method. - :param bot_id: - :param reference: - :param callback: - :param claims_identity: - :return: + """Continues a conversation with a user + + :param reference: A reference to the conversation to continue + :type reference: :class:`botbuilder.schema.ConversationReference + :param callback: The method to call for the resulting bot turn + :type callback: :class:`typing.Callable` + :param bot_id: The application Id of the bot. This is the appId returned by the Azure portal registration, + and is generally found in the `MicrosoftAppId` parameter in `config.py`. + :type bot_id: :class:`typing.str` + :param claims_identity: The bot claims identity + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + + .. remarks:: + This is often referred to as the bots *proactive messaging* flow as it lets the bot proactively + send messages to a conversation or user that are already in a communication. + Scenarios such as sending notifications or coupons to a user are enabled by this function. + """ # TODO: proactive messages @@ -176,12 +207,27 @@ async def create_conversation( logic: Callable[[TurnContext], Awaitable] = None, conversation_parameters: ConversationParameters = None, ): - """ - Starts a new conversation with a user. This is typically used to Direct Message (DM) a member - of a group. - :param reference: - :param logic: - :return: + """Starts a new conversation with a user + Used to direct message to a member of a group + :param reference: A conversation reference that contains the tenant. + :type reference: :class:`botbuilder.schema.ConversationReference` + :param logic: The logic to use for the creation of the conversation + :type logic: :class:`typing.Callable` + :param conversation_parameters: The information to use to create the conversation + :type conversation_parameters: + + :return: A task representing the work queued to execute + + .. remarks:: + To start a conversation, your bot must know its account information and the user's + account information on that channel. + Most channels only support initiating a direct message (non-group) conversation. + The adapter attempts to create a new conversation on the channel, and + then sends a conversation update activity through its middleware pipeline + to the the callback method. + If the conversation is established with the specified users, the ID of the activity + will contain the ID of the new conversation. + """ try: if reference.service_url is None: @@ -231,14 +277,26 @@ async def create_conversation( raise error async def process_activity(self, req, auth_header: str, logic: Callable): - """ + """Creates a turn context and runs the middleware pipeline for an incoming activity Processes an activity received by the bots web server. This includes any messages sent from a - user and is the method that drives what's often referred to as the bots "Reactive Messaging" - flow. - :param req: - :param auth_header: - :param logic: - :return: + user and is the method that drives what's often referred to as the bots *reactive messaging* flow. + + :param req: The incoming activity + :type req: :class:`typing.str` + :param auth_header: The HTTP authentication header of the request + :type auth_header: :class:`typing.str` + :param logic: The logic to execute at the end of the adapter's middleware pipeline. + :type logic: :class:`typing.Callable` + + :return: A task that represents the work queued to execute. If the activity type + was `Invoke` and the corresponding key (`channelId` + `activityId`) was found then + an :class:`InvokeResponse` is returned; otherwise, `null` is returned. + + .. remarks:: + Call this method to reactively send a message to a conversation. + If the task completes successfully, then an :class:`InvokeResponse` is returned; + otherwise. `null` is returned. + """ activity = await self.parse_request(req) auth_header = auth_header or "" From 43699188b7e82e7209519623486cca9d4d56c43c Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Thu, 16 Jan 2020 11:39:20 -0800 Subject: [PATCH 147/616] Update bot_framework_adapter.py Partially documented the BotFrameworkAdapterSettings class. --- .../botbuilder/core/bot_framework_adapter.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 10b0c91c2..1210bb9f0 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -83,10 +83,26 @@ def __init__( channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, ): - """ - :param app_id: The application Id of the bot. This is the appId returned by the Azure portal registration, and is - generally found in the `MicrosoftAppId` parameter in the *config.py* file. + """Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. + + :param app_id: The bot application ID. This is the appId returned by the Azure portal registration, and is + the value of the `MicrosoftAppId` parameter in the `config.py` file. :type app_id: str + :param app_password: The bot application password. This is the password returned by the Azure portal registration, and is + the value os the `MicrosoftAppPassword` parameter in the `config.py` file. + :type app_password: str + :param channel_auth_tenant: The channel tenant to use in conversation + :type channel_auth_tenant: str + :param oauth_endpoint: + :type oauth_endpoint: str + :param open_id_metadata: + :type open_id_metadata: str + :param channel_service: + :type channel_service: str + :param channel_provider: + :type channel_provider: :class:`ChannelProvider` + :param auth_configuration: + :type auth_configuration: :class:`AuthenticationConfiguration` """ self.app_id = app_id From e97e302c67732621b31a5523846ac0dfb7c41e30 Mon Sep 17 00:00:00 2001 From: Kyle Delaney Date: Thu, 16 Jan 2020 11:44:06 -0800 Subject: [PATCH 148/616] Update README.rst (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Axel Suárez --- libraries/botframework-connector/README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/botframework-connector/README.rst b/libraries/botframework-connector/README.rst index fc196f30a..aec6b1e92 100644 --- a/libraries/botframework-connector/README.rst +++ b/libraries/botframework-connector/README.rst @@ -39,9 +39,9 @@ Client creation (with authentication), conversation initialization and activity .. code-block:: python - from microsoft.botbuilder.schema import * - from microsoft.botframework.connector import ConnectorClient - from microsoft.botframework.connector.auth import MicrosoftTokenAuthentication + from botbuilder.schema import * + from botframework.connector import ConnectorClient + from botframework.connector.auth import MicrosoftAppCredentials APP_ID = '' APP_PASSWORD = '' @@ -50,7 +50,7 @@ Client creation (with authentication), conversation initialization and activity BOT_ID = '' RECIPIENT_ID = '' - credentials = MicrosoftTokenAuthentication(APP_ID, APP_PASSWORD) + credentials = MicrosoftAppCredentials(APP_ID, APP_PASSWORD) connector = ConnectorClient(credentials, base_url=SERVICE_URL) conversation = connector.conversations.create_conversation(ConversationParameters( @@ -130,4 +130,4 @@ Licensed under the MIT_ License. .. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 .. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt -.. `_ \ No newline at end of file +.. `_ From 8559317cc0d3171cb6610fda98486481974c87fe Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 16 Jan 2020 14:03:01 -0800 Subject: [PATCH 149/616] emolsh/api-ref-docs-dialogreason --- .../botbuilder/dialogs/dialog_reason.py | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index c20f2e3b2..57f20f73b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -2,17 +2,47 @@ # Licensed under the MIT License. from enum import Enum +""" +NOTE: Multiple formats added, will remove whatever formatting isn't needed +""" class DialogReason(Enum): - # A dialog is being started through a call to `DialogContext.begin()`. + """ Indicates in which a dialog-related method is being called. + :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. + :vartype BeginCalled: int + :var ContinuCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. + :vartype ContinueCalled: int + :var EndCalled: A dialog ended normally through a call to `DialogContext.end_dialog() + :vartype EndCalled: int + :var ReplaceCalled: A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. + :vartype ReplacedCalled: int + :var CancelCalled: A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. + :vartype CancelCalled: int + :var NextCalled: A preceding step was skipped through a call to `WaterfallStepContext.next()`. + :vartype NextCalled: int + """ + + """ + A dialog is being started through a call to `DialogContext.begin()`. + """ BeginCalled = 1 - # A dialog is being continued through a call to `DialogContext.continue_dialog()`. + """ + A dialog is being continued through a call to `DialogContext.continue_dialog()`. + """ ContinueCalled = 2 - # A dialog ended normally through a call to `DialogContext.end_dialog()`. + """ + A dialog ended normally through a call to `DialogContext.end_dialog() + """ EndCalled = 3 - # A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. + """ + A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. + """ ReplaceCalled = 4 - # A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. + """ + A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. + """ CancelCalled = 5 - # A step was advanced through a call to `WaterfallStepContext.next()`. + """ + A preceding step was skipped through a call to `WaterfallStepContext.next()`. + """ NextCalled = 6 From a334b6ddbbadd128d85cacdca3f494aac7dc2a4d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 16 Jan 2020 15:30:46 -0800 Subject: [PATCH 150/616] Fixed variable formatting --- .../botbuilder/dialogs/dialog_reason.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index 57f20f73b..36aed9558 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -9,17 +9,17 @@ class DialogReason(Enum): """ Indicates in which a dialog-related method is being called. :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. - :vartype BeginCalled: int + :vartype BeginCalled: :class:`int` :var ContinuCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. - :vartype ContinueCalled: int + :vartype ContinueCalled: i:class:`int` :var EndCalled: A dialog ended normally through a call to `DialogContext.end_dialog() - :vartype EndCalled: int + :vartype EndCalled: :class:`int` :var ReplaceCalled: A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. - :vartype ReplacedCalled: int + :vartype ReplacedCalled: :class:`int` :var CancelCalled: A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. - :vartype CancelCalled: int + :vartype CancelCalled: :class:`int` :var NextCalled: A preceding step was skipped through a call to `WaterfallStepContext.next()`. - :vartype NextCalled: int + :vartype NextCalled: :class:`int` """ """ From c824f4cd758483c5a94a3963ad7458a6e74f3072 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 16 Jan 2020 15:32:12 -0800 Subject: [PATCH 151/616] Fixed variable formatting --- .../botbuilder/dialogs/dialog_turn_status.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py index 36441fe7c..395cda883 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -6,13 +6,13 @@ class DialogTurnStatus(Enum): """Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` :var Empty: Indicates that there is currently nothing on the dialog stack. - :vartype Empty: int + :vartype Empty: :class:`int` :var Waiting: Indicates that the dialog on top is waiting for a response from the user. - :vartype Waiting: int + :vartype Waiting: :class:`int` :var Complete: Indicates that the dialog completed successfully, the result is available, and the stack is empty. - :vartype Complete: int + :vartype Complete: :class:`int` :var Cancelled: Indicates that the dialog was cancelled and the stack is empty. - :vartype Cancelled: int + :vartype Cancelled: :class:`int` """ Empty = 1 From 4a3ee8407238fc0558add058bfa7aad8cb4ef910 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 16 Jan 2020 15:38:54 -0800 Subject: [PATCH 152/616] emolsh/api-ref-docs-dialoginstance --- .../botbuilder/dialogs/dialog_instance.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index 3b5b4423f..392697155 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -10,10 +10,28 @@ class DialogInstance: """ def __init__(self): + """ + Gets or sets the ID of the dialog and gets or sets the instance's persisted state. + + :var self.id: The ID of the dialog + :vartype self.id: :class:`str` + :var self.state: The instance's persisted state. + :vartype self.state: :class:`Dict` + """ self.id: str = None # pylint: disable=invalid-name + self.state: Dict[str, object] = {} def __str__(self): + """ + Gets or sets a stack index. + + .. remarks:: + Positive values are indexes within the current DC and negative values are indexes in the parent DC. + + :return: result + :rtype: :class:`str` + """ result = "\ndialog_instance_id: %s\n" % self.id if self.state is not None: for key, value in self.state.items(): From e03f7e1dc751c792090ae897a0d760ebfec20ec3 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Thu, 16 Jan 2020 17:44:09 -0800 Subject: [PATCH 153/616] Update bot_framework_adapter.py Added remaining ref documentation --- .../botbuilder/core/bot_framework_adapter.py | 200 ++++++++++++++---- 1 file changed, 160 insertions(+), 40 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 1210bb9f0..bc7f09b92 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -99,10 +99,10 @@ def __init__( :type open_id_metadata: str :param channel_service: :type channel_service: str - :param channel_provider: - :type channel_provider: :class:`ChannelProvider` + :param channel_provider: The channel provider + :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` :param auth_configuration: - :type auth_configuration: :class:`AuthenticationConfiguration` + :type auth_configuration: :class:`botframework.connector.auth.AuthenticationConfiguration` """ self.app_id = app_id @@ -192,13 +192,15 @@ async def continue_conversation( :param claims_identity: The bot claims identity :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :raises: It raises an argument null exception + + :return: A task that represents the work queued to execute + .. remarks:: This is often referred to as the bots *proactive messaging* flow as it lets the bot proactively send messages to a conversation or user that are already in a communication. Scenarios such as sending notifications or coupons to a user are enabled by this function. - """ - # TODO: proactive messages if not claims_identity: if not bot_id: @@ -225,13 +227,15 @@ async def create_conversation( ): """Starts a new conversation with a user Used to direct message to a member of a group - :param reference: A conversation reference that contains the tenant. + :param reference: The conversation reference that contains the tenant :type reference: :class:`botbuilder.schema.ConversationReference` :param logic: The logic to use for the creation of the conversation :type logic: :class:`typing.Callable` :param conversation_parameters: The information to use to create the conversation :type conversation_parameters: + :raises: It raises a generic exception error + :return: A task representing the work queued to execute .. remarks:: @@ -243,7 +247,6 @@ async def create_conversation( to the the callback method. If the conversation is established with the specified users, the ID of the activity will contain the ID of the new conversation. - """ try: if reference.service_url is None: @@ -353,11 +356,15 @@ async def process_activity(self, req, auth_header: str, logic: Callable): async def authenticate_request( self, request: Activity, auth_header: str ) -> ClaimsIdentity: - """ - Allows for the overriding of authentication in unit tests. - :param request: - :param auth_header: - :return: + """Allows for the overriding of authentication in unit tests. + :param request: The request to authenticate + :type request: :class:`botbuilder.schema.Activity` + :param auth_header: The authentication header + + :raises: A permission exception error + + :return: The request claims identity + :rtype: :class:`botframework.connector.auth.ClaimsIdentity` """ claims = await JwtTokenValidation.authenticate_request( request, @@ -428,9 +435,22 @@ async def update_activity(self, context: TurnContext, activity: Activity): """ Replaces an activity that was previously sent to a channel. It should be noted that not all channels support this feature. - :param context: - :param activity: - :return: + + :param context: The context object for the turn + :type context: :class:`TurnContext' + :param activity: New replacement activity + :type activity: :class:`botbuilder.schema.Activity` + + :raises: A generic exception error + + :return: A task that represents the work queued to execute + + .. remarks:: + If the activity is successfully sent, the task result contains + a :class:`botbuilder.schema.ResourceResponse` object containing the ID that + the receiving channel assigned to the activity. + Before calling this function, set the ID of the replacement activity to the ID + of the activity to replace. """ try: identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) @@ -447,9 +467,18 @@ async def delete_activity( """ Deletes an activity that was previously sent to a channel. It should be noted that not all channels support this feature. - :param context: - :param reference: - :return: + + :param context: The context object for the turn + :type context: :class:`TurnContext' + :param reference: Conversation reference for the activity to delete + :type reference: :class:`botbuilder.schema.ConversationReference` + + :raises: A exception error + + :return: A task that represents the work queued to execute + + .. remarks:: + The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) @@ -520,11 +549,15 @@ async def send_activities( async def delete_conversation_member( self, context: TurnContext, member_id: str ) -> None: - """ - Deletes a member from the current conversation. - :param context: - :param member_id: - :return: + """Deletes a member from the current conversation + :param context: The context object for the turn + :type context: :class:`TurnContext` + :param member_id: The ID of the member to remove from the conversation + :type member_id: str + + :raises: A exception error + + :return: A task that represents the work queued to execute. TokenResponse: + + """Attempts to retrieve the token for a user that's in a login flow + + :param context: Context for the current turn of conversation with the user + :type context: :class:`TurnContext` + :param connection_name: Name of the auth connection to use + :type connection_name: str + :param magic_code" (Optional) user entered code to validate + :str magic_code" str + + :raises: An exception error + + :returns: Token Response + :rtype: :class:'botbuilder.schema.TokenResponse` + + """ + if ( context.activity.from_property is None or not context.activity.from_property.id @@ -659,6 +728,17 @@ async def get_user_token( async def sign_out_user( self, context: TurnContext, connection_name: str = None, user_id: str = None ) -> str: + """Signs the user out with the token server + + :param context: Context for the current turn of conversation with the user + :type context: :class:`TurnContext` + :param connection_name: Name of the auth connection to use + :type connection_name: str + :param user_id: User id of user to sign out + :type user_id: str + + :returns: A task that represents the work queued to execute + """ if not context.activity.from_property or not context.activity.from_property.id: raise Exception( "BotFrameworkAdapter.sign_out_user(): missing from_property or from_property.id" @@ -676,6 +756,18 @@ async def sign_out_user( async def get_oauth_sign_in_link( self, context: TurnContext, connection_name: str ) -> str: + """Gets the raw sign-in link to be sent to the user for sign-in for a connection name. + + :param context: Context for the current turn of conversation with the user + :type context: :class:`TurnContext` + :param connection_name: Name of the auth connection to use + :type connection_name: str + + :returns: A task that represents the work queued to execute + + .. remarks:: + If the task completes successfully, the result contains the raw sign-in link + """ self.check_emulating_oauth_cards(context) conversation = TurnContext.get_conversation_reference(context.activity) url = self.oauth_api_url(context) @@ -695,6 +787,20 @@ async def get_oauth_sign_in_link( async def get_token_status( self, context: TurnContext, user_id: str = None, include_filter: str = None ) -> List[TokenStatus]: + + """Retrieves the token status for each configured connection for the given user + + :param context: Context for the current turn of conversation with the user + :type context: :class:`TurnContext` + :param user_id: The user Id for which token status is retrieved + :type user_id: str + :param include_filter: (Optional) Comma separated list of connection's to include. + Blank will return token status for all configured connections. + :type include_filter: str + + :returns: Array of :class:`botframework.connector.token_api.modelsTokenStatus` + """ + if not user_id and ( not context.activity.from_property or not context.activity.from_property.id ): @@ -715,6 +821,20 @@ async def get_token_status( async def get_aad_tokens( self, context: TurnContext, connection_name: str, resource_urls: List[str] ) -> Dict[str, TokenResponse]: + """Retrieves Azure Active Directory tokens for particular resources on a configured connection + + :param context: Context for the current turn of conversation with the user + :type context: :class:`TurnContext` + + :param connection_name: The name of the Azure Active Directory connection configured with this bot + :type connection_name: str + + :param resource_urls: The list of resource URLs to retrieve tokens for + :type resource_urls: :class:`typing.List` + + :returns: Dictionary of resource Urls to the corresponding :class:'botbuilder.schema.TokenResponse` + :rtype: :class:`typing.Dict` + """ if not context.activity.from_property or not context.activity.from_property.id: raise Exception( "BotFrameworkAdapter.get_aad_tokens(): missing from_property or from_property.id" @@ -733,11 +853,11 @@ async def get_aad_tokens( async def create_connector_client( self, service_url: str, identity: ClaimsIdentity = None ) -> ConnectorClient: - """ - Allows for mocking of the connector client in unit tests. - :param service_url: - :param identity: - :return: + """Allows for mocking of the connector client in unit tests + :param service_url: The service URL + :param identity: The claims identity + + :return: An instance of the :class:`ConnectorClient` class """ if identity: bot_app_id_claim = identity.claims.get( From dc6108bbf7973d61c46b73e94084aa20b4db8907 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 17 Jan 2020 16:09:57 -0800 Subject: [PATCH 154/616] Update activity_handler.py Added ref docuimentation --- .../botbuilder/core/activity_handler.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 40c06d91e..27b24a3ea 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -8,6 +8,23 @@ class ActivityHandler: async def on_turn(self, turn_context: TurnContext): + """ Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime + in order to process an inbound :class:`botbuilder.schema.Activity`. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + It calls other methods in this class based on the type of the activity to + process, which allows a derived class to provide type-specific logic in a controlled way. + In a derived class, override this method to add logic that applies to all activity types. + + .. note:: + - Add logic to apply before the type-specific logic and before the call to the base class `OnTurnAsync` method. + - Add logic to apply after the type-specific logic after the call to the base class `OnTurnAsync` method. + """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -40,6 +57,18 @@ async def on_turn(self, turn_context: TurnContext): async def on_message_activity( # pylint: disable=unused-argument self, turn_context: TurnContext ): + """Override this method in a derived class to provide logic specific to activities, + such as the conversational logic. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + The `OnTurnAsync` method calls this method when it receives a message activity. + + """ return async def on_conversation_update_activity(self, turn_context: TurnContext): From a136b9a2f93d0eebb481e78abe501c597b6d1120 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 20 Jan 2020 10:46:14 -0800 Subject: [PATCH 155/616] emolsh/api-ref-docs-dialogturnresult --- .../botbuilder/dialogs/dialog_turn_result.py | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index e36504f8b..a3d6230ab 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -3,16 +3,41 @@ from .dialog_turn_status import DialogTurnStatus - class DialogTurnResult: + """ + Result returned to the caller of one of the various stack manipulation methods. + """ def __init__(self, status: DialogTurnStatus, result: object = None): + """ + :param status: The current status of the stack. + :type status: :class:`DialogTurnStatus` + :param result: The result returned by a dialog that was just ended. + :type result: object + """ self._status = status self._result = result - + @property def status(self): + """ + Gets or sets the current status of the stack. + + :return self._status: + :rtype self._status: :class:`DialogTurnStatus` + + """ return self._status + + """ + Final result returned by a dialog that just completed. + ..remarks: + This will only be populated in certain cases: + - The bot calls `DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. + - The bot calls `DialogContext.continue_dialog()` and a dialog that was active ends. + :return self._result: + :rtype self._result: object + """ @property def result(self): return self._result From 2034c6b7ad5b5b80c88479d7b3fc7b7b33a5fe1e Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 20 Jan 2020 11:19:39 -0800 Subject: [PATCH 156/616] emolsh/apu-ref-docs-dialogstate --- .../botbuilder/dialogs/dialog_state.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 278e6b14d..ba557631a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -4,9 +4,18 @@ from typing import List from .dialog_instance import DialogInstance - class DialogState: + """ + Contains state information for the dialog stack. + """ def __init__(self, stack: List[DialogInstance] = None): + """ + Initializes a new instance of the `DialogState` class. + .. remarks:: + The new instance is created with an empty dialog stack. + :param stack: The state information to initialize the stack with. + :type stack: List + """ if stack is None: self._dialog_stack = [] else: @@ -14,9 +23,22 @@ def __init__(self, stack: List[DialogInstance] = None): @property def dialog_stack(self): + """ + Initializes a new instance of the `DialogState` class. + .. remarks:: + The new instance has a dialog stack that is populated using the information + :return: The state information to initialize the stack with. + :rtype: List + """ return self._dialog_stack def __str__(self): + """ + Gets or sets the state information for a dialog stack. + + :return: State information for a dialog stack + :rtype: str + """ if not self._dialog_stack: return "dialog stack empty!" return " ".join(map(str, self._dialog_stack)) From 161791e2041976d0fafb22a3f6a55697ae16aeab Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 20 Jan 2020 11:22:22 -0800 Subject: [PATCH 157/616] Fixed formatting --- .../botbuilder/dialogs/dialog_turn_status.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py index 395cda883..36441fe7c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -6,13 +6,13 @@ class DialogTurnStatus(Enum): """Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` :var Empty: Indicates that there is currently nothing on the dialog stack. - :vartype Empty: :class:`int` + :vartype Empty: int :var Waiting: Indicates that the dialog on top is waiting for a response from the user. - :vartype Waiting: :class:`int` + :vartype Waiting: int :var Complete: Indicates that the dialog completed successfully, the result is available, and the stack is empty. - :vartype Complete: :class:`int` + :vartype Complete: int :var Cancelled: Indicates that the dialog was cancelled and the stack is empty. - :vartype Cancelled: :class:`int` + :vartype Cancelled: int """ Empty = 1 From 8680347629eb53700076ccd14970870afc8446c1 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 20 Jan 2020 11:23:01 -0800 Subject: [PATCH 158/616] Fixed formatting --- .../botbuilder/dialogs/dialog_reason.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index 36aed9558..57f20f73b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -9,17 +9,17 @@ class DialogReason(Enum): """ Indicates in which a dialog-related method is being called. :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. - :vartype BeginCalled: :class:`int` + :vartype BeginCalled: int :var ContinuCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. - :vartype ContinueCalled: i:class:`int` + :vartype ContinueCalled: int :var EndCalled: A dialog ended normally through a call to `DialogContext.end_dialog() - :vartype EndCalled: :class:`int` + :vartype EndCalled: int :var ReplaceCalled: A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. - :vartype ReplacedCalled: :class:`int` + :vartype ReplacedCalled: int :var CancelCalled: A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. - :vartype CancelCalled: :class:`int` + :vartype CancelCalled: int :var NextCalled: A preceding step was skipped through a call to `WaterfallStepContext.next()`. - :vartype NextCalled: :class:`int` + :vartype NextCalled: int """ """ From 84ebf4153a8b4028191c356b6e81b6b4a5062b91 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 20 Jan 2020 11:29:14 -0800 Subject: [PATCH 159/616] Update dialog_instance.py --- .../botbuilder/dialogs/dialog_instance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index 392697155..3fb59a0c3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -14,9 +14,9 @@ def __init__(self): Gets or sets the ID of the dialog and gets or sets the instance's persisted state. :var self.id: The ID of the dialog - :vartype self.id: :class:`str` + :vartype self.id: str :var self.state: The instance's persisted state. - :vartype self.state: :class:`Dict` + :vartype self.state: Dict """ self.id: str = None # pylint: disable=invalid-name @@ -30,7 +30,7 @@ def __str__(self): Positive values are indexes within the current DC and negative values are indexes in the parent DC. :return: result - :rtype: :class:`str` + :rtype: str """ result = "\ndialog_instance_id: %s\n" % self.id if self.state is not None: From 1ff4e09de5c969a760b097867c118c5000efdde8 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Tue, 21 Jan 2020 11:07:42 -0800 Subject: [PATCH 160/616] Update activity_handler.py Added comments to n_conversation_update_activity method --- .../botbuilder/core/activity_handler.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 27b24a3ea..a429894dd 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -65,13 +65,31 @@ async def on_message_activity( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute - .. remarks:: - The `OnTurnAsync` method calls this method when it receives a message activity. - """ return async def on_conversation_update_activity(self, turn_context: TurnContext): + """Invoked when a conversation update activity is received from the channel when the base behavior of + `OnTurnAsync` is used. + Conversation update activities are useful when it comes to responding to users being added to or removed from the conversation. + For example, a bot could respond to a user being added by greeting the user. + By default, this method calls :meth:`ActivityHandler.on_members_added_activity()` if any users have been added or + :meth:`ActivityHandler.on_members_removed_activity()` if any users have been removed. + The method checks the member ID so that it only responds to updates regarding members other than the bot itself. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this method. + If the conversation update activity indicates that members other than the bot joined the conversation, + it calls the :meth:`ActivityHandler.on_members_added_activity()` method. + If the conversation update activity indicates that members other than the bot left the conversation, + it calls the :meth:`ActivityHandler.on_members_removed_activity()` method. + In a derived class, override this method to add logic that applies to all conversation update activities. + Add logic to apply before the member added or removed logic before the call to this base class method. + """ if ( turn_context.activity.members_added is not None and turn_context.activity.members_added From feb1c5afef7d2861c7ece02a11091e9d88552d3c Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 21 Jan 2020 13:49:30 -0800 Subject: [PATCH 161/616] emolsh/api-ref-docs-activityprompt --- .../dialogs/prompts/activity_prompt.py | 89 ++++++++++++++----- 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index 5930441e1..0cfa4739f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -24,9 +24,15 @@ class ActivityPrompt(Dialog, ABC): """ Waits for an activity to be received. - This prompt requires a validator be passed in and is useful when waiting for non-message - activities like an event to be received. The validator can ignore received events until the - expected activity is received. + ..remarks: + This prompt requires a validator be passed in and is useful when waiting for non-message + activities like an event to be received. The validator can ignore received events until the + expected activity is received. + + :var persisted_options: ? + :typevar persisted_options: str + :var persisted_state: ? + :vartype persisted_state: str """ persisted_options = "options" @@ -36,13 +42,12 @@ def __init__( self, dialog_id: str, validator: Callable[[PromptValidatorContext], bool] ): """ - Initializes a new instance of the ActivityPrompt class. + Initializes a new instance of the :class:`ActivityPrompt` class. - Parameters: - ---------- - dialog_id (str): Unique ID of the dialog within its parent DialogSet or ComponentDialog. - - validator: Validator that will be called each time a new activity is received. + :param dialog_id: Unique ID of the dialog within its parent :class:`DialogSet` or :class:`ComponentDialog`. + :type dialog_id: str + :param validator: Validator that will be called each time a new activity is received. + :type validator: Callable[[PromptValidatorContext], bool] """ Dialog.__init__(self, dialog_id) @@ -53,6 +58,16 @@ def __init__( async def begin_dialog( self, dialog_context: DialogContext, options: PromptOptions = None ) -> DialogTurnResult: + """ + Called when a prompt dialog is pushed onto the dialog stack and is being activated. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :param options: Optional, additional information to pass to the prompt being started. + :type options: :class:`PromptOptions` + :return Dialog.end_of_turn: + :rtype Dialog.end_of_turn: :class:`Dialog.DialogTurnResult` + """ if not dialog_context: raise TypeError("ActivityPrompt.begin_dialog(): dc cannot be None.") if not isinstance(options, PromptOptions): @@ -84,6 +99,14 @@ async def begin_dialog( async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: if not dialog_context: + """ + Called when a prompt dialog is the active dialog and the user replied with a new activity. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :return Dialog.end_of_turn: + :rtype Dialog.end_of_turn: :class:`Dialog.DialogTurnResult` + """ raise TypeError( "ActivityPrompt.continue_dialog(): DialogContext cannot be None." ) @@ -130,11 +153,19 @@ async def resume_dialog( # pylint: disable=unused-argument self, dialog_context: DialogContext, reason: DialogReason, result: object = None ): """ - Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs - on top of the stack which will result in the prompt receiving an unexpected call to - resume_dialog() when the pushed on dialog ends. - To avoid the prompt prematurely ending, we need to implement this method and - simply re-prompt the user + Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. + ..remarks: + Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs + on top of the stack which will result in the prompt receiving an unexpected call to + resume_dialog() when the pushed on dialog ends. + To avoid the prompt prematurely ending, we need to implement this method and + simply re-prompt the user. + :param dialog_context: The dialog context for the current turn of the conversation + :type dialog_context: :class:`DialogContext` + :param reason: An enum indicating why the dialog resumed. + :type reason: :class:`DialogReason` + :param result: >Optional, value returned from the previous dialog on the stack. The type of the value returned is dependent on the previous dialog. + :type result: object """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) @@ -155,15 +186,14 @@ async def on_prompt( """ Called anytime the derived class should send the user a prompt. - Parameters: - ---------- - context: Context for the current turn of conversation with the user. - - state: Additional state being persisted for the prompt. - - options: Options that the prompt started with in the call to `DialogContext.prompt()`. - - isRetry: If `true` the users response wasn't recognized and the re-prompt should be sent. + :param dialog_context: The dialog context for the current turn of the conversation + :type dialog_context: :class:`DialogContext` + :param state: Additional state being persisted for the prompt. + :type state: Dict[str, dict] + :param options: Options that the prompt started with in the call to `DialogContext.prompt()`. + :type options: :class:`PromptOptions` + :param isRetry: If `true` the users response wasn't recognized and the re-prompt should be sent. + :type isRetry: bool """ if is_retry and options.retry_prompt: options.retry_prompt.input_hint = InputHints.expecting_input @@ -175,7 +205,18 @@ async def on_prompt( async def on_recognize( # pylint: disable=unused-argument self, context: TurnContext, state: Dict[str, object], options: PromptOptions ) -> PromptRecognizerResult: - + """ + When overridden in a derived class, attempts to recognize the incoming activity. + + :param context: Context for the current turn of conversation with the user. + :type context: :class:`TurnContext` + :param state: Contains state for the current instance of the prompt on the dialog stack. + :type state: Dict[str, object] + :param options: A prompt options object + :type options: :class:`PromptOptions` + :return result: constructed from the options initially provided in the call to `async def on_prompt()` + :rtype result: :class:`PromptRecognizerResult` + """ result = PromptRecognizerResult() result.succeeded = (True,) result.value = context.activity From ff0b5c3d64365ad384f4404dbad38593e5d5c2b6 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 21 Jan 2020 13:51:47 -0800 Subject: [PATCH 162/616] Update activity_prompt.py --- .../botbuilder/dialogs/prompts/activity_prompt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index 0cfa4739f..3a70d17cc 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -19,7 +19,6 @@ from .prompt_recognizer_result import PromptRecognizerResult from .prompt_validator_context import PromptValidatorContext - class ActivityPrompt(Dialog, ABC): """ Waits for an activity to be received. From ee0ec4795e50b3814d45ee06bd321de98609bcb4 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Tue, 21 Jan 2020 17:06:04 -0800 Subject: [PATCH 163/616] Update activity_handler.py Added remaining ref doc --- .../botbuilder/core/activity_handler.py | 160 ++++++++++++++++-- 1 file changed, 150 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index a429894dd..9d4bfb6f1 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -22,8 +22,8 @@ async def on_turn(self, turn_context: TurnContext): In a derived class, override this method to add logic that applies to all activity types. .. note:: - - Add logic to apply before the type-specific logic and before the call to the base class `OnTurnAsync` method. - - Add logic to apply after the type-specific logic after the call to the base class `OnTurnAsync` method. + - Add logic to apply before the type-specific logic and before the call to the :meth:`ActivityHandler.on_turn()` method. + - Add logic to apply after the type-specific logic after the call to the :meth:`ActivityHandler.on_turn()` method. """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -69,13 +69,7 @@ async def on_message_activity( # pylint: disable=unused-argument return async def on_conversation_update_activity(self, turn_context: TurnContext): - """Invoked when a conversation update activity is received from the channel when the base behavior of - `OnTurnAsync` is used. - Conversation update activities are useful when it comes to responding to users being added to or removed from the conversation. - For example, a bot could respond to a user being added by greeting the user. - By default, this method calls :meth:`ActivityHandler.on_members_added_activity()` if any users have been added or - :meth:`ActivityHandler.on_members_removed_activity()` if any users have been removed. - The method checks the member ID so that it only responds to updates regarding members other than the bot itself. + """Invoked when a conversation update activity is received from the channel when the base behavior of :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -108,15 +102,63 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext - ): # pylint: disable=unused-argument + ): # pylint: disable=unused-argument + """Override this method in a derived class to provide logic for when members other than the bot join the conversation. + You can add your bot's welcome logic. + + :param members_added: A list of all the members added to the conversation, as described by the conversation update activity + :type members_added: :class:`typing.List` + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates + one or more users other than the bot are joining the conversation, it calls this method. + """ return async def on_members_removed_activity( self, members_removed: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument + """Override this method in a derived class to provide logic for when members other than the bot leave the conversation. + You can add your bot's good-bye logic. + + :param members_added: A list of all the members removed from the conversation, as described by the conversation update activity + :type members_added: :class:`typing.List` + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates + one or more users other than the bot are leaving the conversation, it calls this method. + """ + return async def on_message_reaction_activity(self, turn_context: TurnContext): + """Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. + Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the + reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response + from a send call. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this method. + If the message reaction indicates that reactions were added to a message, it calls :meth:'ActivityHandler.on_reaction_added(). + If the message reaction indicates that reactions were removed from a message, it calls :meth:'ActivityHandler.on_reaction_removed(). + In a derived class, override this method to add logic that applies to all message reaction activities. + Add logic to apply before the reactions added or removed logic before the call to the this base class method. + Add logic to apply after the reactions added or removed logic after the call to the this base class method. + """ if turn_context.activity.reactions_added is not None: await self.on_reactions_added( turn_context.activity.reactions_added, turn_context @@ -130,14 +172,68 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): async def on_reactions_added( # pylint: disable=unused-argument self, message_reactions: List[MessageReaction], turn_context: TurnContext ): + """Override this method in a derived class to provide logic for when reactions to a previous activity + are added to the conversation. + + :param message_reactions: The list of reactions added + :type message_reactions: :class:`typing.List` + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) + to a previously sent message on the conversation. Message reactions are supported by only a few channels. + The activity that the message is in reaction to is identified by the activity's reply to Id property. + The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, + the channel assigns an ID to it, which is available in the resource response Id of the result. + """ return async def on_reactions_removed( # pylint: disable=unused-argument self, message_reactions: List[MessageReaction], turn_context: TurnContext ): + """Override this method in a derived class to provide logic for when reactions to a previous activity + are removed from the conversation. + + :param message_reactions: The list of reactions removed + :type message_reactions: :class:`typing.List` + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) + to a previously sent message on the conversation. Message reactions are supported by only a few channels. + The activity that the message is in reaction to is identified by the activity's reply to Id property. + The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, + the channel assigns an ID to it, which is available in the resource response Id of the result. + """ return async def on_event_activity(self, turn_context: TurnContext): + """Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. + If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; + otherwise, it calls :meth:'ActivityHandler.on_event()`. + + In a derived class, override this method to add logic that applies to all event activities. + Add logic to apply before the specific event-handling logic before the call to this base class method. + Add logic to apply after the specific event-handling logic after the call to this base class method. + + Event activities communicate programmatic information from a client or channel to a bot. + The meaning of an event activity is defined by the event activity name property, which is meaningful within + the scope of a channel. + """ if turn_context.activity.name == "tokens/response": return await self.on_token_response_event(turn_context) @@ -146,19 +242,63 @@ async def on_event_activity(self, turn_context: TurnContext): async def on_token_response_event( # pylint: disable=unused-argument self, turn_context: TurnContext ): + """Invoked when a `tokens/response` event is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. + If using an `oauth_prompt`, override this method to forward this activity to the current dialog. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of `tokens/response`, + it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. + """ return async def on_event( # pylint: disable=unused-argument self, turn_context: TurnContext ): + """Invoked when an event other than `tokens/response` is received when the base behavior of + :meth:'ActivityHandler.on_event_activity()` is used. + This method could optionally be overridden if the bot is meant to handle miscellaneous events. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an + activity name other than `tokens/response`, it calls this method. + """ return async def on_end_of_conversation_activity( # pylint: disable=unused-argument self, turn_context: TurnContext ): + """Invoked when a conversation end activity is received from the channel. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :returns: A task that represents the work queued to execute + """ return async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): + """Invoked when an activity other than a message, conversation update, or event is received when the base behavior of + :meth:`ActivityHandler.on_turn()` is used. + If overridden, this method could potentially respond to any of the other activity types. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, conversation update, message reaction, + or event activity, it calls this method. + """ return From 31dc353ae0b0cf4216ada3b4ed76a7c29e1db6df Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 12:30:21 -0800 Subject: [PATCH 164/616] emolsh/api-ref-docs-componentdialog - Formatting issues start at line 197 - comments not showing, can't find error - ? where I didn't know what to put for that section (feel free to edit/suggest correct content) --- .../botbuilder/dialogs/component_dialog.py | 185 +++++++++++++++++- 1 file changed, 175 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index b4c531b23..ed676119d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -14,9 +14,25 @@ class ComponentDialog(Dialog): + """ + A :class:`Dialog` that is composed of other dialogs + + ..remarks: + + A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, + which provides an inner dialog stack that is hidden from the parent dialog. + :var persisted_dialog state: ? + :vartype persisted_dialog_state: str + """ persisted_dialog_state = "dialogs" def __init__(self, dialog_id: str): + """ + Initializes a new instance of the :class:`ComponentDialog` + + :param dialog_id: The ID to assign to the new dialog within the parent dialog set. + :type dialog_id: str + """ super(ComponentDialog, self).__init__(dialog_id) if dialog_id is None: @@ -30,6 +46,21 @@ def __init__(self, dialog_id: str): async def begin_dialog( self, dialog_context: DialogContext, options: object = None ) -> DialogTurnResult: + """ + Called when the dialog is started and pushed onto the parent's dialog stack. + + ..remarks:: + + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. + + :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :param options: Optional, initial information to pass to the dialog. + :type options: object + :return: Signals the end of the turn + :rtype: :class:`Dialog.end_of_turn` + """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -49,6 +80,28 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: + """ + Called when the dialog is continued, where it is the active dialog and the + user replies with a new activity. + + ..remarks:: + + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. The result may also + contain a return value. + + If this method is *not* overriden the component dialog calls the + :meth:`DialogContext.continue_dialog` method on it's inner dialog + context. If the inner dialog stack is empty, the component dialog ends, + and if a :class:`DialogTurnResult.result` is available, the component dialog + uses that as it's return value. + + + :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :return: Signals the end of the turn + :rtype: :class:`Dialog.end_of_turn` + """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") # Continue execution of inner dialog. @@ -65,17 +118,53 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu async def resume_dialog( self, dialog_context: DialogContext, reason: DialogReason, result: object = None ) -> DialogTurnResult: - # Containers are typically leaf nodes on the stack but the dev is free to push other dialogs - # on top of the stack which will result in the container receiving an unexpected call to - # resume_dialog() when the pushed on dialog ends. - # To avoid the container prematurely ending we need to implement this method and simply - # ask our inner dialog stack to re-prompt. + """ + Called when a child dialog on the parent's dialog stack completed this turn, returning + control to this dialog component. + + ..remarks:: + + If the task is successful, the result indicates whether this dialog is still + active after this dialog turn has been processed. + + Generally, the child dialog was started with a call to :meth:`def async begin_dialog()` + in the parent's context. However, if the :meth:`DialogContext.replace_dialog()` method is + is called, the logical child dialog may be different than the original. + + If this method is *not* overridden, the dialog automatically calls its + :meth:`asyn def reprompt_dialog()` when the user replies. + + :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :param reason: Reason why the dialog resumed. + :type reason: :class:`DialogReason` + :param result: Optional, value returned from the dialog that was called. The type of the value returned is dependent on the child dialog. + :type result: object + :return: Signals the end of the turn + :rtype: :class:`Dialog.end_of_turn` + """ + + """ (not sure where to put this information) + Containers are typically leaf nodes on the stack but the dev is free to push other dialogs + on top of the stack which will result in the container receiving an unexpected call to + resume_dialog() when the pushed on dialog ends. + To avoid the container prematurely ending we need to implement this method and simply + ask our inner dialog stack to re-prompt. + """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) return Dialog.end_of_turn async def reprompt_dialog( self, context: TurnContext, instance: DialogInstance ) -> None: + """ + Called when the dialog should re-prompt the user for input. + + :param context: The context object for this turn. + :type context: :class:`TurnContext` + :param instance: State information for this dialog. + :type instance: :class:`DialogInstance` + """ # Delegate to inner dialog. dialog_state = instance.state[self.persisted_dialog_state] inner_dc = DialogContext(self._dialogs, context, dialog_state) @@ -87,7 +176,17 @@ async def reprompt_dialog( async def end_dialog( self, context: TurnContext, instance: DialogInstance, reason: DialogReason ) -> None: - # Forward cancel to inner dialogs + """ + Called when the dialog is ending. + + :param context: The context object for this turn. + :type context: :class:`TurnContext` + :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. + :type instance: :class:`DialogInstance` + :param reason: Reason why the dialog ended. + :type reason: :class:`DialogReason` + """ + # Forward cancel to inner dialog if reason == DialogReason.CancelCalled: dialog_state = instance.state[self.persisted_dialog_state] inner_dc = DialogContext(self._dialogs, context, dialog_state) @@ -96,10 +195,13 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ - Adds a dialog to the component dialog. - Adding a new dialog will inherit the BotTelemetryClient of the ComponentDialog. + Adds a :class:`Dialog` to the component dialog and returns the updated component. + + ..remarks:: + Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. :param dialog: The dialog to add. - :return: The updated ComponentDialog + :return: The updated :class:`ComponentDialog` + :rtype: :class:`ComponentDialog` """ self._dialogs.add(dialog) if not self.initial_dialog_id: @@ -109,15 +211,39 @@ def add_dialog(self, dialog: Dialog) -> object: def find_dialog(self, dialog_id: str) -> Dialog: """ Finds a dialog by ID. - Adding a new dialog will inherit the BotTelemetryClient of the ComponentDialog. + + ..remarks:: + Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. + :param dialog_id: The dialog to add. :return: The dialog; or None if there is not a match for the ID. + :rtype: :class:Dialog """ return self._dialogs.find(dialog_id) async def on_begin_dialog( self, inner_dc: DialogContext, options: object ) -> DialogTurnResult: + """ + Called when the dialog is started and pushed onto the parent's dialog stack. + + ..remarks:: + + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. + + By default, this calls the :meth:`Dialog.begin_dialog()` method of the component + dialog's initial dialog, as defined by ?. + + Override this method in a derived class to implement interrupt logic. + + :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. + :type inner_dc: :class:`DialogContext` + :param options: Optional, initial information to pass to the dialog. + :type options: object + :return: ? + :rtype: ? + """ return await inner_dc.begin_dialog(self.initial_dialog_id, options) async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: @@ -126,14 +252,53 @@ async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: async def on_end_dialog( # pylint: disable=unused-argument self, context: TurnContext, instance: DialogInstance, reason: DialogReason ) -> None: + """ + Ends the component dialog in its parent's context. + + :param turn_context: The :class:`TurnContext` for the current turn of the conversation. + :type turn_context: :class:`TurnContext` + :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. + :type instance: :class:`DialogInstance` + :param reason: Reason why the dialog ended. + :type reason: :class:`DialogReason` + """ return async def on_reprompt_dialog( # pylint: disable=unused-argument self, turn_context: TurnContext, instance: DialogInstance ) -> None: + """ + :param turn_context: + :type turn_context: :class:`DialogInstance` + :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. + :type instance: :class:`DialogInstance` + """ return async def end_component( self, outer_dc: DialogContext, result: object # pylint: disable=unused-argument ) -> DialogTurnResult: + """ + Ends the component dialog in its parent's context. + + ..remarks:: + If the task is successful, the result indicates that the dialog ended after the + turn was processed by the dialog. + + In general, the parent context is the dialog or bot turn handler that started the dialog. + If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method + to return a result to the parent dialog. If the parent dialog does not implement + :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next + parent context, if one exists. + + The returned :class:`DialogTurnResult`contains the return value in its + :class:`DialogTurnResult.result` property. + + :param outer_dc: The parent class:`DialogContext` for the current turn of conversation. + :type outer_dc: class:`DialogContext` + :param result: Optional, value to return from the dialog component to the parent context. + :type result: object + :return: Value to return. + :rtype: :class:`DialogTurnResult.result` + """ return await outer_dc.end_dialog(result) From 6de16337534910fb25fd1b43737998220e6579b4 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 15:18:30 -0800 Subject: [PATCH 165/616] Updated formatting --- .../botbuilder/dialogs/dialog_turn_status.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py index 36441fe7c..46be68c85 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -3,7 +3,8 @@ from enum import Enum class DialogTurnStatus(Enum): - """Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` + """ + Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` :var Empty: Indicates that there is currently nothing on the dialog stack. :vartype Empty: int From e28409d563a82228de2100b428cf149a1c1d42f4 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 15:19:38 -0800 Subject: [PATCH 166/616] Updated formatting --- .../botbuilder/dialogs/dialog_reason.py | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index 57f20f73b..fa24bc3ea 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -2,12 +2,10 @@ # Licensed under the MIT License. from enum import Enum -""" -NOTE: Multiple formats added, will remove whatever formatting isn't needed -""" - class DialogReason(Enum): - """ Indicates in which a dialog-related method is being called. + """ + Indicates in which a dialog-related method is being called. + :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. :vartype BeginCalled: int :var ContinuCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. @@ -21,28 +19,15 @@ class DialogReason(Enum): :var NextCalled: A preceding step was skipped through a call to `WaterfallStepContext.next()`. :vartype NextCalled: int """ - - """ - A dialog is being started through a call to `DialogContext.begin()`. - """ + BeginCalled = 1 - """ - A dialog is being continued through a call to `DialogContext.continue_dialog()`. - """ + ContinueCalled = 2 - """ - A dialog ended normally through a call to `DialogContext.end_dialog() - """ + EndCalled = 3 - """ - A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. - """ + ReplaceCalled = 4 - """ - A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. - """ + CancelCalled = 5 - """ - A preceding step was skipped through a call to `WaterfallStepContext.next()`. - """ + NextCalled = 6 From 00cb3a8fd7e1a17bca9cf39ce6d77653da726de2 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 15:28:53 -0800 Subject: [PATCH 167/616] Updated formatting --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index 3fb59a0c3..83afefbe6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -16,7 +16,7 @@ def __init__(self): :var self.id: The ID of the dialog :vartype self.id: str :var self.state: The instance's persisted state. - :vartype self.state: Dict + :vartype self.state: Dict[str, object] """ self.id: str = None # pylint: disable=invalid-name @@ -29,7 +29,7 @@ def __str__(self): .. remarks:: Positive values are indexes within the current DC and negative values are indexes in the parent DC. - :return: result + :return: Returns stack index. :rtype: str """ result = "\ndialog_instance_id: %s\n" % self.id From bef05785573e85b946f797d288ac26b03c6f1dc6 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 15:32:25 -0800 Subject: [PATCH 168/616] Updated formatting --- .../botbuilder/dialogs/dialog_turn_result.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index a3d6230ab..6a09a690e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -6,6 +6,10 @@ class DialogTurnResult: """ Result returned to the caller of one of the various stack manipulation methods. + + ..remarks: + Use :class:`DialogContext.end_dialogAsync()` to end a :class:`Dialog` and + return a result to the calling context. """ def __init__(self, status: DialogTurnStatus, result: object = None): """ @@ -19,25 +23,25 @@ def __init__(self, status: DialogTurnStatus, result: object = None): @property def status(self): - """ - Gets or sets the current status of the stack. - - :return self._status: - :rtype self._status: :class:`DialogTurnStatus` + """ + Gets or sets the current status of the stack. - """ + :return self._status: + :rtype self._status: :class:`DialogTurnStatus` + """ return self._status + + @property + def result(self): + """ + Final result returned by a dialog that just completed. - """ - Final result returned by a dialog that just completed. - ..remarks: - This will only be populated in certain cases: - - The bot calls `DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. - - The bot calls `DialogContext.continue_dialog()` and a dialog that was active ends. + ..remarks: + This will only be populated in certain cases: + - The bot calls `DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. + - The bot calls `DialogContext.continue_dialog()` and a dialog that was active ends. :return self._result: :rtype self._result: object """ - @property - def result(self): return self._result From 299cec4da6bd4fb4359ece5b5e0b43325253d6b8 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 15:33:04 -0800 Subject: [PATCH 169/616] Updated formatting --- .../botbuilder/dialogs/dialog_turn_result.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 6a09a690e..7e1bb4075 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -7,7 +7,7 @@ class DialogTurnResult: """ Result returned to the caller of one of the various stack manipulation methods. - ..remarks: + ..remarks:: Use :class:`DialogContext.end_dialogAsync()` to end a :class:`Dialog` and return a result to the calling context. """ @@ -36,7 +36,7 @@ def result(self): """ Final result returned by a dialog that just completed. - ..remarks: + ..remarks:: This will only be populated in certain cases: - The bot calls `DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. - The bot calls `DialogContext.continue_dialog()` and a dialog that was active ends. From c1cb806e886aa3f16e91a2b9c2489cccc38a65e9 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 22 Jan 2020 15:39:26 -0800 Subject: [PATCH 170/616] Updated formatting Not sure how to document method on line 36 --- .../botbuilder/dialogs/dialog_state.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index ba557631a..42bf51fc2 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -11,10 +11,12 @@ class DialogState: def __init__(self, stack: List[DialogInstance] = None): """ Initializes a new instance of the `DialogState` class. + .. remarks:: The new instance is created with an empty dialog stack. + :param stack: The state information to initialize the stack with. - :type stack: List + :type stack: List[:class:`DialogInstance`] """ if stack is None: self._dialog_stack = [] @@ -24,9 +26,8 @@ def __init__(self, stack: List[DialogInstance] = None): @property def dialog_stack(self): """ - Initializes a new instance of the `DialogState` class. - .. remarks:: - The new instance has a dialog stack that is populated using the information + Initializes a new instance of the :class:`DialogState` class. + :return: The state information to initialize the stack with. :rtype: List """ @@ -34,9 +35,8 @@ def dialog_stack(self): def __str__(self): """ - Gets or sets the state information for a dialog stack. - - :return: State information for a dialog stack + ? + :return: :rtype: str """ if not self._dialog_stack: From 8f3f4e16f68c63f9d0dbf5b5bb1b268ef946bcbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 23 Jan 2020 14:40:28 -0800 Subject: [PATCH 171/616] Azure pipelines YAML pipeline (#643) * Set up CI with Azure Pipelines for Axel [skip ci] * Update pipeline Co-authored-by: Steven Gum <14935595+stevengum@users.noreply.github.com> --- ci-pr-pipeline.yml | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 ci-pr-pipeline.yml diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml new file mode 100644 index 000000000..b97d5256f --- /dev/null +++ b/ci-pr-pipeline.yml @@ -0,0 +1,93 @@ +variables: + # Container registry service connection established during pipeline creation + CI_PULL_REQUEST: $(System.PullRequest.PullRequestId) + COVERALLS_FLAG_NAME: Build \# $(Build.BuildNumber) + COVERALLS_GIT_BRANCH: $(Build.SourceBranchName) + COVERALLS_GIT_COMMIT: $(Build.SourceVersion) + COVERALLS_SERVICE_JOB_ID: $(Build.BuildId) + COVERALLS_SERVICE_NAME: python-ci + python.version: 3.7.6 + + +jobs: +# Build and publish container +- job: Build +#Multi-configuration and multi-agent job options are not exported to YAML. Configure these options using documentation guidance: https://docs.microsoft.com/vsts/pipelines/process/phases + pool: + name: Hosted Ubuntu 1604 + #Your build pipeline references the ‘python.version’ variable, which you’ve selected to be settable at queue time. Create or edit the build pipeline for this YAML file, define the variable on the Variables tab, and then select the option to make it settable at queue time. See https://go.microsoft.com/fwlink/?linkid=865971 + #Your build pipeline references the ‘python.version’ variable, which you’ve selected to be settable at queue time. Create or edit the build pipeline for this YAML file, define the variable on the Variables tab, and then select the option to make it settable at queue time. See https://go.microsoft.com/fwlink/?linkid=865971 + + steps: + - powershell: | + Get-ChildItem env:* | sort-object name | Format-Table -Autosize -Wrap | Out-String -Width 120 + displayName: 'Get environment vars' + + - task: UsePythonVersion@0 + displayName: 'Use Python $(python.version)' + inputs: + versionSpec: '$(python.version)' + + - script: 'sudo ln -s /opt/hostedtoolcache/Python/3.6.9/x64/lib/libpython3.6m.so.1.0 /usr/lib/libpython3.6m.so' + displayName: libpython3.6m + + - script: | + python -m pip install --upgrade pip + pip install -e ./libraries/botbuilder-schema + pip install -e ./libraries/botframework-connector + pip install -e ./libraries/botbuilder-core + pip install -e ./libraries/botbuilder-ai + pip install -e ./libraries/botbuilder-applicationinsights + pip install -e ./libraries/botbuilder-dialogs + pip install -e ./libraries/botbuilder-azure + pip install -e ./libraries/botbuilder-testing + pip install -r ./libraries/botframework-connector/tests/requirements.txt + pip install -r ./libraries/botbuilder-core/tests/requirements.txt + pip install coveralls + pip install pylint + pip install black + displayName: 'Install dependencies' + + - script: 'pip install requests_mock' + displayName: 'Install requests mock (REMOVE AFTER MERGING INSPECTION)' + enabled: false + + - script: | + pip install pytest + pip install pytest-cov + pip install coveralls + pytest --junitxml=junit/test-results.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + displayName: Pytest + + - script: 'black --check libraries' + displayName: 'Check Black compliant' + + - script: 'pylint --rcfile=.pylintrc libraries' + displayName: Pylint + + - task: PublishTestResults@2 + displayName: 'Publish Test Results **/test-results.xml' + inputs: + testResultsFiles: '**/test-results.xml' + testRunTitle: 'Python $(python.version)' + + - script: 'COVERALLS_REPO_TOKEN=$(COVERALLS_TOKEN) coveralls' + displayName: 'Push test results to coveralls https://coveralls.io/github/microsoft/botbuilder-python' + continueOnError: true + + - powershell: | + Set-Location .. + Get-ChildItem -Recurse -Force + + displayName: 'Dir workspace' + condition: succeededOrFailed() + + - powershell: | + # This task copies the code coverage file created by dotnet test into a well known location. In all + # checks I've done, dotnet test ALWAYS outputs the coverage file to the temp directory. + # My attempts to override this and have it go directly to the CodeCoverage directory have + # all failed, so I'm just doing the copy here. (cmullins) + + Get-ChildItem -Path "$(Build.SourcesDirectory)" -Include "*coverage*" | Copy-Item -Destination "$(Build.ArtifactStagingDirectory)/CodeCoverage" + displayName: 'Copy .coverage Files to CodeCoverage folder' + continueOnError: true From b9cc4179aabe3d97df339ac92d40a38f85e7d8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 24 Jan 2020 10:55:41 -0800 Subject: [PATCH 172/616] Axsuarez/update badge (#645) * Update build badge in root readme * Updating build badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5981ef94f..c44cb1099 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ### [Click here to find out what's new with Bot Framework](https://github.com/Microsoft/botframework/blob/master/whats-new.md#whats-new) # Bot Framework SDK v4 for Python (Preview) -[![Build status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=431) +[![Build Status](https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR?branchName=master)](https://dev.azure.com/FuseLabs/SDK_v4/_build/latest?definitionId=771&branchName=master) [![roadmap badge](https://img.shields.io/badge/visit%20the-roadmap-blue.svg)](https://github.com/Microsoft/botbuilder-python/wiki/Roadmap) [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) From 9759e56d9be4c1203e64d3f226eb442b4c006079 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 13:45:00 -0800 Subject: [PATCH 173/616] Fixed formatting Removed docstring for `def __str__(self)` --- .../botbuilder/dialogs/dialog_state.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 42bf51fc2..218caf5d0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -10,10 +10,8 @@ class DialogState: """ def __init__(self, stack: List[DialogInstance] = None): """ - Initializes a new instance of the `DialogState` class. - - .. remarks:: - The new instance is created with an empty dialog stack. + Initializes a new instance of the :class:`DialogState` class. + The new instance is created with an empty dialog stack. :param stack: The state information to initialize the stack with. :type stack: List[:class:`DialogInstance`] @@ -34,11 +32,6 @@ def dialog_stack(self): return self._dialog_stack def __str__(self): - """ - ? - :return: - :rtype: str - """ if not self._dialog_stack: return "dialog stack empty!" return " ".join(map(str, self._dialog_stack)) From 695bd5d33c733e2730ab6e1cdd7d85f1e2dce9c5 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 13:48:47 -0800 Subject: [PATCH 174/616] Fixed formatting --- .../botbuilder/dialogs/dialog_turn_result.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 7e1bb4075..d02ecaa4a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -6,10 +6,9 @@ class DialogTurnResult: """ Result returned to the caller of one of the various stack manipulation methods. - - ..remarks:: - Use :class:`DialogContext.end_dialogAsync()` to end a :class:`Dialog` and - return a result to the calling context. + + Use :meth:`DialogContext.end_dialogAsync()` to end a :class:`Dialog` and + return a result to the calling context. """ def __init__(self, status: DialogTurnStatus, result: object = None): """ @@ -36,12 +35,12 @@ def result(self): """ Final result returned by a dialog that just completed. - ..remarks:: - This will only be populated in certain cases: - - The bot calls `DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. - - The bot calls `DialogContext.continue_dialog()` and a dialog that was active ends. + .. note:: + This will only be populated in certain cases: + * The bot calls :meth:`DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. + * The bot calls :meth:`DialogContext.continue_dialog()` and a dialog that was active ends. - :return self._result: + :return self._result: Final result returned by a dialog that just completed. :rtype self._result: object """ return self._result From ce58a6fd830ac0447e3afa513ff81d50cc674aac Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 13:50:47 -0800 Subject: [PATCH 175/616] Fixed formatting --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index 83afefbe6..e4aa2bf24 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -26,9 +26,6 @@ def __str__(self): """ Gets or sets a stack index. - .. remarks:: - Positive values are indexes within the current DC and negative values are indexes in the parent DC. - :return: Returns stack index. :rtype: str """ From af0dc932e9e67e95898543d1aad708cdc7667afd Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 14:21:08 -0800 Subject: [PATCH 176/616] Fixed formatting --- .../botbuilder/dialogs/component_dialog.py | 105 ++++++++---------- 1 file changed, 48 insertions(+), 57 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index ed676119d..288bc7b5d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -16,12 +16,11 @@ class ComponentDialog(Dialog): """ A :class:`Dialog` that is composed of other dialogs - - ..remarks: - - A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, - which provides an inner dialog stack that is hidden from the parent dialog. - :var persisted_dialog state: ? + + A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, + which provides an inner dialog stack that is hidden from the parent dialog. + + :var persisted_dialog state: :vartype persisted_dialog_state: str """ persisted_dialog_state = "dialogs" @@ -49,10 +48,8 @@ async def begin_dialog( """ Called when the dialog is started and pushed onto the parent's dialog stack. - ..remarks:: - - If the task is successful, the result indicates whether the dialog is still - active after the turn has been processed by the dialog. + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` @@ -84,17 +81,16 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. - ..remarks:: + .. note:: + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. The result may also + contain a return value. - If the task is successful, the result indicates whether the dialog is still - active after the turn has been processed by the dialog. The result may also - contain a return value. - - If this method is *not* overriden the component dialog calls the - :meth:`DialogContext.continue_dialog` method on it's inner dialog - context. If the inner dialog stack is empty, the component dialog ends, - and if a :class:`DialogTurnResult.result` is available, the component dialog - uses that as it's return value. + If this method is *not* overriden the component dialog calls the + :meth:`DialogContext.continue_dialog` method on it's inner dialog + context. If the inner dialog stack is empty, the component dialog ends, + and if a :class:`DialogTurnResult.result` is available, the component dialog + uses that as it's return value. :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. @@ -122,17 +118,16 @@ async def resume_dialog( Called when a child dialog on the parent's dialog stack completed this turn, returning control to this dialog component. - ..remarks:: - - If the task is successful, the result indicates whether this dialog is still - active after this dialog turn has been processed. + .. note:: + If the task is successful, the result indicates whether this dialog is still + active after this dialog turn has been processed. - Generally, the child dialog was started with a call to :meth:`def async begin_dialog()` - in the parent's context. However, if the :meth:`DialogContext.replace_dialog()` method is - is called, the logical child dialog may be different than the original. + Generally, the child dialog was started with a call to :meth:`def async begin_dialog()` + in the parent's context. However, if the :meth:`DialogContext.replace_dialog()` method is + is called, the logical child dialog may be different than the original. - If this method is *not* overridden, the dialog automatically calls its - :meth:`asyn def reprompt_dialog()` when the user replies. + If this method is *not* overridden, the dialog automatically calls its + :meth:`asyn def reprompt_dialog()` when the user replies. :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` @@ -144,7 +139,7 @@ async def resume_dialog( :rtype: :class:`Dialog.end_of_turn` """ - """ (not sure where to put this information) + """ Containers are typically leaf nodes on the stack but the dev is free to push other dialogs on top of the stack which will result in the container receiving an unexpected call to resume_dialog() when the pushed on dialog ends. @@ -177,7 +172,7 @@ async def end_dialog( self, context: TurnContext, instance: DialogInstance, reason: DialogReason ) -> None: """ - Called when the dialog is ending. + Called when the dialog is ending. :param context: The context object for this turn. :type context: :class:`TurnContext` @@ -196,9 +191,8 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ Adds a :class:`Dialog` to the component dialog and returns the updated component. - - ..remarks:: - Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. + Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. + :param dialog: The dialog to add. :return: The updated :class:`ComponentDialog` :rtype: :class:`ComponentDialog` @@ -211,9 +205,7 @@ def add_dialog(self, dialog: Dialog) -> object: def find_dialog(self, dialog_id: str) -> Dialog: """ Finds a dialog by ID. - - ..remarks:: - Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. + Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. :param dialog_id: The dialog to add. :return: The dialog; or None if there is not a match for the ID. @@ -227,15 +219,15 @@ async def on_begin_dialog( """ Called when the dialog is started and pushed onto the parent's dialog stack. - ..remarks:: + .. note:: - If the task is successful, the result indicates whether the dialog is still - active after the turn has been processed by the dialog. + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. - By default, this calls the :meth:`Dialog.begin_dialog()` method of the component - dialog's initial dialog, as defined by ?. + By default, this calls the :meth:`Dialog.begin_dialog()` method of the component + dialog's initial dialog. - Override this method in a derived class to implement interrupt logic. + Override this method in a derived class to implement interrupt logic. :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. :type inner_dc: :class:`DialogContext` @@ -268,7 +260,7 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument self, turn_context: TurnContext, instance: DialogInstance ) -> None: """ - :param turn_context: + :param turn_context: The :class:`TurnContext` for the current turn of the conversation. :type turn_context: :class:`DialogInstance` :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. :type instance: :class:`DialogInstance` @@ -280,19 +272,18 @@ async def end_component( ) -> DialogTurnResult: """ Ends the component dialog in its parent's context. - - ..remarks:: - If the task is successful, the result indicates that the dialog ended after the - turn was processed by the dialog. - - In general, the parent context is the dialog or bot turn handler that started the dialog. - If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method - to return a result to the parent dialog. If the parent dialog does not implement - :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next - parent context, if one exists. - - The returned :class:`DialogTurnResult`contains the return value in its - :class:`DialogTurnResult.result` property. + .. note:: + If the task is successful, the result indicates that the dialog ended after the + turn was processed by the dialog. + + In general, the parent context is the dialog or bot turn handler that started the dialog. + If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method + to return a result to the parent dialog. If the parent dialog does not implement + :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next + parent context, if one exists. + + The returned :class:`DialogTurnResult`contains the return value in its + :class:`DialogTurnResult.result` property. :param outer_dc: The parent class:`DialogContext` for the current turn of conversation. :type outer_dc: class:`DialogContext` From f75cffd0a1ab4fe8cd727fbfb28ae75ee47b7904 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 14:22:53 -0800 Subject: [PATCH 177/616] Update bot_state.py --- .../botbuilder/core/bot_state.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index a05244832..eadbefc16 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -13,7 +13,7 @@ class CachedBotState: """ - Internal cached bot state + Internal cached bot state. """ def __init__(self, state: Dict[str, object] = None): @@ -30,9 +30,9 @@ def compute_hash(self, obj: object) -> str: class BotState(PropertyManager): - """ Defines a state management object + """ Defines a state management object and automates the reading and writing of - associated state properties to a storage layer + associated state properties to a storage layer. .. remarks:: Each state management object defines a scope for a storage layer. @@ -42,7 +42,8 @@ class BotState(PropertyManager): """ def __init__(self, storage: Storage, context_service_key: str): - """ Initializes a new instance of the :class:`BotState` class. + """ + Initializes a new instance of the :class:`BotState` class. :param storage: The storage layer this state management object will use to store and retrieve state :type storage: :class:`bptbuilder.core.Storage` @@ -54,7 +55,7 @@ def __init__(self, storage: Storage, context_service_key: str): the :param storage: to persist state property values and the :param context_service_key: to cache state within the context for each turn. - :raises: It raises an argument null exception + :raises: It raises an argument null exception. """ self.state_key = "state" self._storage = storage @@ -63,9 +64,10 @@ def __init__(self, storage: Storage, context_service_key: str): def create_property(self, name: str) -> StatePropertyAccessor: """ Create a property definition and register it with this :class:`BotState`. + :param name: The name of the property :type name: str - :return: If successful, the state property accessor created. + :return: If successful, the state property accessor created :rtype: :class:`StatePropertyAccessor` """ if not name: @@ -79,7 +81,8 @@ def get(self, turn_context: TurnContext) -> Dict[str, object]: async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ - Reads in the current state object and caches it in the context object for this turn. + Reads the current state object and caches it in the context object for this turn. + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :param force: Optional, true to bypass the cache @@ -99,12 +102,13 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: async def save_changes( self, turn_context: TurnContext, force: bool = False ) -> None: - """Save the state cached in the current context for this turn - If it has changed, save the state cached in the current context for this turn. + """ + Saves the state cached in the current context for this turn. + If the state has changed, it saves the state cached in the current context for this turn. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` - :param force: Optional, true to save state to storage whether or not there are changes. + :param force: Optional, true to save state to storage whether or not there are changes :type force: bool """ if turn_context is None: @@ -119,7 +123,8 @@ async def save_changes( cached_state.hash = cached_state.compute_hash(cached_state.state) async def clear_state(self, turn_context: TurnContext): - """Clears any state currently stored in this state scope + """ + Clears any state currently stored in this state scope. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -138,7 +143,8 @@ async def clear_state(self, turn_context: TurnContext): turn_context.turn_state[self._context_service_key] = cache_value async def delete(self, turn_context: TurnContext) -> None: - """Deletes any state currently stored in this state scope. + """ + Deletes any state currently stored in this state scope. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -158,7 +164,8 @@ def get_storage_key(self, turn_context: TurnContext) -> str: raise NotImplementedError() async def get_property_value(self, turn_context: TurnContext, property_name: str): - """Gets the value of the specified property in the turn context + """ + Gets the value of the specified property in the turn context. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -184,7 +191,8 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str async def delete_property_value( self, turn_context: TurnContext, property_name: str ) -> None: - """Deletes a property from the state cache in the turn context + """ + Deletes a property from the state cache in the turn context. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -203,7 +211,8 @@ async def delete_property_value( async def set_property_value( self, turn_context: TurnContext, property_name: str, value: object ) -> None: - """Sets a property to the specified value in the turn context + """ + Sets a property to the specified value in the turn context. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` From 7e72b0fe83e13d5e34c2e014a613a7c0b2a504a1 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 14:24:42 -0800 Subject: [PATCH 178/616] Fixed formatting --- .../dialogs/prompts/activity_prompt.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index 3a70d17cc..e849e47c0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -23,14 +23,14 @@ class ActivityPrompt(Dialog, ABC): """ Waits for an activity to be received. - ..remarks: + .. remarks: This prompt requires a validator be passed in and is useful when waiting for non-message activities like an event to be received. The validator can ignore received events until the expected activity is received. - :var persisted_options: ? + :var persisted_options: :typevar persisted_options: str - :var persisted_state: ? + :var persisted_state: :vartype persisted_state: str """ @@ -153,17 +153,19 @@ async def resume_dialog( # pylint: disable=unused-argument ): """ Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. - ..remarks: + + .. note: Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs on top of the stack which will result in the prompt receiving an unexpected call to - resume_dialog() when the pushed on dialog ends. + :meth:resume_dialog() when the pushed on dialog ends. To avoid the prompt prematurely ending, we need to implement this method and - simply re-prompt the user. + simply re-prompt the user. + :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` :param reason: An enum indicating why the dialog resumed. :type reason: :class:`DialogReason` - :param result: >Optional, value returned from the previous dialog on the stack. The type of the value returned is dependent on the previous dialog. + :param result: Optional, value returned from the previous dialog on the stack. The type of the value returned is dependent on the previous dialog. :type result: object """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) From 63ff9814ddd79fa9509a983c4b54b94a83cda142 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 14:25:51 -0800 Subject: [PATCH 179/616] Update activity_prompt.py --- .../botbuilder/dialogs/prompts/activity_prompt.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index e849e47c0..15f7f9cdc 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -152,7 +152,8 @@ async def resume_dialog( # pylint: disable=unused-argument self, dialog_context: DialogContext, reason: DialogReason, result: object = None ): """ - Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. + Called when a prompt dialog resumes being the active dialog on the dialog stack, such + as when the previous active dialog on the stack completes. .. note: Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs @@ -165,7 +166,7 @@ async def resume_dialog( # pylint: disable=unused-argument :type dialog_context: :class:`DialogContext` :param reason: An enum indicating why the dialog resumed. :type reason: :class:`DialogReason` - :param result: Optional, value returned from the previous dialog on the stack. The type of the value returned is dependent on the previous dialog. + :param result: Optional, value returned from the previous dialog on the stack. :type result: object """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) From 7426e9fb86b078c130bd1a9b68eda3e2f72011b7 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 24 Jan 2020 14:31:07 -0800 Subject: [PATCH 180/616] Fixed formatting --- .../botbuilder/dialogs/component_dialog.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 288bc7b5d..b297443df 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -119,6 +119,12 @@ async def resume_dialog( control to this dialog component. .. note:: + Containers are typically leaf nodes on the stack but the dev is free to push other dialogs + on top of the stack which will result in the container receiving an unexpected call to + :meth:resume_dialog() when the pushed on dialog ends. + To avoid the container prematurely ending we need to implement this method and simply + ask our inner dialog stack to re-prompt. + If the task is successful, the result indicates whether this dialog is still active after this dialog turn has been processed. @@ -139,13 +145,6 @@ async def resume_dialog( :rtype: :class:`Dialog.end_of_turn` """ - """ - Containers are typically leaf nodes on the stack but the dev is free to push other dialogs - on top of the stack which will result in the container receiving an unexpected call to - resume_dialog() when the pushed on dialog ends. - To avoid the container prematurely ending we need to implement this method and simply - ask our inner dialog stack to re-prompt. - """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) return Dialog.end_of_turn @@ -220,7 +219,6 @@ async def on_begin_dialog( Called when the dialog is started and pushed onto the parent's dialog stack. .. note:: - If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. @@ -272,6 +270,7 @@ async def end_component( ) -> DialogTurnResult: """ Ends the component dialog in its parent's context. + .. note:: If the task is successful, the result indicates that the dialog ended after the turn was processed by the dialog. From 6b97814b469d8441edc6725cf12668221c981611 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 14:34:33 -0800 Subject: [PATCH 181/616] Update conversation_state.py Code comments formatting. --- .../botbuilder/core/conversation_state.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 954620de1..fd39935e0 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -7,17 +7,19 @@ class ConversationState(BotState): - """Conversation state + """ Defines a state management object for conversation state. Extends :class:`BootState` base class. .. remarks:: - Conversation state is available in any turn in a specific conversation, regardless of user, such as in a group conversation. + Conversation state is available in any turn in a specific conversation, regardless of the user, such as in a group conversation. """ no_key_error_message = "ConversationState: channelId and/or conversation missing from context.activity." def __init__(self, storage: Storage): - """ Creates a :class:`ConversationState` instance + """ + Creates a :class:`ConversationState` instance. + Creates a new instance of the :class:`ConversationState` class. :param storage: The storage containing the conversation state. :type storage: :class:`Storage` @@ -25,7 +27,7 @@ def __init__(self, storage: Storage): super(ConversationState, self).__init__(storage, "ConversationState") def get_storage_key(self, turn_context: TurnContext) -> object: - """ Get storage key + """ Gets the key to use when reading and writing state to and from storage. :param turn_context: The context object for this turn. @@ -39,7 +41,7 @@ def get_storage_key(self, turn_context: TurnContext) -> object: :rtype: str .. remarks:: - Conversation state includes the channel Id and conversation Id as part of its storage key. + Conversation state includes the channel ID and conversation ID as part of its storage key. """ channel_id = turn_context.activity.channel_id or self.__raise_type_error( "invalid activity-missing channel_id" From f98a391411f72d4c87a9e80d1ad5bb2b1793fa0d Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 14:56:20 -0800 Subject: [PATCH 182/616] Update prompt.py Code comments formatting. --- .../botbuilder/dialogs/prompts/prompt.py | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index d3ab5a80f..c64b8ad39 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -23,16 +23,14 @@ class Prompt(Dialog): - """ Prompts base class + """ Defines the core behavior of prompt dialogs. Extends the :class:`Dialog` base class. .. remarks:: When the prompt ends, it returns an object that represents the value it was prompted for. - Use :method:`DialogSet.add(self, dialog: Dialog)` or :method:`ComponentDialog.add_dialog(self, dialog: Dialog)` to add a prompt - to a dialog set or component dialog, respectively. - Use :method:`DialogContext.prompt(self, dialog_id: str, options)` or - :meth:`DialogContext.begin_dialog( - self, dialog_context: DialogContext, options: object = None)` to start the prompt. + Use :method:`DialogSet.add()` or :method:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or + component dialog, respectively. + Use :method:`DialogContext.prompt()` or :meth:`DialogContext.begin_dialog()` to start the prompt. .. note:: If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result will be available in the next step of the waterfall. @@ -42,7 +40,9 @@ class Prompt(Dialog): persisted_state = "state" def __init__(self, dialog_id: str, validator: object = None): - """Creates a new Prompt instance + """ + Creates a new :class:`Prompt` instance. + :param dialog_id: Unique Id of the prompt within its parent :class:`DialogSet` or :class:`ComponentDialog`. :type dialog_id: str @@ -56,12 +56,12 @@ def __init__(self, dialog_id: str, validator: object = None): async def begin_dialog( self, dialog_context: DialogContext, options: object = None ) -> DialogTurnResult: - """ Starts a prompt dialog. - Called when a prompt dialog is pushed onto the dialog stack and is being activated. + """ + Starts a prompt dialog. Called when a prompt dialog is pushed onto the dialog stack and is being activated. - :param dialog_context: The dialog context for the current turn of the conversation. + :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` - :param options: Optional, additional information to pass to the prompt being started. + :param options: Optional, additional information to pass to the prompt being started :type options: Object :return: The dialog turn result :rtype: :class:`DialogTurnResult` @@ -97,10 +97,10 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext): - """ Continues a dialog. - Called when a prompt dialog is the active dialog and the user replied with a new activity. + """ + Continues a dialog. Called when a prompt dialog is the active dialog and the user replied with a new activity. - :param dialog_context: The dialog context for the current turn of the conversation. + :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` :return: The dialog turn result :rtype: :class:`DialogTurnResult` @@ -148,16 +148,16 @@ async def continue_dialog(self, dialog_context: DialogContext): async def resume_dialog( self, dialog_context: DialogContext, reason: DialogReason, result: object ) -> DialogTurnResult: - """ Resumes a dialog. - Called when a prompt dialog resumes being the active dialog on the dialog stack, such as - when the previous active dialog on the stack completes. + """ + Resumes a dialog. Called when a prompt dialog resumes being the active dialog + on the dialog stack, such as when the previous active dialog on the stack completes. :param dialog_context: The dialog context for the current turn of the conversation. :type dialog_context: :class:DialogContext :param reason: An enum indicating why the dialog resumed. :type reason: :class:DialogReason - :param result: Optional, value returned from the previous dialog on the stack. - The type of the value returned is dependent on the previous dialog. + :param result: Optional, value returned from the previous dialog on the stack. + The type of the value returned is dependent on the previous dialog. :type result: object :return: The dialog turn result :rtype: :class:`DialogTurnResult` @@ -174,14 +174,14 @@ async def resume_dialog( return Dialog.end_of_turn async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): - """ Reprompts user for input. - Called when a prompt dialog has been requested to re-prompt the user for input. + """ + Reprompts user for input. Called when a prompt dialog has been requested to re-prompt the user for input. - :param context: Context for the current turn of conversation with the user. + :param context: Context for the current turn of conversation with the user :type context: :class:TurnContext - :param instance: The instance of the dialog on the stack. + :param instance: The instance of the dialog on the stack :type instance: :class:DialogInstance - :return: A :class:Task representing the asynchronous operation. + :return: A :class:Task representing the asynchronous operation :rtype: :class:Task """ state = instance.state[self.persisted_state] @@ -196,18 +196,18 @@ async def on_prompt( options: PromptOptions, is_retry: bool, ): - """ Prompts user for input. - When overridden in a derived class, prompts the user for input. + """ + Prompts user for input. When overridden in a derived class, prompts the user for input. - :param turn_context: Context for the current turn of conversation with the user. + :param turn_context: Context for the current turn of conversation with the user :type turn_context: :class:`TurnContext` - :param state: Contains state for the current instance of the prompt on the dialog stack. + :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided - in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)`. + in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` :type options: :class:`PromptOptions` :param is_retry: true if this is the first time this prompt dialog instance on the stack is prompting - the user for input; otherwise, false. + the user for input; otherwise, false :type is_retry: bool :return: A :class:Task representing the asynchronous operation. @@ -222,12 +222,12 @@ async def on_recognize( state: Dict[str, object], options: PromptOptions, ): - """ Recognizes the user's input. - When overridden in a derived class, attempts to recognize the user's input. + """ + Recognizes the user's input. When overridden in a derived class, attempts to recognize the user's input. - :param turn_context: Context for the current turn of conversation with the user. + :param turn_context: Context for the current turn of conversation with the user :type turn_context: :class:`TurnContext` - :param state: Contains state for the current instance of the prompt on the dialog stack. + :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` @@ -246,21 +246,22 @@ def append_choices( style: ListStyle, options: ChoiceFactoryOptions = None, ) -> Activity: - """ Composes an output activity containing a set of choices. + """ + Composes an output activity containing a set of choices. When overridden in a derived class, appends choices to the activity when the user is prompted for input. Helper function to compose an output activity containing a set of choices. - :param prompt: The prompt to append the user's choice to. + :param prompt: The prompt to append the user's choice to :type prompt: - :param channel_id: Id of the channel the prompt is being sent to. + :param channel_id: Id of the channel the prompt is being sent to :type channel_id: str - :param: choices: List of choices to append. + :param: choices: List of choices to append :type choices: :class:`List` - :param: style: Configured style for the list of choices. + :param: style: Configured style for the list of choices :type style: :class:`ListStyle` - :param: options: Optional formatting options to use when presenting the choices. + :param: options: Optional formatting options to use when presenting the choices :type style: :class:`ChoiceFactoryOptions` - :return: A :class:Task representing the asynchronous operation. + :return: A :class:Task representing the asynchronous operation :rtype: :class:Task .. remarks:: From 1a2cc75d0eee62764f8399e0956fb320b382c652 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 15:07:35 -0800 Subject: [PATCH 183/616] Update bot_state.py Chaanged remarks to note in method as per feedback. --- libraries/botbuilder-core/botbuilder/core/bot_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index eadbefc16..2718c1889 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -50,7 +50,7 @@ def __init__(self, storage: Storage, context_service_key: str): :param context_service_key: The key for the state cache for this :class:`BotState` :type context_service_key: str - .. remarks:: + .. note:: This constructor creates a state management object and associated scope. The object uses the :param storage: to persist state property values and the :param context_service_key: to cache state within the context for each turn. @@ -131,7 +131,7 @@ async def clear_state(self, turn_context: TurnContext): :return: None - .. notes:: + .. note:: This function must be called in order for the cleared state to be persisted to the underlying store. """ if turn_context is None: From 8703ddb15ec8ac7ec4461a5f6127e1483babc6bd Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 15:18:55 -0800 Subject: [PATCH 184/616] Update oauth_prompt.py Code comments formatting and replaced remarks with note in methods. --- .../dialogs/prompts/oauth_prompt.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 731ac6ecd..b6f726689 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -32,8 +32,8 @@ class OAuthPrompt(Dialog): - """Creates a new prompt for the user to sign in - Creates a new prompt that asks the user to sign in using the Bot Framework Single Sign On (SSO) service. + """ + Creates a new prompt that asks the user to sign in, using the Bot Framework Single Sign On (SSO) service. .. remarks:: The prompt will attempt to retrieve the users current token and if the user isn't signed in, it @@ -67,17 +67,17 @@ def __init__( settings: OAuthPromptSettings, validator: Callable[[PromptValidatorContext], Awaitable[bool]] = None, ): - """ Creates a :class:`OAuthPrompt` instance + """ Creates a new instance of the :class:`OAuthPrompt` class. :param dialogId: The Id to assign to this prompt. :type dialogId: str - :param settings: Additional authentication settings to use with this instance of the prompt. + :param settings: Additional authentication settings to use with this instance of the prompt :type settings: :class:`OAuthPromptSettings` - :param validator: Optional, a :class:`PromptValidator` that contains additional, custom validation for this prompt. + :param validator: Optional, a :class:`PromptValidator` that contains additional, custom validation for this prompt :type validator: :class:`PromptValidatorContext` - .. remarks:: + .. note:: The value of :param dialogId: must be unique within the :class:`DialogSet`or :class:`ComponentDialog` to which the prompt is added. """ super().__init__(dialog_id) @@ -94,19 +94,18 @@ def __init__( async def begin_dialog( self, dialog_context: DialogContext, options: PromptOptions = None ) -> DialogTurnResult: - """ Starts an authentication prompt dialog. - Called when an authentication prompt dialog is pushed onto the dialog stack and is being activated. + """ + Starts an authentication prompt dialog. Called when an authentication prompt dialog is pushed onto the dialog stack and is being activated. - :param dialog_context: The dialog context for the current turn of the conversation. + :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` - :param options: Optional, additional information to pass to the prompt being started. + :param options: Optional, additional information to pass to the prompt being started :type options: :class:PromptOptions :return: Dialog turn result :rtype: :class:DialogTurnResult - .. remarks:: - If the task is successful, the result indicates whether the prompt is still active - after the turn has been processed by the prompt. + .. note:: + If the task is successful, the result indicates whether the prompt is still active after the turn has been processed by the prompt. """ if dialog_context is None: raise TypeError( @@ -149,15 +148,15 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: - """ Continues a dialog. - Called when a prompt dialog is the active dialog and the user replied with a new activity. + """ + Continues a dialog. Called when a prompt dialog is the active dialog and the user replied with a new activity. - :param dialog_context: The dialog context for the current turn of the conversation. + :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` :return: Dialog turn result :rtype: :class:DialogTurnResult - .. remarks:: + .. note:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The prompt generally continues to receive the user's replies until it accepts the @@ -210,15 +209,15 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu async def get_user_token( self, context: TurnContext, code: str = None ) -> TokenResponse: - """Gets the user's token - Attempts to get the user's token. + """ + Gets the user's tokeN. - :param context: Context for the current turn of conversation with the user. + :param context: Context for the current turn of conversation with the user :type context: :class:TurnContext :return: A response that includes the user's token :rtype: :class:TokenResponse - .. remarks:: + .. note:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ @@ -235,14 +234,15 @@ async def get_user_token( ) async def sign_out_user(self, context: TurnContext): - """Signs out the user + """ + Signs out the user - :param context: Context for the current turn of conversation with the user. + :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` - :return: A :class:`Task` representing the work queued to execute. + :return: A :class:`Task` representing the work queued to execute :rtype: :class:`Task` - .. remarks:: + .. note:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ From 1a2e82968de8fe4402626563c25242d2a386c7b9 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 15:42:10 -0800 Subject: [PATCH 185/616] Update prompt.py Changed reamrks to note in methods. --- .../botbuilder/dialogs/prompts/prompt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index c64b8ad39..842c606f1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -66,7 +66,7 @@ async def begin_dialog( :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. remarks:: + .. note:: If the task is successful, the result indicates whether the prompt is still active after the turn has been processed by the prompt. """ @@ -105,7 +105,7 @@ async def continue_dialog(self, dialog_context: DialogContext): :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. remarks:: + .. note:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The prompt generally continues to receive the user's replies until it accepts the @@ -162,7 +162,7 @@ async def resume_dialog( :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. remarks:: + .. note:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs @@ -264,7 +264,7 @@ def append_choices( :return: A :class:Task representing the asynchronous operation :rtype: :class:Task - .. remarks:: + .. note:: If the task is successful, the result contains the updated activity. """ # Get base prompt text (if any) From 8fa2dee563975b2bf90068a6cf7a0312463a7317 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 15:46:40 -0800 Subject: [PATCH 186/616] Update prompt_options.py Code comments formatting. --- .../botbuilder/dialogs/prompts/prompt_options.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py index 2a3d6ef38..ea0c74825 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py @@ -8,7 +8,7 @@ class PromptOptions: - """ Contains prompt settings + """ Contains settings to pass to a :class:`Prompt` object when the prompt is started. """ @@ -21,7 +21,8 @@ def __init__( validations: object = None, number_of_attempts: int = 0, ): - """ Sets the initial prompt to send to the user as an :class:`botbuilder.schema.Activity`. + """ + Sets the initial prompt to send to the user as an :class:`botbuilder.schema.Activity`. :param prompt: The initial prompt to send to the user :type prompt: :class:`botbuilder.schema.Activity` From 526d2ebbf9d71383ebaa77cdd18cb92edb7462cd Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 16:04:07 -0800 Subject: [PATCH 187/616] Update bot_framework_adapter.py Code comments formatting and replaced remarks with note in methods. --- .../botbuilder/core/bot_framework_adapter.py | 79 +++++++++++-------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index bc7f09b92..a717051e7 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -83,7 +83,8 @@ def __init__( channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, ): - """Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. + """ + Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. :param app_id: The bot application ID. This is the appId returned by the Azure portal registration, and is the value of the `MicrosoftAppId` parameter in the `config.py` file. @@ -116,7 +117,8 @@ def __init__( class BotFrameworkAdapter(BotAdapter, UserTokenProvider): - """Defines an adapter to connect a bot to a service endpoint + """ + Defines an adapter to connect a bot to a service endpoint. .. remarks:: The bot adapter encapsulates authentication processes and sends activities to and @@ -132,10 +134,11 @@ class BotFrameworkAdapter(BotAdapter, UserTokenProvider): _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" def __init__(self, settings: BotFrameworkAdapterSettings): - """ Initializes a new instance of the :class:`BotFrameworkAdapter` class + """ + Initializes a new instance of the :class:`BotFrameworkAdapter` class. - :param settings: The settings to initialize the adapter - :type settings: :class:`BotFrameworkAdapterSettings` + :param settings: The settings to initialize the adapter + :type settings: :class:`BotFrameworkAdapterSettings` """ super(BotFrameworkAdapter, self).__init__() self.settings = settings or BotFrameworkAdapterSettings("", "") @@ -180,7 +183,8 @@ async def continue_conversation( bot_id: str = None, claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): - """Continues a conversation with a user + """ + Continues a conversation with a user. :param reference: A reference to the conversation to continue :type reference: :class:`botbuilder.schema.ConversationReference @@ -192,11 +196,11 @@ async def continue_conversation( :param claims_identity: The bot claims identity :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` - :raises: It raises an argument null exception + :raises: It raises an argument null exception. - :return: A task that represents the work queued to execute + :return: A task that represents the work queued to execute. - .. remarks:: + .. note:: This is often referred to as the bots *proactive messaging* flow as it lets the bot proactively send messages to a conversation or user that are already in a communication. Scenarios such as sending notifications or coupons to a user are enabled by this function. @@ -225,8 +229,9 @@ async def create_conversation( logic: Callable[[TurnContext], Awaitable] = None, conversation_parameters: ConversationParameters = None, ): - """Starts a new conversation with a user - Used to direct message to a member of a group + """ + Starts a new conversation with a user. Used to direct message to a member of a group. + :param reference: The conversation reference that contains the tenant :type reference: :class:`botbuilder.schema.ConversationReference` :param logic: The logic to use for the creation of the conversation @@ -234,11 +239,11 @@ async def create_conversation( :param conversation_parameters: The information to use to create the conversation :type conversation_parameters: - :raises: It raises a generic exception error + :raises: It raises a generic exception error. - :return: A task representing the work queued to execute + :return: A task representing the work queued to execute. - .. remarks:: + .. note:: To start a conversation, your bot must know its account information and the user's account information on that channel. Most channels only support initiating a direct message (non-group) conversation. @@ -296,7 +301,8 @@ async def create_conversation( raise error async def process_activity(self, req, auth_header: str, logic: Callable): - """Creates a turn context and runs the middleware pipeline for an incoming activity + """ + Creates a turn context and runs the middleware pipeline for an incoming activity, Processes an activity received by the bots web server. This includes any messages sent from a user and is the method that drives what's often referred to as the bots *reactive messaging* flow. @@ -311,7 +317,7 @@ async def process_activity(self, req, auth_header: str, logic: Callable): was `Invoke` and the corresponding key (`channelId` + `activityId`) was found then an :class:`InvokeResponse` is returned; otherwise, `null` is returned. - .. remarks:: + .. note:: Call this method to reactively send a message to a conversation. If the task completes successfully, then an :class:`InvokeResponse` is returned; otherwise. `null` is returned. @@ -356,12 +362,14 @@ async def process_activity(self, req, auth_header: str, logic: Callable): async def authenticate_request( self, request: Activity, auth_header: str ) -> ClaimsIdentity: - """Allows for the overriding of authentication in unit tests. + """ + Allows for the overriding of authentication in unit tests. + :param request: The request to authenticate :type request: :class:`botbuilder.schema.Activity` :param auth_header: The authentication header - :raises: A permission exception error + :raises: A permission exception error. :return: The request claims identity :rtype: :class:`botframework.connector.auth.ClaimsIdentity` @@ -383,7 +391,7 @@ def create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. :param activity: - :return: + :return: """ return TurnContext(self, activity) @@ -445,7 +453,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): :return: A task that represents the work queued to execute - .. remarks:: + .. note:: If the activity is successfully sent, the task result contains a :class:`botbuilder.schema.ResourceResponse` object containing the ID that the receiving channel assigned to the activity. @@ -477,7 +485,7 @@ async def delete_activity( :return: A task that represents the work queued to execute - .. remarks:: + .. note:: The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: @@ -549,7 +557,9 @@ async def send_activities( async def delete_conversation_member( self, context: TurnContext, member_id: str ) -> None: - """Deletes a member from the current conversation + """ + Deletes a member from the current conversation. + :param context: The context object for the turn :type context: :class:`TurnContext` :param member_id: The ID of the member to remove from the conversation @@ -585,7 +595,8 @@ async def delete_conversation_member( raise error async def get_activity_members(self, context: TurnContext, activity_id: str): - """Lists the members of a given activity + """ + Lists the members of a given activity. :param context: The context object for the turn :type context: :class:`TurnContext` @@ -626,7 +637,8 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): raise error async def get_conversation_members(self, context: TurnContext): - """Lists the members of a current conversation. + """ + Lists the members of a current conversation. :param context: The context object for the turn :type context: :class:`TurnContext` @@ -672,7 +684,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :return: A task that represents the work queued to execute - .. remarks:: If the task completes successfully, the result contains a page of the members of the current conversation. + .. note:: If the task completes successfully, the result contains a page of the members of the current conversation. This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. """ client = await self.create_connector_client(service_url) @@ -682,7 +694,8 @@ async def get_user_token( self, context: TurnContext, connection_name: str, magic_code: str = None ) -> TokenResponse: - """Attempts to retrieve the token for a user that's in a login flow + """ + Attempts to retrieve the token for a user that's in a login flow. :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` @@ -728,7 +741,8 @@ async def get_user_token( async def sign_out_user( self, context: TurnContext, connection_name: str = None, user_id: str = None ) -> str: - """Signs the user out with the token server + """ + Signs the user out with the token server. :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` @@ -756,7 +770,8 @@ async def sign_out_user( async def get_oauth_sign_in_link( self, context: TurnContext, connection_name: str ) -> str: - """Gets the raw sign-in link to be sent to the user for sign-in for a connection name. + """ + Gets the raw sign-in link to be sent to the user for sign-in for a connection name. :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` @@ -765,7 +780,7 @@ async def get_oauth_sign_in_link( :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: If the task completes successfully, the result contains the raw sign-in link """ self.check_emulating_oauth_cards(context) @@ -788,7 +803,8 @@ async def get_token_status( self, context: TurnContext, user_id: str = None, include_filter: str = None ) -> List[TokenStatus]: - """Retrieves the token status for each configured connection for the given user + """ + Retrieves the token status for each configured connection for the given user. :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` @@ -821,7 +837,8 @@ async def get_token_status( async def get_aad_tokens( self, context: TurnContext, connection_name: str, resource_urls: List[str] ) -> Dict[str, TokenResponse]: - """Retrieves Azure Active Directory tokens for particular resources on a configured connection + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` From 5e36083cecf04cae87175a6be0647c90cd4b82bd Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Fri, 24 Jan 2020 16:26:41 -0800 Subject: [PATCH 188/616] Update activity_handler.py Code comments formatting.and repplaced remarks with note in methods. --- .../botbuilder/core/activity_handler.py | 92 +++++++++++-------- 1 file changed, 54 insertions(+), 38 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 9d4bfb6f1..54a16c056 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -8,22 +8,23 @@ class ActivityHandler: async def on_turn(self, turn_context: TurnContext): - """ Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime + """ + Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime in order to process an inbound :class:`botbuilder.schema.Activity`. - :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` - :returns: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute - .. remarks:: - It calls other methods in this class based on the type of the activity to - process, which allows a derived class to provide type-specific logic in a controlled way. - In a derived class, override this method to add logic that applies to all activity types. + .. remarks:: + It calls other methods in this class based on the type of the activity to + process, which allows a derived class to provide type-specific logic in a controlled way. + In a derived class, override this method to add logic that applies to all activity types. - .. note:: - - Add logic to apply before the type-specific logic and before the call to the :meth:`ActivityHandler.on_turn()` method. - - Add logic to apply after the type-specific logic after the call to the :meth:`ActivityHandler.on_turn()` method. + .. note:: + - Add logic to apply before the type-specific logic and before the call to the :meth:`ActivityHandler.on_turn()` method. + - Add logic to apply after the type-specific logic after the call to the :meth:`ActivityHandler.on_turn()` method. """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -57,7 +58,8 @@ async def on_turn(self, turn_context: TurnContext): async def on_message_activity( # pylint: disable=unused-argument self, turn_context: TurnContext ): - """Override this method in a derived class to provide logic specific to activities, + """ + Override this method in a derived class to provide logic specific to activities, such as the conversational logic. :param turn_context: The context object for this turn @@ -69,13 +71,15 @@ async def on_message_activity( # pylint: disable=unused-argument return async def on_conversation_update_activity(self, turn_context: TurnContext): - """Invoked when a conversation update activity is received from the channel when the base behavior of :meth:`ActivityHandler.on_turn()` is used. + """ + Invoked when a conversation update activity is received from the channel when the base behavior of :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` + :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this method. If the conversation update activity indicates that members other than the bot joined the conversation, it calls the :meth:`ActivityHandler.on_members_added_activity()` method. @@ -103,7 +107,8 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument - """Override this method in a derived class to provide logic for when members other than the bot join the conversation. + """ + Override this method in a derived class to provide logic for when members other than the bot join the conversation. You can add your bot's welcome logic. :param members_added: A list of all the members added to the conversation, as described by the conversation update activity @@ -113,7 +118,7 @@ async def on_members_added_activity( :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. """ @@ -122,7 +127,8 @@ async def on_members_added_activity( async def on_members_removed_activity( self, members_removed: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument - """Override this method in a derived class to provide logic for when members other than the bot leave the conversation. + """ + Override this method in a derived class to provide logic for when members other than the bot leave the conversation. You can add your bot's good-bye logic. :param members_added: A list of all the members removed from the conversation, as described by the conversation update activity @@ -132,7 +138,7 @@ async def on_members_removed_activity( :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are leaving the conversation, it calls this method. """ @@ -140,18 +146,20 @@ async def on_members_removed_activity( return async def on_message_reaction_activity(self, turn_context: TurnContext): - """Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. - Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. - Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the - reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response - from a send call. + """ + Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. + Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the + reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response + from a send call. When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this method. If the message reaction indicates that reactions were added to a message, it calls :meth:'ActivityHandler.on_reaction_added(). If the message reaction indicates that reactions were removed from a message, it calls :meth:'ActivityHandler.on_reaction_removed(). @@ -172,7 +180,8 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): async def on_reactions_added( # pylint: disable=unused-argument self, message_reactions: List[MessageReaction], turn_context: TurnContext ): - """Override this method in a derived class to provide logic for when reactions to a previous activity + """ + Override this method in a derived class to provide logic for when reactions to a previous activity are added to the conversation. :param message_reactions: The list of reactions added @@ -182,7 +191,7 @@ async def on_reactions_added( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent message on the conversation. Message reactions are supported by only a few channels. The activity that the message is in reaction to is identified by the activity's reply to Id property. @@ -194,7 +203,8 @@ async def on_reactions_added( # pylint: disable=unused-argument async def on_reactions_removed( # pylint: disable=unused-argument self, message_reactions: List[MessageReaction], turn_context: TurnContext ): - """Override this method in a derived class to provide logic for when reactions to a previous activity + """ + Override this method in a derived class to provide logic for when reactions to a previous activity are removed from the conversation. :param message_reactions: The list of reactions removed @@ -204,7 +214,7 @@ async def on_reactions_removed( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent message on the conversation. Message reactions are supported by only a few channels. The activity that the message is in reaction to is identified by the activity's reply to Id property. @@ -214,14 +224,15 @@ async def on_reactions_removed( # pylint: disable=unused-argument return async def on_event_activity(self, turn_context: TurnContext): - """Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. + """ + Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; otherwise, it calls :meth:'ActivityHandler.on_event()`. @@ -242,7 +253,8 @@ async def on_event_activity(self, turn_context: TurnContext): async def on_token_response_event( # pylint: disable=unused-argument self, turn_context: TurnContext ): - """Invoked when a `tokens/response` event is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. + """ + Invoked when a `tokens/response` event is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. If using an `oauth_prompt`, override this method to forward this activity to the current dialog. :param turn_context: The context object for this turn @@ -250,7 +262,7 @@ async def on_token_response_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of `tokens/response`, it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. """ @@ -259,25 +271,28 @@ async def on_token_response_event( # pylint: disable=unused-argument async def on_event( # pylint: disable=unused-argument self, turn_context: TurnContext ): - """Invoked when an event other than `tokens/response` is received when the base behavior of + """ + Invoked when an event other than `tokens/response` is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. - This method could optionally be overridden if the bot is meant to handle miscellaneous events. + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an activity name other than `tokens/response`, it calls this method. + This method could optionally be overridden if the bot is meant to handle miscellaneous events. """ return async def on_end_of_conversation_activity( # pylint: disable=unused-argument self, turn_context: TurnContext ): - """Invoked when a conversation end activity is received from the channel. + """ + Invoked when a conversation end activity is received from the channel. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -288,7 +303,8 @@ async def on_end_of_conversation_activity( # pylint: disable=unused-argument async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): - """Invoked when an activity other than a message, conversation update, or event is received when the base behavior of + """ + Invoked when an activity other than a message, conversation update, or event is received when the base behavior of :meth:`ActivityHandler.on_turn()` is used. If overridden, this method could potentially respond to any of the other activity types. @@ -297,7 +313,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute - .. remarks:: + .. note:: When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, conversation update, message reaction, or event activity, it calls this method. """ From 9de72232c25a2ef73c7125a077250200620e3095 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 27 Jan 2020 22:12:25 -0800 Subject: [PATCH 189/616] Bumping versions for samples dev environment --- libraries/botbuilder-ai/requirements.txt | 4 ++-- libraries/botbuilder-ai/setup.py | 4 ++-- libraries/botbuilder-applicationinsights/requirements.txt | 2 +- libraries/botbuilder-applicationinsights/setup.py | 6 +++--- libraries/botbuilder-azure/setup.py | 4 ++-- libraries/botbuilder-core/requirements.txt | 4 ++-- libraries/botbuilder-core/setup.py | 4 ++-- libraries/botbuilder-dialogs/requirements.txt | 6 +++--- libraries/botbuilder-dialogs/setup.py | 6 +++--- libraries/botbuilder-testing/requirements.txt | 6 +++--- libraries/botbuilder-testing/setup.py | 6 +++--- libraries/botframework-connector/requirements.txt | 2 +- libraries/botframework-connector/setup.py | 2 +- .../dialog-to-dialog/authentication-bot/requirements.txt | 2 +- .../simple-bot-to-bot/simple-child-bot/requirements.txt | 2 +- .../requirements.txt | 2 +- scenarios/action-based-messaging-extension/requirements.txt | 2 +- scenarios/activity-update-and-delete/requirements.txt | 2 +- scenarios/conversation-update/requirements.txt | 2 +- scenarios/file-upload/requirements.txt | 2 +- scenarios/link-unfurling/requirements.txt | 2 +- scenarios/mentions/requirements.txt | 2 +- scenarios/message-reactions/requirements.txt | 2 +- scenarios/roster/requirements.txt | 2 +- scenarios/search-based-messaging-extension/requirements.txt | 2 +- scenarios/task-module/requirements.txt | 2 +- 26 files changed, 41 insertions(+), 41 deletions(-) diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index 4dc295b54..dc2867a87 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botbuilder-schema>=4.4.0b1 -botbuilder-core>=4.4.0b1 +botbuilder-schema>=4.7.1 +botbuilder-core>=4.7.1 requests==2.22.0 aiounittest==1.3.0 azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index bd8de2101..72f112a5a 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -6,8 +6,8 @@ REQUIRES = [ "azure-cognitiveservices-language-luis==0.2.0", - "botbuilder-schema>=4.4.0b1", - "botbuilder-core>=4.4.0b1", + "botbuilder-schema>=4.7.1", + "botbuilder-core>=4.7.1", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-applicationinsights/requirements.txt b/libraries/botbuilder-applicationinsights/requirements.txt index 9398bc588..e89251ff0 100644 --- a/libraries/botbuilder-applicationinsights/requirements.txt +++ b/libraries/botbuilder-applicationinsights/requirements.txt @@ -1,3 +1,3 @@ msrest==0.6.10 -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index d84118224..0e4429065 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -6,9 +6,9 @@ REQUIRES = [ "applicationinsights>=0.11.9", - "botbuilder-schema>=4.4.0b1", - "botframework-connector>=4.4.0b1", - "botbuilder-core>=4.4.0b1", + "botbuilder-schema>=4.7.1", + "botframework-connector>=4.7.1", + "botbuilder-core>=4.7.1", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index c9a1f3bb7..2245c3bbc 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -7,8 +7,8 @@ REQUIRES = [ "azure-cosmos==3.1.2", "azure-storage-blob==2.1.0", - "botbuilder-schema>=4.4.0b1", - "botframework-connector>=4.4.0b1", + "botbuilder-schema>=4.7.1", + "botframework-connector>=4.7.1", "jsonpickle==1.2", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-core/requirements.txt b/libraries/botbuilder-core/requirements.txt index ba8bedbd4..330c0f2c4 100644 --- a/libraries/botbuilder-core/requirements.txt +++ b/libraries/botbuilder-core/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botframework-connector>=4.4.0b1 -botbuilder-schema>=4.4.0b1 +botframework-connector>=4.7.1 +botbuilder-schema>=4.7.1 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8.0 diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index d49a5f00a..1ce1a0e39 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -6,8 +6,8 @@ VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" REQUIRES = [ - "botbuilder-schema>=4.4.0b1", - "botframework-connector>=4.4.0b1", + "botbuilder-schema>=4.7.1", + "botframework-connector>=4.7.1", "jsonpickle==1.2", ] diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index fa0c59445..fec9928c2 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -1,7 +1,7 @@ msrest==0.6.10 -botframework-connector>=4.4.0b1 -botbuilder-schema>=4.4.0b1 -botbuilder-core>=4.4.0b1 +botframework-connector>=4.7.1 +botbuilder-schema>=4.7.1 +botbuilder-core>=4.7.1 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8 diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index ded5b6df3..ae24e3833 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -12,9 +12,9 @@ "recognizers-text>=1.0.2a1", "recognizers-text-choice>=1.0.2a1", "babel==2.7.0", - "botbuilder-schema>=4.4.0b1", - "botframework-connector>=4.4.0b1", - "botbuilder-core>=4.4.0b1", + "botbuilder-schema>=4.7.1", + "botframework-connector>=4.7.1", + "botbuilder-core>=4.7.1", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-testing/requirements.txt b/libraries/botbuilder-testing/requirements.txt index 21503f391..91ef96796 100644 --- a/libraries/botbuilder-testing/requirements.txt +++ b/libraries/botbuilder-testing/requirements.txt @@ -1,4 +1,4 @@ -botbuilder-schema>=4.4.0b1 -botbuilder-core>=4.4.0b1 -botbuilder-dialogs>=4.4.0b1 +botbuilder-schema>=4.7.1 +botbuilder-core>=4.7.1 +botbuilder-dialogs>=4.7.1 aiounittest==1.3.0 diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py index aae2d6dd2..433235306 100644 --- a/libraries/botbuilder-testing/setup.py +++ b/libraries/botbuilder-testing/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema>=4.4.0b1", - "botbuilder-core>=4.4.0b1", - "botbuilder-dialogs>=4.4.0b1", + "botbuilder-schema>=4.7.1", + "botbuilder-core>=4.7.1", + "botbuilder-dialogs>=4.7.1", ] TESTS_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index a2a1fe1b5..2ac3029a3 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -1,5 +1,5 @@ msrest==0.6.10 -botbuilder-schema>=4.4.0b1 +botbuilder-schema>=4.7.1 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8.0 \ No newline at end of file diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index f9d7e5bd6..7855654a6 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -10,7 +10,7 @@ "requests==2.22.0", "cryptography==2.8.0", "PyJWT==1.5.3", - "botbuilder-schema>=4.4.0b1", + "botbuilder-schema>=4.7.1", "adal==1.2.1", ] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt +++ b/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/action-based-messaging-extension-fetch-task/requirements.txt b/scenarios/action-based-messaging-extension-fetch-task/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/action-based-messaging-extension-fetch-task/requirements.txt +++ b/scenarios/action-based-messaging-extension-fetch-task/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/action-based-messaging-extension/requirements.txt b/scenarios/action-based-messaging-extension/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/action-based-messaging-extension/requirements.txt +++ b/scenarios/action-based-messaging-extension/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/activity-update-and-delete/requirements.txt b/scenarios/activity-update-and-delete/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/activity-update-and-delete/requirements.txt +++ b/scenarios/activity-update-and-delete/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/conversation-update/requirements.txt b/scenarios/conversation-update/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/conversation-update/requirements.txt +++ b/scenarios/conversation-update/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/file-upload/requirements.txt b/scenarios/file-upload/requirements.txt index 32e489163..8ee86105f 100644 --- a/scenarios/file-upload/requirements.txt +++ b/scenarios/file-upload/requirements.txt @@ -1,3 +1,3 @@ requests -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/link-unfurling/requirements.txt b/scenarios/link-unfurling/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/link-unfurling/requirements.txt +++ b/scenarios/link-unfurling/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/mentions/requirements.txt b/scenarios/mentions/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/mentions/requirements.txt +++ b/scenarios/mentions/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/message-reactions/requirements.txt b/scenarios/message-reactions/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/message-reactions/requirements.txt +++ b/scenarios/message-reactions/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/roster/requirements.txt b/scenarios/roster/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/roster/requirements.txt +++ b/scenarios/roster/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/search-based-messaging-extension/requirements.txt b/scenarios/search-based-messaging-extension/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/search-based-messaging-extension/requirements.txt +++ b/scenarios/search-based-messaging-extension/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 diff --git a/scenarios/task-module/requirements.txt b/scenarios/task-module/requirements.txt index 7e54b62ec..87eba6848 100644 --- a/scenarios/task-module/requirements.txt +++ b/scenarios/task-module/requirements.txt @@ -1,2 +1,2 @@ -botbuilder-core>=4.4.0b1 +botbuilder-core>=4.7.1 flask>=1.0.3 From 2af32302b22a713a2d19419bbbe36da4fd4da5be Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 28 Jan 2020 10:26:31 -0800 Subject: [PATCH 190/616] adding scenario for testing new helper --- scenarios/create-thread-in-channel/README.md | 30 ++++++ scenarios/create-thread-in-channel/app.py | 92 ++++++++++++++++++ .../create-thread-in-channel/bots/__init__.py | 6 ++ .../bots/create_thread_in_teams_bot.py | 24 +++++ scenarios/create-thread-in-channel/config.py | 13 +++ .../create-thread-in-channel/requirements.txt | 2 + .../teams_app_manifest/color.png | Bin 0 -> 1229 bytes .../teams_app_manifest/manifest.json | 43 ++++++++ .../teams_app_manifest/outline.png | Bin 0 -> 383 bytes 9 files changed, 210 insertions(+) create mode 100644 scenarios/create-thread-in-channel/README.md create mode 100644 scenarios/create-thread-in-channel/app.py create mode 100644 scenarios/create-thread-in-channel/bots/__init__.py create mode 100644 scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py create mode 100644 scenarios/create-thread-in-channel/config.py create mode 100644 scenarios/create-thread-in-channel/requirements.txt create mode 100644 scenarios/create-thread-in-channel/teams_app_manifest/color.png create mode 100644 scenarios/create-thread-in-channel/teams_app_manifest/manifest.json create mode 100644 scenarios/create-thread-in-channel/teams_app_manifest/outline.png diff --git a/scenarios/create-thread-in-channel/README.md b/scenarios/create-thread-in-channel/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/scenarios/create-thread-in-channel/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/create-thread-in-channel/app.py b/scenarios/create-thread-in-channel/app.py new file mode 100644 index 000000000..3c55decbe --- /dev/null +++ b/scenarios/create-thread-in-channel/app.py @@ -0,0 +1,92 @@ +# 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 CreateThreadInTeamsBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = CreateThreadInTeamsBot(APP.config["APP_ID"]) + +# 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/create-thread-in-channel/bots/__init__.py b/scenarios/create-thread-in-channel/bots/__init__.py new file mode 100644 index 000000000..f5e8a121c --- /dev/null +++ b/scenarios/create-thread-in-channel/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .create_thread_in_teams_bot import CreateThreadInTeamsBot + +__all__ = ["CreateThreadInTeamsBot"] diff --git a/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py b/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py new file mode 100644 index 000000000..8b2f8fccc --- /dev/null +++ b/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.core.teams import ( + teams_get_channel_id, + TeamsActivityHandler, + TeamsInfo +) + + +class CreateThreadInTeamsBot(TeamsActivityHandler): + def __init__(self, id): + self.id = id + + async def on_message_activity(self, turn_context: TurnContext): + message = MessageFactory.text("first message") + channel_id = teams_get_channel_id(turn_context.activity) + result = await TeamsInfo.send_message_to_teams_channel(turn_context, message, channel_id) + + await turn_context.adapter.continue_conversation(result[0], self._continue_conversation_callback, self.id) + + async def _continue_conversation_callback(self, t): + await t.send_activity(MessageFactory.text("second message")) diff --git a/scenarios/create-thread-in-channel/config.py b/scenarios/create-thread-in-channel/config.py new file mode 100644 index 000000000..6b5116fba --- /dev/null +++ b/scenarios/create-thread-in-channel/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/create-thread-in-channel/requirements.txt b/scenarios/create-thread-in-channel/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/scenarios/create-thread-in-channel/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/scenarios/create-thread-in-channel/teams_app_manifest/color.png b/scenarios/create-thread-in-channel/teams_app_manifest/color.png new file mode 100644 index 0000000000000000000000000000000000000000..48a2de13303e1e8a25f76391f4a34c7c4700fd3d GIT binary patch literal 1229 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCe1|JzX3_D&pSWuFnWfl{x;g|9jrEYf8Vqrkk2Ba|%ol3OT){=#|7ID~|e{ zODQ{kU&ME#@`*-tm%Tukt_gFr+`F?$dx9wg-jad`^gsMn2_%Kh%WH91&SjKq5 zgkdI|!exdOVgw@>>=!Tjnk6q)zV*T8$FdgRFYC{kQ7``NOcl@R(_%_8e5e0E;>v0G zEM9kb)2itgOTSfH7M=b3-S61B?PiazMdwXZwrS)^5UUS#HQjaoua5h_{Gx*_Zz|XK z$tf0mZ&=tpf2!!Q)!A_l&o_$g*|JM$VZa~F^0{x1T{=QFu*x$`=V%~jUW=G`iqqp=lquB-`P{Qjw`=zEu3cMc_x7m2f#9m}uoFBMMQ^+%cOL)F_)N@JZ}Axoxi1y= zeebq`y==e!nl+?cK-PhOec!3%|IupShHrcjW8sSt)F1>NW*{ zW%ljk2)nk%-}+F&?gi=7^$L#VeX3@kp%f{n}fR z`}uZ>", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "MentionBot", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "MentionBot", + "full": "MentionBot" + }, + "description": { + "short": "MentionBot", + "full": "MentionBot" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] +} \ No newline at end of file diff --git a/scenarios/create-thread-in-channel/teams_app_manifest/outline.png b/scenarios/create-thread-in-channel/teams_app_manifest/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..dbfa9277299d36542af02499e06e3340bc538fe7 GIT binary patch literal 383 zcmV-_0f7FAP)Px$IY~r8R5%gMlrc`jP!L3IloKFoq~sFFH5|cdklX=R08T)}71BhaN8$`AsNf0_ zq>WNhAtCd|-nBlTU=y5zl_vXlXZ~bkuaYENMp>3QSQ_#zuYZ+eQh*OIHRxP~s(}ic zN2J4$u=AQcPt)|>F3zZLsjtP;Tajkugx;NcYED2~JVBlVO>{`uAY?Q4O|AA z=16}CJieK^5P_TKnou!zGR`$!PUC)DqtkO;?!`p!+9v3lP_mu=%Vt3BkoWsq%;FN1sp58w*zfr-z^7tIb*q>!yncCjrzLuOk3N+d&~^Cxd| z Date: Tue, 28 Jan 2020 10:39:07 -0800 Subject: [PATCH 191/616] updating name for snake case --- .../bots/create_thread_in_teams_bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py b/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py index 8b2f8fccc..6feca9af4 100644 --- a/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py +++ b/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py @@ -20,5 +20,5 @@ async def on_message_activity(self, turn_context: TurnContext): await turn_context.adapter.continue_conversation(result[0], self._continue_conversation_callback, self.id) - async def _continue_conversation_callback(self, t): - await t.send_activity(MessageFactory.text("second message")) + async def _continue_conversation_callback(self, turn_context): + await turn_context.send_activity(MessageFactory.text("second message")) From 33bc1a7097211e08e383d65c12555ca6d486bed2 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 28 Jan 2020 11:39:01 -0800 Subject: [PATCH 192/616] Fixed formatting --- .../botbuilder/dialogs/component_dialog.py | 3 --- .../botbuilder/dialogs/dialog_instance.py | 2 +- .../botbuilder/dialogs/dialog_state.py | 4 ++-- .../botbuilder/dialogs/prompts/activity_prompt.py | 10 +++++----- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index b297443df..cddd8e00a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -204,7 +204,6 @@ def add_dialog(self, dialog: Dialog) -> object: def find_dialog(self, dialog_id: str) -> Dialog: """ Finds a dialog by ID. - Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. :param dialog_id: The dialog to add. :return: The dialog; or None if there is not a match for the ID. @@ -231,8 +230,6 @@ async def on_begin_dialog( :type inner_dc: :class:`DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object - :return: ? - :rtype: ? """ return await inner_dc.begin_dialog(self.initial_dialog_id, options) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index e4aa2bf24..3d06d6205 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -16,7 +16,7 @@ def __init__(self): :var self.id: The ID of the dialog :vartype self.id: str :var self.state: The instance's persisted state. - :vartype self.state: Dict[str, object] + :vartype self.state: :class:`typing.Dict[str, object]` """ self.id: str = None # pylint: disable=invalid-name diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 218caf5d0..cf5cb1344 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -14,7 +14,7 @@ def __init__(self, stack: List[DialogInstance] = None): The new instance is created with an empty dialog stack. :param stack: The state information to initialize the stack with. - :type stack: List[:class:`DialogInstance`] + :type stack: :class:`typing.List[:class:`DialogInstance`]` """ if stack is None: self._dialog_stack = [] @@ -27,7 +27,7 @@ def dialog_stack(self): Initializes a new instance of the :class:`DialogState` class. :return: The state information to initialize the stack with. - :rtype: List + :rtype: list """ return self._dialog_stack diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index 15f7f9cdc..dc255cd33 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -46,7 +46,7 @@ def __init__( :param dialog_id: Unique ID of the dialog within its parent :class:`DialogSet` or :class:`ComponentDialog`. :type dialog_id: str :param validator: Validator that will be called each time a new activity is received. - :type validator: Callable[[PromptValidatorContext], bool] + :type validator: :class:`typing.Callable[[:class:`PromptValidatorContext`], bool]` """ Dialog.__init__(self, dialog_id) @@ -191,8 +191,8 @@ async def on_prompt( :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` :param state: Additional state being persisted for the prompt. - :type state: Dict[str, dict] - :param options: Options that the prompt started with in the call to `DialogContext.prompt()`. + :type state: :class:`typing.Dict[str, dict]` + :param options: Options that the prompt started with in the call to :meth:`DialogContext.prompt()`. :type options: :class:`PromptOptions` :param isRetry: If `true` the users response wasn't recognized and the re-prompt should be sent. :type isRetry: bool @@ -213,10 +213,10 @@ async def on_recognize( # pylint: disable=unused-argument :param context: Context for the current turn of conversation with the user. :type context: :class:`TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack. - :type state: Dict[str, object] + :type state: :class:`typing.Dict[str, dict]` :param options: A prompt options object :type options: :class:`PromptOptions` - :return result: constructed from the options initially provided in the call to `async def on_prompt()` + :return result: constructed from the options initially provided in the call to :meth:`async def on_prompt()` :rtype result: :class:`PromptRecognizerResult` """ result = PromptRecognizerResult() From a9b37ec70b80f1ea817547d77d98c61cf5ef944c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jan 2020 14:04:06 -0600 Subject: [PATCH 193/616] FIxed pylint errors --- .../botbuilder/dialogs/component_dialog.py | 31 +++++++++++-------- .../botbuilder/dialogs/dialog_turn_result.py | 22 +++++++------ 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index cddd8e00a..6857ad5b4 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -16,13 +16,14 @@ class ComponentDialog(Dialog): """ A :class:`Dialog` that is composed of other dialogs - + A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, which provides an inner dialog stack that is hidden from the parent dialog. - + :var persisted_dialog state: :vartype persisted_dialog_state: str """ + persisted_dialog_state = "dialogs" def __init__(self, dialog_id: str): @@ -47,7 +48,7 @@ async def begin_dialog( ) -> DialogTurnResult: """ Called when the dialog is started and pushed onto the parent's dialog stack. - + If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. @@ -86,7 +87,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu active after the turn has been processed by the dialog. The result may also contain a return value. - If this method is *not* overriden the component dialog calls the + If this method is *not* overriden the component dialog calls the :meth:`DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, and if a :class:`DialogTurnResult.result` is available, the component dialog @@ -124,7 +125,7 @@ async def resume_dialog( :meth:resume_dialog() when the pushed on dialog ends. To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - + If the task is successful, the result indicates whether this dialog is still active after this dialog turn has been processed. @@ -133,13 +134,14 @@ async def resume_dialog( is called, the logical child dialog may be different than the original. If this method is *not* overridden, the dialog automatically calls its - :meth:`asyn def reprompt_dialog()` when the user replies. + :meth:`asyn def reprompt_dialog()` when the user replies. :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` - :param result: Optional, value returned from the dialog that was called. The type of the value returned is dependent on the child dialog. + :param result: Optional, value returned from the dialog that was called. The type of the + value returned is dependent on the child dialog. :type result: object :return: Signals the end of the turn :rtype: :class:`Dialog.end_of_turn` @@ -175,7 +177,8 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`TurnContext` - :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. + :param instance: State information associated with the instance of this component dialog + on its parent's dialog stack. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` @@ -191,7 +194,7 @@ def add_dialog(self, dialog: Dialog) -> object: """ Adds a :class:`Dialog` to the component dialog and returns the updated component. Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. - + :param dialog: The dialog to add. :return: The updated :class:`ComponentDialog` :rtype: :class:`ComponentDialog` @@ -204,7 +207,7 @@ def add_dialog(self, dialog: Dialog) -> object: def find_dialog(self, dialog_id: str) -> Dialog: """ Finds a dialog by ID. - + :param dialog_id: The dialog to add. :return: The dialog; or None if there is not a match for the ID. :rtype: :class:Dialog @@ -244,7 +247,8 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`TurnContext` for the current turn of the conversation. :type turn_context: :class:`TurnContext` - :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. + :param instance: State information associated with the instance of this component dialog on + its parent's dialog stack. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` @@ -257,7 +261,8 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument """ :param turn_context: The :class:`TurnContext` for the current turn of the conversation. :type turn_context: :class:`DialogInstance` - :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. + :param instance: State information associated with the instance of this component dialog + on its parent's dialog stack. :type instance: :class:`DialogInstance` """ return @@ -267,7 +272,7 @@ async def end_component( ) -> DialogTurnResult: """ Ends the component dialog in its parent's context. - + .. note:: If the task is successful, the result indicates that the dialog ended after the turn was processed by the dialog. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index d02ecaa4a..7fd1b5632 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -3,23 +3,25 @@ from .dialog_turn_status import DialogTurnStatus + class DialogTurnResult: - """ + """ Result returned to the caller of one of the various stack manipulation methods. Use :meth:`DialogContext.end_dialogAsync()` to end a :class:`Dialog` and return a result to the calling context. """ + def __init__(self, status: DialogTurnStatus, result: object = None): - """ - :param status: The current status of the stack. - :type status: :class:`DialogTurnStatus` - :param result: The result returned by a dialog that was just ended. - :type result: object - """ + """ + :param status: The current status of the stack. + :type status: :class:`DialogTurnStatus` + :param result: The result returned by a dialog that was just ended. + :type result: object + """ self._status = status self._result = result - + @property def status(self): """ @@ -29,12 +31,12 @@ def status(self): :rtype self._status: :class:`DialogTurnStatus` """ return self._status - + @property def result(self): """ Final result returned by a dialog that just completed. - + .. note:: This will only be populated in certain cases: * The bot calls :meth:`DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. From 9f722d26e67a4fb831bad8a64c7dd91304d31b0c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jan 2020 14:22:01 -0600 Subject: [PATCH 194/616] pylint corrections in dialogs --- .../botbuilder/dialogs/dialog_instance.py | 8 +-- .../botbuilder/dialogs/dialog_reason.py | 5 +- .../botbuilder/dialogs/dialog_state.py | 6 +- .../dialogs/prompts/activity_prompt.py | 27 ++++---- .../dialogs/prompts/oauth_prompt.py | 57 +++++++++-------- .../botbuilder/dialogs/prompts/prompt.py | 62 ++++++++++--------- .../dialogs/prompts/prompt_options.py | 8 +-- 7 files changed, 93 insertions(+), 80 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index 3d06d6205..add9e2dc6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -10,7 +10,7 @@ class DialogInstance: """ def __init__(self): - """ + """ Gets or sets the ID of the dialog and gets or sets the instance's persisted state. :var self.id: The ID of the dialog @@ -24,9 +24,9 @@ def __init__(self): def __str__(self): """ - Gets or sets a stack index. - - :return: Returns stack index. + Gets or sets a stack index. + + :return: Returns stack index. :rtype: str """ result = "\ndialog_instance_id: %s\n" % self.id diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index fa24bc3ea..a017a33df 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -2,10 +2,11 @@ # Licensed under the MIT License. from enum import Enum + class DialogReason(Enum): - """ + """ Indicates in which a dialog-related method is being called. - + :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. :vartype BeginCalled: int :var ContinuCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index cf5cb1344..940ee73ff 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -4,15 +4,17 @@ from typing import List from .dialog_instance import DialogInstance + class DialogState: """ Contains state information for the dialog stack. """ + def __init__(self, stack: List[DialogInstance] = None): """ Initializes a new instance of the :class:`DialogState` class. The new instance is created with an empty dialog stack. - + :param stack: The state information to initialize the stack with. :type stack: :class:`typing.List[:class:`DialogInstance`]` """ @@ -25,7 +27,7 @@ def __init__(self, stack: List[DialogInstance] = None): def dialog_stack(self): """ Initializes a new instance of the :class:`DialogState` class. - + :return: The state information to initialize the stack with. :rtype: list """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index dc255cd33..db0e87e01 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -19,6 +19,7 @@ from .prompt_recognizer_result import PromptRecognizerResult from .prompt_validator_context import PromptValidatorContext + class ActivityPrompt(Dialog, ABC): """ Waits for an activity to be received. @@ -64,7 +65,7 @@ async def begin_dialog( :type dialog_context: :class:`DialogContext` :param options: Optional, additional information to pass to the prompt being started. :type options: :class:`PromptOptions` - :return Dialog.end_of_turn: + :return Dialog.end_of_turn: :rtype Dialog.end_of_turn: :class:`Dialog.DialogTurnResult` """ if not dialog_context: @@ -97,15 +98,15 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: + """ + Called when a prompt dialog is the active dialog and the user replied with a new activity. + + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` + :return Dialog.end_of_turn: + :rtype Dialog.end_of_turn: :class:`Dialog.DialogTurnResult` + """ if not dialog_context: - """ - Called when a prompt dialog is the active dialog and the user replied with a new activity. - - :param dialog_context: The dialog context for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` - :return Dialog.end_of_turn: - :rtype Dialog.end_of_turn: :class:`Dialog.DialogTurnResult` - """ raise TypeError( "ActivityPrompt.continue_dialog(): DialogContext cannot be None." ) @@ -152,21 +153,21 @@ async def resume_dialog( # pylint: disable=unused-argument self, dialog_context: DialogContext, reason: DialogReason, result: object = None ): """ - Called when a prompt dialog resumes being the active dialog on the dialog stack, such + Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. - + .. note: Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs on top of the stack which will result in the prompt receiving an unexpected call to :meth:resume_dialog() when the pushed on dialog ends. To avoid the prompt prematurely ending, we need to implement this method and simply re-prompt the user. - + :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` :param reason: An enum indicating why the dialog resumed. :type reason: :class:`DialogReason` - :param result: Optional, value returned from the previous dialog on the stack. + :param result: Optional, value returned from the previous dialog on the stack. :type result: object """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index b6f726689..736637d30 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -5,6 +5,8 @@ from datetime import datetime, timedelta from typing import Union, Awaitable, Callable +from botframework.connector import Channels +from botframework.connector.auth import ClaimsIdentity, SkillValidation from botbuilder.core import ( CardFactory, MessageFactory, @@ -23,8 +25,6 @@ OAuthCard, TokenResponse, ) -from botframework.connector import Channels -from botframework.connector.auth import ClaimsIdentity, SkillValidation from .prompt_options import PromptOptions from .oauth_prompt_settings import OAuthPromptSettings from .prompt_validator_context import PromptValidatorContext @@ -46,8 +46,8 @@ class OAuthPrompt(Dialog): standard `message` activity. Both flows are automatically supported by the `OAuthPrompt` and they only thing you need to be careful of is that you don't block the `event` and `invoke` activities that the prompt might be waiting on. - - .. note:: + + .. note:: You should avoid persisting the access token with your bots other state. The Bot Frameworks SSO service will securely store the token on your behalf. If you store it in your bots state, it could expire or be revoked in between turns. @@ -55,30 +55,35 @@ class OAuthPrompt(Dialog): following the prompt and then let the token go out of scope at the end of your function. **Prompt Usage** - When used with your bots :class:`DialogSet`, you can simply add a new instance of the prompt as a named dialog using + When used with your bots :class:`DialogSet`, you can simply add a new instance of the prompt as a named + dialog using :meth`DialogSet.add()`. - You can then start the prompt from a waterfall step using either :meth:`DialogContext.begin()` or :meth:`DialogContext.prompt()`. - The user will be prompted to sign in as needed and their access token will be passed as an argument to the callers - next waterfall step. + You can then start the prompt from a waterfall step using either :meth:`DialogContext.begin()` or + :meth:`DialogContext.prompt()`. + The user will be prompted to sign in as needed and their access token will be passed as an argument to + the callers next waterfall step. """ + def __init__( self, dialog_id: str, settings: OAuthPromptSettings, validator: Callable[[PromptValidatorContext], Awaitable[bool]] = None, ): - """ + """ Creates a new instance of the :class:`OAuthPrompt` class. :param dialogId: The Id to assign to this prompt. :type dialogId: str :param settings: Additional authentication settings to use with this instance of the prompt :type settings: :class:`OAuthPromptSettings` - :param validator: Optional, a :class:`PromptValidator` that contains additional, custom validation for this prompt + :param validator: Optional, a :class:`PromptValidator` that contains additional, custom validation + for this prompt :type validator: :class:`PromptValidatorContext` - .. note:: - The value of :param dialogId: must be unique within the :class:`DialogSet`or :class:`ComponentDialog` to which the prompt is added. + .. note:: + The value of :param dialogId: must be unique within the :class:`DialogSet`or :class:`ComponentDialog` + to which the prompt is added. """ super().__init__(dialog_id) self._validator = validator @@ -95,7 +100,8 @@ async def begin_dialog( self, dialog_context: DialogContext, options: PromptOptions = None ) -> DialogTurnResult: """ - Starts an authentication prompt dialog. Called when an authentication prompt dialog is pushed onto the dialog stack and is being activated. + Starts an authentication prompt dialog. Called when an authentication prompt dialog is pushed onto the + dialog stack and is being activated. :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` @@ -104,8 +110,9 @@ async def begin_dialog( :return: Dialog turn result :rtype: :class:DialogTurnResult - .. note:: - If the task is successful, the result indicates whether the prompt is still active after the turn has been processed by the prompt. + .. note:: + If the task is successful, the result indicates whether the prompt is still active after the turn + has been processed by the prompt. """ if dialog_context is None: raise TypeError( @@ -148,15 +155,15 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResult: - """ + """ Continues a dialog. Called when a prompt dialog is the active dialog and the user replied with a new activity. :param dialog_context: The dialog context for the current turn of the conversation - :type dialog_context: :class:`DialogContext` + :type dialog_context: :class:`DialogContext` :return: Dialog turn result :rtype: :class:DialogTurnResult - .. note:: + .. note:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The prompt generally continues to receive the user's replies until it accepts the @@ -209,15 +216,15 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu async def get_user_token( self, context: TurnContext, code: str = None ) -> TokenResponse: - """ + """ Gets the user's tokeN. :param context: Context for the current turn of conversation with the user - :type context: :class:TurnContext + :type context: :class:TurnContext :return: A response that includes the user's token - :rtype: :class:TokenResponse + :rtype: :class:TokenResponse - .. note:: + .. note:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ @@ -238,11 +245,11 @@ async def sign_out_user(self, context: TurnContext): Signs out the user :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` + :type context: :class:`TurnContext` :return: A :class:`Task` representing the work queued to execute - :rtype: :class:`Task` + :rtype: :class:`Task` - .. note:: + .. note:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 842c606f1..e1df46e6f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -26,15 +26,16 @@ class Prompt(Dialog): """ Defines the core behavior of prompt dialogs. Extends the :class:`Dialog` base class. - .. remarks:: + .. remarks:: When the prompt ends, it returns an object that represents the value it was prompted for. - Use :method:`DialogSet.add()` or :method:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or + Use :method:`DialogSet.add()` or :method:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or component dialog, respectively. Use :method:`DialogContext.prompt()` or :meth:`DialogContext.begin_dialog()` to start the prompt. .. note:: - If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result will be - available in the next step of the waterfall. + If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result + will be available in the next step of the waterfall. """ + ATTEMPT_COUNT_KEY = "AttemptCount" persisted_options = "options" persisted_state = "state" @@ -46,7 +47,8 @@ def __init__(self, dialog_id: str, validator: object = None): :param dialog_id: Unique Id of the prompt within its parent :class:`DialogSet` or :class:`ComponentDialog`. :type dialog_id: str - :param validator: Optional custom validator used to provide additional validation and re-prompting logic for the prompt. + :param validator: Optional custom validator used to provide additional validation and re-prompting + logic for the prompt. :type validator: Object """ super(Prompt, self).__init__(dialog_id) @@ -56,7 +58,7 @@ def __init__(self, dialog_id: str, validator: object = None): async def begin_dialog( self, dialog_context: DialogContext, options: object = None ) -> DialogTurnResult: - """ + """ Starts a prompt dialog. Called when a prompt dialog is pushed onto the dialog stack and is being activated. :param dialog_context: The dialog context for the current turn of the conversation @@ -66,8 +68,8 @@ async def begin_dialog( :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. note:: - If the task is successful, the result indicates whether the prompt is still active + .. note:: + If the task is successful, the result indicates whether the prompt is still active after the turn has been processed by the prompt. """ if not dialog_context: @@ -97,15 +99,15 @@ async def begin_dialog( return Dialog.end_of_turn async def continue_dialog(self, dialog_context: DialogContext): - """ + """ Continues a dialog. Called when a prompt dialog is the active dialog and the user replied with a new activity. :param dialog_context: The dialog context for the current turn of the conversation - :type dialog_context: :class:`DialogContext` + :type dialog_context: :class:`DialogContext` :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. note:: + .. note:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The prompt generally continues to receive the user's replies until it accepts the @@ -148,17 +150,17 @@ async def continue_dialog(self, dialog_context: DialogContext): async def resume_dialog( self, dialog_context: DialogContext, reason: DialogReason, result: object ) -> DialogTurnResult: - """ - Resumes a dialog. Called when a prompt dialog resumes being the active dialog + """ + Resumes a dialog. Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. :param dialog_context: The dialog context for the current turn of the conversation. - :type dialog_context: :class:DialogContext + :type dialog_context: :class:DialogContext :param reason: An enum indicating why the dialog resumed. - :type reason: :class:DialogReason - :param result: Optional, value returned from the previous dialog on the stack. + :type reason: :class:DialogReason + :param result: Optional, value returned from the previous dialog on the stack. The type of the value returned is dependent on the previous dialog. - :type result: object + :type result: object :return: The dialog turn result :rtype: :class:`DialogTurnResult` @@ -174,13 +176,13 @@ async def resume_dialog( return Dialog.end_of_turn async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): - """ + """ Reprompts user for input. Called when a prompt dialog has been requested to re-prompt the user for input. :param context: Context for the current turn of conversation with the user - :type context: :class:TurnContext + :type context: :class:TurnContext :param instance: The instance of the dialog on the stack - :type instance: :class:DialogInstance + :type instance: :class:DialogInstance :return: A :class:Task representing the asynchronous operation :rtype: :class:Task """ @@ -196,19 +198,19 @@ async def on_prompt( options: PromptOptions, is_retry: bool, ): - """ + """ Prompts user for input. When overridden in a derived class, prompts the user for input. :param turn_context: Context for the current turn of conversation with the user - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` - :type options: :class:`PromptOptions` - :param is_retry: true if this is the first time this prompt dialog instance on the stack is prompting + :type options: :class:`PromptOptions` + :param is_retry: true if this is the first time this prompt dialog instance on the stack is prompting the user for input; otherwise, false - :type is_retry: bool + :type is_retry: bool :return: A :class:Task representing the asynchronous operation. :rtype: :class:Task @@ -222,16 +224,16 @@ async def on_recognize( state: Dict[str, object], options: PromptOptions, ): - """ + """ Recognizes the user's input. When overridden in a derived class, attempts to recognize the user's input. :param turn_context: Context for the current turn of conversation with the user - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` - :type options: :class:PromptOptions + :type options: :class:PromptOptions :return: A :class:Task representing the asynchronous operation. :rtype: :class:Task @@ -246,13 +248,13 @@ def append_choices( style: ListStyle, options: ChoiceFactoryOptions = None, ) -> Activity: - """ + """ Composes an output activity containing a set of choices. When overridden in a derived class, appends choices to the activity when the user is prompted for input. Helper function to compose an output activity containing a set of choices. :param prompt: The prompt to append the user's choice to - :type prompt: + :type prompt: :param channel_id: Id of the channel the prompt is being sent to :type channel_id: str :param: choices: List of choices to append diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py index ea0c74825..c341a4b52 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py @@ -8,7 +8,7 @@ class PromptOptions: - """ + """ Contains settings to pass to a :class:`Prompt` object when the prompt is started. """ @@ -21,7 +21,7 @@ def __init__( validations: object = None, number_of_attempts: int = 0, ): - """ + """ Sets the initial prompt to send to the user as an :class:`botbuilder.schema.Activity`. :param prompt: The initial prompt to send to the user @@ -32,11 +32,11 @@ def __init__( :type choices: :class:`List` :param style: The style of the list of choices to send to the user :type style: :class:`ListStyle` - :param validations: The prompt validations + :param validations: The prompt validations :type validations: :class:`Object` :param number_of_attempts: The number of attempts allowed :type number_of_attempts: :class:`int` - + """ self.prompt = prompt self.retry_prompt = retry_prompt From 60a274d41a83cbdcd1222da39c96c8e228d6513c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jan 2020 14:29:35 -0600 Subject: [PATCH 195/616] pylint corrections in core --- .../botbuilder/core/activity_handler.py | 108 ++++++------ .../botbuilder/core/bot_framework_adapter.py | 158 +++++++++--------- .../botbuilder/core/bot_state.py | 28 ++-- .../botbuilder/core/conversation_state.py | 12 +- 4 files changed, 153 insertions(+), 153 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 54a16c056..5fe2ac086 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -8,24 +8,24 @@ class ActivityHandler: async def on_turn(self, turn_context: TurnContext): - """ - Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime - in order to process an inbound :class:`botbuilder.schema.Activity`. - - :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` - - :returns: A task that represents the work queued to execute - - .. remarks:: - It calls other methods in this class based on the type of the activity to - process, which allows a derived class to provide type-specific logic in a controlled way. - In a derived class, override this method to add logic that applies to all activity types. - - .. note:: - - Add logic to apply before the type-specific logic and before the call to the :meth:`ActivityHandler.on_turn()` method. - - Add logic to apply after the type-specific logic after the call to the :meth:`ActivityHandler.on_turn()` method. - """ + """ + Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime + in order to process an inbound :class:`botbuilder.schema.Activity`. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :returns: A task that represents the work queued to execute + + .. remarks:: + It calls other methods in this class based on the type of the activity to + process, which allows a derived class to provide type-specific logic in a controlled way. + In a derived class, override this method to add logic that applies to all activity types. + + .. note:: + - Add logic to apply before the type-specific logic and before the call to the :meth:`ActivityHandler.on_turn()` method. + - Add logic to apply after the type-specific logic after the call to the :meth:`ActivityHandler.on_turn()` method. + """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -59,9 +59,9 @@ async def on_message_activity( # pylint: disable=unused-argument self, turn_context: TurnContext ): """ - Override this method in a derived class to provide logic specific to activities, + Override this method in a derived class to provide logic specific to activities, such as the conversational logic. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -73,7 +73,7 @@ async def on_message_activity( # pylint: disable=unused-argument async def on_conversation_update_activity(self, turn_context: TurnContext): """ Invoked when a conversation update activity is received from the channel when the base behavior of :meth:`ActivityHandler.on_turn()` is used. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -81,9 +81,9 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): .. note:: When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this method. - If the conversation update activity indicates that members other than the bot joined the conversation, + If the conversation update activity indicates that members other than the bot joined the conversation, it calls the :meth:`ActivityHandler.on_members_added_activity()` method. - If the conversation update activity indicates that members other than the bot left the conversation, + If the conversation update activity indicates that members other than the bot left the conversation, it calls the :meth:`ActivityHandler.on_members_removed_activity()` method. In a derived class, override this method to add logic that applies to all conversation update activities. Add logic to apply before the member added or removed logic before the call to this base class method. @@ -110,7 +110,7 @@ async def on_members_added_activity( """ Override this method in a derived class to provide logic for when members other than the bot join the conversation. You can add your bot's welcome logic. - + :param members_added: A list of all the members added to the conversation, as described by the conversation update activity :type members_added: :class:`typing.List` :param turn_context: The context object for this turn @@ -119,7 +119,7 @@ async def on_members_added_activity( :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. """ return @@ -130,7 +130,7 @@ async def on_members_removed_activity( """ Override this method in a derived class to provide logic for when members other than the bot leave the conversation. You can add your bot's good-bye logic. - + :param members_added: A list of all the members removed from the conversation, as described by the conversation update activity :type members_added: :class:`typing.List` :param turn_context: The context object for this turn @@ -139,7 +139,7 @@ async def on_members_removed_activity( :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are leaving the conversation, it calls this method. """ @@ -148,24 +148,24 @@ async def on_members_removed_activity( async def on_message_reaction_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. - - + + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :returns: A task that represents the work queued to execute .. note:: - Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. - Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the - reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. + Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the + reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response from a send call. When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this method. If the message reaction indicates that reactions were added to a message, it calls :meth:'ActivityHandler.on_reaction_added(). If the message reaction indicates that reactions were removed from a message, it calls :meth:'ActivityHandler.on_reaction_removed(). In a derived class, override this method to add logic that applies to all message reaction activities. Add logic to apply before the reactions added or removed logic before the call to the this base class method. - Add logic to apply after the reactions added or removed logic after the call to the this base class method. + Add logic to apply after the reactions added or removed logic after the call to the this base class method. """ if turn_context.activity.reactions_added is not None: await self.on_reactions_added( @@ -181,9 +181,9 @@ async def on_reactions_added( # pylint: disable=unused-argument self, message_reactions: List[MessageReaction], turn_context: TurnContext ): """ - Override this method in a derived class to provide logic for when reactions to a previous activity + Override this method in a derived class to provide logic for when reactions to a previous activity are added to the conversation. - + :param message_reactions: The list of reactions added :type message_reactions: :class:`typing.List` :param turn_context: The context object for this turn @@ -192,10 +192,10 @@ async def on_reactions_added( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. note:: - Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent message on the conversation. Message reactions are supported by only a few channels. - The activity that the message is in reaction to is identified by the activity's reply to Id property. - The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, + The activity that the message is in reaction to is identified by the activity's reply to Id property. + The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, the channel assigns an ID to it, which is available in the resource response Id of the result. """ return @@ -204,9 +204,9 @@ async def on_reactions_removed( # pylint: disable=unused-argument self, message_reactions: List[MessageReaction], turn_context: TurnContext ): """ - Override this method in a derived class to provide logic for when reactions to a previous activity + Override this method in a derived class to provide logic for when reactions to a previous activity are removed from the conversation. - + :param message_reactions: The list of reactions removed :type message_reactions: :class:`typing.List` :param turn_context: The context object for this turn @@ -215,10 +215,10 @@ async def on_reactions_removed( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. note:: - Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent message on the conversation. Message reactions are supported by only a few channels. - The activity that the message is in reaction to is identified by the activity's reply to Id property. - The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, + The activity that the message is in reaction to is identified by the activity's reply to Id property. + The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, the channel assigns an ID to it, which is available in the resource response Id of the result. """ return @@ -226,7 +226,7 @@ async def on_reactions_removed( # pylint: disable=unused-argument async def on_event_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -235,14 +235,14 @@ async def on_event_activity(self, turn_context: TurnContext): .. note:: When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; - otherwise, it calls :meth:'ActivityHandler.on_event()`. - + otherwise, it calls :meth:'ActivityHandler.on_event()`. + In a derived class, override this method to add logic that applies to all event activities. Add logic to apply before the specific event-handling logic before the call to this base class method. Add logic to apply after the specific event-handling logic after the call to this base class method. - + Event activities communicate programmatic information from a client or channel to a bot. - The meaning of an event activity is defined by the event activity name property, which is meaningful within + The meaning of an event activity is defined by the event activity name property, which is meaningful within the scope of a channel. """ if turn_context.activity.name == "tokens/response": @@ -256,7 +256,7 @@ async def on_token_response_event( # pylint: disable=unused-argument """ Invoked when a `tokens/response` event is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. If using an `oauth_prompt`, override this method to forward this activity to the current dialog. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -274,15 +274,15 @@ async def on_event( # pylint: disable=unused-argument """ Invoked when an event other than `tokens/response` is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. - - + + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an + When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an activity name other than `tokens/response`, it calls this method. This method could optionally be overridden if the bot is meant to handle miscellaneous events. """ @@ -293,7 +293,7 @@ async def on_end_of_conversation_activity( # pylint: disable=unused-argument ): """ Invoked when a conversation end activity is received from the channel. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :returns: A task that represents the work queued to execute @@ -307,7 +307,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument Invoked when an activity other than a message, conversation update, or event is received when the base behavior of :meth:`ActivityHandler.on_turn()` is used. If overridden, this method could potentially respond to any of the other activity types. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a717051e7..cf2c8032d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -7,15 +7,7 @@ import os from typing import List, Callable, Awaitable, Union, Dict from msrest.serialization import Model -from botbuilder.schema import ( - Activity, - ActivityTypes, - ConversationAccount, - ConversationParameters, - ConversationReference, - TokenResponse, - ResourceResponse, -) + from botframework.connector import Channels, EmulatorApiClient from botframework.connector.aio import ConnectorClient from botframework.connector.auth import ( @@ -33,6 +25,15 @@ ) from botframework.connector.token_api import TokenApiClient from botframework.connector.token_api.models import TokenStatus +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationAccount, + ConversationParameters, + ConversationReference, + TokenResponse, + ResourceResponse, +) from . import __version__ from .bot_adapter import BotAdapter @@ -83,29 +84,28 @@ def __init__( channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, ): - """ - Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. - - :param app_id: The bot application ID. This is the appId returned by the Azure portal registration, and is - the value of the `MicrosoftAppId` parameter in the `config.py` file. - :type app_id: str - :param app_password: The bot application password. This is the password returned by the Azure portal registration, and is - the value os the `MicrosoftAppPassword` parameter in the `config.py` file. - :type app_password: str - :param channel_auth_tenant: The channel tenant to use in conversation - :type channel_auth_tenant: str - :param oauth_endpoint: - :type oauth_endpoint: str - :param open_id_metadata: - :type open_id_metadata: str - :param channel_service: - :type channel_service: str - :param channel_provider: The channel provider - :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` - :param auth_configuration: - :type auth_configuration: :class:`botframework.connector.auth.AuthenticationConfiguration` - - """ + """ + Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. + + :param app_id: The bot application ID. This is the appId returned by the Azure portal registration, and is + the value of the `MicrosoftAppId` parameter in the `config.py` file. + :type app_id: str + :param app_password: The bot application password. This is the password returned by the Azure portal registration, and is + the value os the `MicrosoftAppPassword` parameter in the `config.py` file. + :type app_password: str + :param channel_auth_tenant: The channel tenant to use in conversation + :type channel_auth_tenant: str + :param oauth_endpoint: + :type oauth_endpoint: str + :param open_id_metadata: + :type open_id_metadata: str + :param channel_service: + :type channel_service: str + :param channel_provider: The channel provider + :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` + :param auth_configuration: + :type auth_configuration: :class:`botframework.connector.auth.AuthenticationConfiguration` + """ self.app_id = app_id self.app_password = app_password self.channel_auth_tenant = channel_auth_tenant @@ -121,20 +121,20 @@ class BotFrameworkAdapter(BotAdapter, UserTokenProvider): Defines an adapter to connect a bot to a service endpoint. .. remarks:: - The bot adapter encapsulates authentication processes and sends activities to and - receives activities from the Bot Connector Service. When your bot receives an activity, - the adapter creates a context object, passes it to your bot's application logic, and - sends responses back to the user's channel. - The adapter processes and directs incoming activities in through the bot middleware - pipeline to your bot’s logic and then back out again. - As each activity flows in and out of the bot, each piece of middleware can inspect or act + The bot adapter encapsulates authentication processes and sends activities to and + receives activities from the Bot Connector Service. When your bot receives an activity, + the adapter creates a context object, passes it to your bot's application logic, and + sends responses back to the user's channel. + The adapter processes and directs incoming activities in through the bot middleware + pipeline to your bot’s logic and then back out again. + As each activity flows in and out of the bot, each piece of middleware can inspect or act upon the activity, both before and after the bot logic runs. """ _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" def __init__(self, settings: BotFrameworkAdapterSettings): - """ + """ Initializes a new instance of the :class:`BotFrameworkAdapter` class. :param settings: The settings to initialize the adapter @@ -184,25 +184,25 @@ async def continue_conversation( claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): """ - Continues a conversation with a user. + Continues a conversation with a user. :param reference: A reference to the conversation to continue - :type reference: :class:`botbuilder.schema.ConversationReference + :type reference: :class:`botbuilder.schema.ConversationReference :param callback: The method to call for the resulting bot turn :type callback: :class:`typing.Callable` - :param bot_id: The application Id of the bot. This is the appId returned by the Azure portal registration, + :param bot_id: The application Id of the bot. This is the appId returned by the Azure portal registration, and is generally found in the `MicrosoftAppId` parameter in `config.py`. :type bot_id: :class:`typing.str` :param claims_identity: The bot claims identity :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` :raises: It raises an argument null exception. - + :return: A task that represents the work queued to execute. - + .. note:: - This is often referred to as the bots *proactive messaging* flow as it lets the bot proactively - send messages to a conversation or user that are already in a communication. + This is often referred to as the bots *proactive messaging* flow as it lets the bot proactively + send messages to a conversation or user that are already in a communication. Scenarios such as sending notifications or coupons to a user are enabled by this function. """ # TODO: proactive messages @@ -233,9 +233,9 @@ async def create_conversation( Starts a new conversation with a user. Used to direct message to a member of a group. :param reference: The conversation reference that contains the tenant - :type reference: :class:`botbuilder.schema.ConversationReference` + :type reference: :class:`botbuilder.schema.ConversationReference` :param logic: The logic to use for the creation of the conversation - :type logic: :class:`typing.Callable` + :type logic: :class:`typing.Callable` :param conversation_parameters: The information to use to create the conversation :type conversation_parameters: @@ -244,7 +244,7 @@ async def create_conversation( :return: A task representing the work queued to execute. .. note:: - To start a conversation, your bot must know its account information and the user's + To start a conversation, your bot must know its account information and the user's account information on that channel. Most channels only support initiating a direct message (non-group) conversation. The adapter attempts to create a new conversation on the channel, and @@ -309,17 +309,17 @@ async def process_activity(self, req, auth_header: str, logic: Callable): :param req: The incoming activity :type req: :class:`typing.str` :param auth_header: The HTTP authentication header of the request - :type auth_header: :class:`typing.str` + :type auth_header: :class:`typing.str` :param logic: The logic to execute at the end of the adapter's middleware pipeline. - :type logic: :class:`typing.Callable` + :type logic: :class:`typing.Callable` :return: A task that represents the work queued to execute. If the activity type - was `Invoke` and the corresponding key (`channelId` + `activityId`) was found then + was `Invoke` and the corresponding key (`channelId` + `activityId`) was found then an :class:`InvokeResponse` is returned; otherwise, `null` is returned. - .. note:: + .. note:: Call this method to reactively send a message to a conversation. - If the task completes successfully, then an :class:`InvokeResponse` is returned; + If the task completes successfully, then an :class:`InvokeResponse` is returned; otherwise. `null` is returned. """ @@ -368,9 +368,9 @@ async def authenticate_request( :param request: The request to authenticate :type request: :class:`botbuilder.schema.Activity` :param auth_header: The authentication header - + :raises: A permission exception error. - + :return: The request claims identity :rtype: :class:`botframework.connector.auth.ClaimsIdentity` """ @@ -391,7 +391,7 @@ def create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. :param activity: - :return: + :return: """ return TurnContext(self, activity) @@ -453,9 +453,9 @@ async def update_activity(self, context: TurnContext, activity: Activity): :return: A task that represents the work queued to execute - .. note:: + .. note:: If the activity is successfully sent, the task result contains - a :class:`botbuilder.schema.ResourceResponse` object containing the ID that + a :class:`botbuilder.schema.ResourceResponse` object containing the ID that the receiving channel assigned to the activity. Before calling this function, set the ID of the replacement activity to the ID of the activity to replace. @@ -479,13 +479,13 @@ async def delete_activity( :param context: The context object for the turn :type context: :class:`TurnContext' :param reference: Conversation reference for the activity to delete - :type reference: :class:`botbuilder.schema.ConversationReference` + :type reference: :class:`botbuilder.schema.ConversationReference` :raises: A exception error :return: A task that represents the work queued to execute - .. note:: + .. note:: The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: @@ -597,12 +597,12 @@ async def delete_conversation_member( async def get_activity_members(self, context: TurnContext, activity_id: str): """ Lists the members of a given activity. - + :param context: The context object for the turn :type context: :class:`TurnContext` - :param activity_id: (Optional) Activity ID to enumerate. + :param activity_id: (Optional) Activity ID to enumerate. If not specified the current activities ID will be used. - + :raises: An exception error :return: List of Members of the activity @@ -639,7 +639,7 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): async def get_conversation_members(self, context: TurnContext): """ Lists the members of a current conversation. - + :param context: The context object for the turn :type context: :class:`TurnContext` @@ -676,7 +676,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :param service_url: The URL of the channel server to query. This can be retrieved from `context.activity.serviceUrl` :type service_url: str - + :param continuation_token: The continuation token from the previous page of results :type continuation_token: str @@ -696,14 +696,14 @@ async def get_user_token( """ Attempts to retrieve the token for a user that's in a login flow. - + :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str :param magic_code" (Optional) user entered code to validate :str magic_code" str - + :raises: An exception error :returns: Token Response @@ -743,14 +743,14 @@ async def sign_out_user( ) -> str: """ Signs the user out with the token server. - + :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str :param user_id: User id of user to sign out :type user_id: str - + :returns: A task that represents the work queued to execute """ if not context.activity.from_property or not context.activity.from_property.id: @@ -772,14 +772,14 @@ async def get_oauth_sign_in_link( ) -> str: """ Gets the raw sign-in link to be sent to the user for sign-in for a connection name. - + :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str :returns: A task that represents the work queued to execute - + .. note:: If the task completes successfully, the result contains the raw sign-in link """ @@ -805,12 +805,12 @@ async def get_token_status( """ Retrieves the token status for each configured connection for the given user. - + :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` :param user_id: The user Id for which token status is retrieved :type user_id: str - :param include_filter: (Optional) Comma separated list of connection's to include. + :param include_filter: (Optional) Comma separated list of connection's to include. Blank will return token status for all configured connections. :type include_filter: str @@ -839,7 +839,7 @@ async def get_aad_tokens( ) -> Dict[str, TokenResponse]: """ Retrieves Azure Active Directory tokens for particular resources on a configured connection. - + :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` @@ -848,7 +848,7 @@ async def get_aad_tokens( :param resource_urls: The list of resource URLs to retrieve tokens for :type resource_urls: :class:`typing.List` - + :returns: Dictionary of resource Urls to the corresponding :class:'botbuilder.schema.TokenResponse` :rtype: :class:`typing.Dict` """ @@ -873,8 +873,8 @@ async def create_connector_client( """Allows for mocking of the connector client in unit tests :param service_url: The service URL :param identity: The claims identity - - :return: An instance of the :class:`ConnectorClient` class + + :return: An instance of the :class:`ConnectorClient` class """ if identity: bot_app_id_claim = identity.claims.get( diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 2718c1889..7f2c26984 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -12,12 +12,12 @@ class CachedBotState: - """ + """ Internal cached bot state. """ def __init__(self, state: Dict[str, object] = None): - + self.state = state if state is not None else {} self.hash = self.compute_hash(state) @@ -31,7 +31,7 @@ def compute_hash(self, obj: object) -> str: class BotState(PropertyManager): """ - Defines a state management object and automates the reading and writing of + Defines a state management object and automates the reading and writing of associated state properties to a storage layer. .. remarks:: @@ -42,7 +42,7 @@ class BotState(PropertyManager): """ def __init__(self, storage: Storage, context_service_key: str): - """ + """ Initializes a new instance of the :class:`BotState` class. :param storage: The storage layer this state management object will use to store and retrieve state @@ -51,8 +51,8 @@ def __init__(self, storage: Storage, context_service_key: str): :type context_service_key: str .. note:: - This constructor creates a state management object and associated scope. The object uses - the :param storage: to persist state property values and the :param context_service_key: to cache state + This constructor creates a state management object and associated scope. The object uses + the :param storage: to persist state property values and the :param context_service_key: to cache state within the context for each turn. :raises: It raises an argument null exception. @@ -105,7 +105,7 @@ async def save_changes( """ Saves the state cached in the current context for this turn. If the state has changed, it saves the state cached in the current context for this turn. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :param force: Optional, true to save state to storage whether or not there are changes @@ -125,12 +125,12 @@ async def save_changes( async def clear_state(self, turn_context: TurnContext): """ Clears any state currently stored in this state scope. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :return: None - + .. note:: This function must be called in order for the cleared state to be persisted to the underlying store. """ @@ -166,12 +166,12 @@ def get_storage_key(self, turn_context: TurnContext) -> str: async def get_property_value(self, turn_context: TurnContext, property_name: str): """ Gets the value of the specified property in the turn context. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :param property_name: The property name :type property_name: str - + :return: The value of the property """ if turn_context is None: @@ -193,12 +193,12 @@ async def delete_property_value( ) -> None: """ Deletes a property from the state cache in the turn context. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :param property_name: The name of the property to delete :type property_name: str - + :return: None """ if turn_context is None: @@ -213,7 +213,7 @@ async def set_property_value( ) -> None: """ Sets a property to the specified value in the turn context. - + :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` :param property_name: The property name diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index fd39935e0..333139cbd 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -17,9 +17,9 @@ class ConversationState(BotState): no_key_error_message = "ConversationState: channelId and/or conversation missing from context.activity." def __init__(self, storage: Storage): - """ + """ Creates a :class:`ConversationState` instance. - + Creates a new instance of the :class:`ConversationState` class. :param storage: The storage containing the conversation state. :type storage: :class:`Storage` @@ -27,14 +27,14 @@ def __init__(self, storage: Storage): super(ConversationState, self).__init__(storage, "ConversationState") def get_storage_key(self, turn_context: TurnContext) -> object: - """ + """ Gets the key to use when reading and writing state to and from storage. :param turn_context: The context object for this turn. - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`TurnContext` :raise: :class:`TypeError` if the :meth:`TurnContext.activity` for the current turn is missing - :class:`botbuilder.schema.Activity` channelId or conversation information or the conversation's + :class:`botbuilder.schema.Activity` channelId or conversation information or the conversation's account id is missing. :return: The storage key. @@ -58,6 +58,6 @@ def get_storage_key(self, turn_context: TurnContext) -> object: def __raise_type_error(self, err: str = "NoneType found while expecting value"): """ Raise type error exception - :raises: :class:`TypeError` + :raises: :class:`TypeError` """ raise TypeError(err) From fd0059ae7bdf2d6a6c25868b817a11b8296b2320 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jan 2020 14:39:47 -0600 Subject: [PATCH 196/616] black corrections --- libraries/botbuilder-core/botbuilder/core/activity_handler.py | 2 +- .../botbuilder-core/botbuilder/core/conversation_state.py | 1 + .../botbuilder/dialogs/dialog_turn_status.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 5fe2ac086..ef3f4b627 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -106,7 +106,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext - ): # pylint: disable=unused-argument + ): # pylint: disable=unused-argument """ Override this method in a derived class to provide logic for when members other than the bot join the conversation. You can add your bot's welcome logic. diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 333139cbd..4014f54a0 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -14,6 +14,7 @@ class ConversationState(BotState): .. remarks:: Conversation state is available in any turn in a specific conversation, regardless of the user, such as in a group conversation. """ + no_key_error_message = "ConversationState: channelId and/or conversation missing from context.activity." def __init__(self, storage: Storage): diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py index 46be68c85..b88cd359b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from enum import Enum + class DialogTurnStatus(Enum): """ Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` @@ -15,7 +16,7 @@ class DialogTurnStatus(Enum): :var Cancelled: Indicates that the dialog was cancelled and the stack is empty. :vartype Cancelled: int """ - + Empty = 1 Waiting = 2 From a68c5e8d2ee439fda4ba51cf74cbfef725fe41f0 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jan 2020 14:57:19 -0600 Subject: [PATCH 197/616] More pylint corrections --- .../botbuilder/core/activity_handler.py | 78 +++++++++++-------- .../botbuilder/core/bot_framework_adapter.py | 12 ++- .../botbuilder/dialogs/dialog_reason.py | 3 +- .../botbuilder/dialogs/prompts/prompt.py | 2 - 4 files changed, 57 insertions(+), 38 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index ef3f4b627..8d7b7cccc 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -23,8 +23,10 @@ async def on_turn(self, turn_context: TurnContext): In a derived class, override this method to add logic that applies to all activity types. .. note:: - - Add logic to apply before the type-specific logic and before the call to the :meth:`ActivityHandler.on_turn()` method. - - Add logic to apply after the type-specific logic after the call to the :meth:`ActivityHandler.on_turn()` method. + - Add logic to apply before the type-specific logic and before the call to the + :meth:`ActivityHandler.on_turn()` method. + - Add logic to apply after the type-specific logic after the call to the + :meth:`ActivityHandler.on_turn()` method. """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -72,7 +74,8 @@ async def on_message_activity( # pylint: disable=unused-argument async def on_conversation_update_activity(self, turn_context: TurnContext): """ - Invoked when a conversation update activity is received from the channel when the base behavior of :meth:`ActivityHandler.on_turn()` is used. + Invoked when a conversation update activity is received from the channel when the base behavior of + :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -80,7 +83,8 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this method. + When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this + method. If the conversation update activity indicates that members other than the bot joined the conversation, it calls the :meth:`ActivityHandler.on_members_added_activity()` method. If the conversation update activity indicates that members other than the bot left the conversation, @@ -108,10 +112,11 @@ async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument """ - Override this method in a derived class to provide logic for when members other than the bot join the conversation. - You can add your bot's welcome logic. + Override this method in a derived class to provide logic for when members other than the bot join + the conversation. You can add your bot's welcome logic. - :param members_added: A list of all the members added to the conversation, as described by the conversation update activity + :param members_added: A list of all the members added to the conversation, as described by the + conversation update activity :type members_added: :class:`typing.List` :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -119,7 +124,8 @@ async def on_members_added_activity( :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation + update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. """ return @@ -128,10 +134,11 @@ async def on_members_removed_activity( self, members_removed: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument """ - Override this method in a derived class to provide logic for when members other than the bot leave the conversation. - You can add your bot's good-bye logic. + Override this method in a derived class to provide logic for when members other than the bot leave + the conversation. You can add your bot's good-bye logic. - :param members_added: A list of all the members removed from the conversation, as described by the conversation update activity + :param members_added: A list of all the members removed from the conversation, as described by the + conversation update activity :type members_added: :class:`typing.List` :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -139,16 +146,17 @@ async def on_members_removed_activity( :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates - one or more users other than the bot are leaving the conversation, it calls this method. + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation + update activity that indicates one or more users other than the bot are leaving the conversation, + it calls this method. """ return async def on_message_reaction_activity(self, turn_context: TurnContext): """ - Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. - + Invoked when an event activity is received from the connector when the base behavior of + :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -156,15 +164,20 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. note:: - Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. - Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the - reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response - from a send call. - When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this method. - If the message reaction indicates that reactions were added to a message, it calls :meth:'ActivityHandler.on_reaction_added(). - If the message reaction indicates that reactions were removed from a message, it calls :meth:'ActivityHandler.on_reaction_removed(). + Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously + sent activity. + Message reactions are only supported by a few channels. The activity that the message reaction corresponds + to is indicated in the reply to Id property. The value of this property is the activity id of a previously + sent activity given back to the bot as the response from a send call. + When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this + method. + If the message reaction indicates that reactions were added to a message, it calls + :meth:'ActivityHandler.on_reaction_added(). + If the message reaction indicates that reactions were removed from a message, it calls + :meth:'ActivityHandler.on_reaction_removed(). In a derived class, override this method to add logic that applies to all message reaction activities. - Add logic to apply before the reactions added or removed logic before the call to the this base class method. + Add logic to apply before the reactions added or removed logic before the call to the this base class + method. Add logic to apply after the reactions added or removed logic after the call to the this base class method. """ if turn_context.activity.reactions_added is not None: @@ -225,7 +238,8 @@ async def on_reactions_removed( # pylint: disable=unused-argument async def on_event_activity(self, turn_context: TurnContext): """ - Invoked when an event activity is received from the connector when the base behavior of :meth:'ActivityHandler.on_turn()` is used. + Invoked when an event activity is received from the connector when the base behavior of + :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`TurnContext` @@ -254,7 +268,8 @@ async def on_token_response_event( # pylint: disable=unused-argument self, turn_context: TurnContext ): """ - Invoked when a `tokens/response` event is received when the base behavior of :meth:'ActivityHandler.on_event_activity()` is used. + Invoked when a `tokens/response` event is received when the base behavior of + :meth:'ActivityHandler.on_event_activity()` is used. If using an `oauth_prompt`, override this method to forward this activity to the current dialog. :param turn_context: The context object for this turn @@ -263,8 +278,9 @@ async def on_token_response_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. note:: - When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of `tokens/response`, - it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. + When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of + `tokens/response`, it calls this method. If your bot uses an `oauth_prompt`, forward the incoming + activity to the current dialog. """ return @@ -304,8 +320,8 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): """ - Invoked when an activity other than a message, conversation update, or event is received when the base behavior of - :meth:`ActivityHandler.on_turn()` is used. + Invoked when an activity other than a message, conversation update, or event is received when the base + behavior of :meth:`ActivityHandler.on_turn()` is used. If overridden, this method could potentially respond to any of the other activity types. :param turn_context: The context object for this turn @@ -314,7 +330,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. note:: - When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, conversation update, message reaction, - or event activity, it calls this method. + When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, + conversation update, message reaction, or event activity, it calls this method. """ return diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index cf2c8032d..6f4681437 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -90,7 +90,8 @@ def __init__( :param app_id: The bot application ID. This is the appId returned by the Azure portal registration, and is the value of the `MicrosoftAppId` parameter in the `config.py` file. :type app_id: str - :param app_password: The bot application password. This is the password returned by the Azure portal registration, and is + :param app_password: The bot application password. This is the password returned by the Azure portal + registration, and is the value os the `MicrosoftAppPassword` parameter in the `config.py` file. :type app_password: str :param channel_auth_tenant: The channel tenant to use in conversation @@ -674,7 +675,8 @@ async def get_conversations(self, service_url: str, continuation_token: str = No returns results in pages and each page will include a `continuationToken` that can be used to fetch the next page of results from the server. - :param service_url: The URL of the channel server to query. This can be retrieved from `context.activity.serviceUrl` + :param service_url: The URL of the channel server to query. This can be retrieved from + `context.activity.serviceUrl` :type service_url: str :param continuation_token: The continuation token from the previous page of results @@ -684,8 +686,10 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :return: A task that represents the work queued to execute - .. note:: If the task completes successfully, the result contains a page of the members of the current conversation. - This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. + .. note:: If the task completes successfully, the result contains a page of the members of the current + conversation. + This overload may be called from outside the context of a conversation, as only the bot's service URL and + credentials are required. """ client = await self.create_connector_client(service_url) return await client.conversations.get_conversations(continuation_token) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index a017a33df..7c8eb9bef 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -13,7 +13,8 @@ class DialogReason(Enum): :vartype ContinueCalled: int :var EndCalled: A dialog ended normally through a call to `DialogContext.end_dialog() :vartype EndCalled: int - :var ReplaceCalled: A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. + :var ReplaceCalled: A dialog is ending because it's being replaced through a call to + `DialogContext.replace_dialog()`. :vartype ReplacedCalled: int :var CancelCalled: A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. :vartype CancelCalled: int diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index e1df46e6f..8c9c0edc5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -215,7 +215,6 @@ async def on_prompt( :return: A :class:Task representing the asynchronous operation. :rtype: :class:Task """ - pass @abstractmethod async def on_recognize( @@ -238,7 +237,6 @@ async def on_recognize( :return: A :class:Task representing the asynchronous operation. :rtype: :class:Task """ - pass def append_choices( self, From 5685d77d6c657865131c18e21f731271b338e6d4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jan 2020 15:05:34 -0600 Subject: [PATCH 198/616] ConversationState pylint --- .../botbuilder-core/botbuilder/core/conversation_state.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 4014f54a0..445b8949d 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -12,7 +12,8 @@ class ConversationState(BotState): Extends :class:`BootState` base class. .. remarks:: - Conversation state is available in any turn in a specific conversation, regardless of the user, such as in a group conversation. + Conversation state is available in any turn in a specific conversation, regardless of the user, such as + in a group conversation. """ no_key_error_message = "ConversationState: channelId and/or conversation missing from context.activity." From ef87e20bf9285a641e7ad6193ea559438d4a5bee Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Thu, 30 Jan 2020 14:44:20 -0800 Subject: [PATCH 199/616] Fixed style errors Indented .. note:: content. Replaced :method: with :meth: --- libraries/botbuilder-core/botbuilder/core/bot_state.py | 2 +- .../botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 7f2c26984..0b7aaa53d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -132,7 +132,7 @@ async def clear_state(self, turn_context: TurnContext): :return: None .. note:: - This function must be called in order for the cleared state to be persisted to the underlying store. + This function must be called in order for the cleared state to be persisted to the underlying store. """ if turn_context is None: raise TypeError("BotState.clear_state(): turn_context cannot be None.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 8c9c0edc5..3ed1a51f0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -28,9 +28,9 @@ class Prompt(Dialog): .. remarks:: When the prompt ends, it returns an object that represents the value it was prompted for. - Use :method:`DialogSet.add()` or :method:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or + Use :meth:`DialogSet.add()` or :meth:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or component dialog, respectively. - Use :method:`DialogContext.prompt()` or :meth:`DialogContext.begin_dialog()` to start the prompt. + Use :meth:`DialogContext.prompt()` or :meth:`DialogContext.begin_dialog()` to start the prompt. .. note:: If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result will be available in the next step of the waterfall. From 153dcfc63b0621a4c0586d4f124171c98b100f82 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 31 Jan 2020 12:47:39 -0600 Subject: [PATCH 200/616] Sets version on Python libs (#680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sets version on Python libs * pylint: Sets version on Python libs Co-authored-by: Axel Suárez --- .../botbuilder-ai/botbuilder/ai/about.py | 28 +++---- .../botbuilder/applicationinsights/about.py | 2 +- .../botbuilder/azure/about.py | 28 +++---- .../botbuilder-core/botbuilder/core/about.py | 28 +++---- libraries/botbuilder-core/setup.py | 2 +- .../botbuilder/dialogs/about.py | 28 +++---- libraries/botbuilder-schema/setup.py | 74 +++++++++---------- .../botbuilder/testing/about.py | 30 ++++---- libraries/botframework-connector/setup.py | 2 +- 9 files changed, 111 insertions(+), 111 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/about.py b/libraries/botbuilder-ai/botbuilder/ai/about.py index 96d47800b..2fe559dac 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/about.py +++ b/libraries/botbuilder-ai/botbuilder/ai/about.py @@ -1,14 +1,14 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - -__title__ = "botbuilder-ai" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-ai" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py index bae8313bf..4c5006f5f 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-applicationinsights" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-azure/botbuilder/azure/about.py b/libraries/botbuilder-azure/botbuilder/azure/about.py index 94b912127..a2276b583 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/about.py +++ b/libraries/botbuilder-azure/botbuilder/azure/about.py @@ -1,14 +1,14 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - -__title__ = "botbuilder-azure" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-azure" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-core/botbuilder/core/about.py b/libraries/botbuilder-core/botbuilder/core/about.py index 6ec169c3a..f47c7fd78 100644 --- a/libraries/botbuilder-core/botbuilder/core/about.py +++ b/libraries/botbuilder-core/botbuilder/core/about.py @@ -1,14 +1,14 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - -__title__ = "botbuilder-core" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-core" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index 1ce1a0e39..21a356b49 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -4,7 +4,7 @@ import os from setuptools import setup -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" REQUIRES = [ "botbuilder-schema>=4.7.1", "botframework-connector>=4.7.1", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py index f8c29f033..2f0ceb142 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py @@ -1,14 +1,14 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - -__title__ = "botbuilder-dialogs" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-dialogs" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-schema/setup.py b/libraries/botbuilder-schema/setup.py index a7fc298b2..bb183e091 100644 --- a/libraries/botbuilder-schema/setup.py +++ b/libraries/botbuilder-schema/setup.py @@ -1,37 +1,37 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -from setuptools import setup - -NAME = "botbuilder-schema" -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -REQUIRES = ["msrest==0.6.10"] - -root = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name=NAME, - version=VERSION, - description="BotBuilder Schema", - author="Microsoft", - url="https://github.com/Microsoft/botbuilder-python", - keywords=["BotBuilderSchema", "bots", "ai", "botframework", "botbuilder"], - long_description=long_description, - long_description_content_type="text/x-rst", - license="MIT", - install_requires=REQUIRES, - packages=["botbuilder.schema", "botbuilder.schema.teams",], - include_package_data=True, - classifiers=[ - "Programming Language :: Python :: 3.7", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Development Status :: 5 - Production/Stable", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - ], -) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +NAME = "botbuilder-schema" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +REQUIRES = ["msrest==0.6.10"] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name=NAME, + version=VERSION, + description="BotBuilder Schema", + author="Microsoft", + url="https://github.com/Microsoft/botbuilder-python", + keywords=["BotBuilderSchema", "bots", "ai", "botframework", "botbuilder"], + long_description=long_description, + long_description_content_type="text/x-rst", + license="MIT", + install_requires=REQUIRES, + packages=["botbuilder.schema", "botbuilder.schema.teams",], + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.7", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/libraries/botbuilder-testing/botbuilder/testing/about.py b/libraries/botbuilder-testing/botbuilder/testing/about.py index d44f889b1..09585f325 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/about.py +++ b/libraries/botbuilder-testing/botbuilder/testing/about.py @@ -1,15 +1,15 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - - -__title__ = "botbuilder-testing" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +__title__ = "botbuilder-testing" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 7855654a6..6c2b30e16 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -4,7 +4,7 @@ from setuptools import setup NAME = "botframework-connector" -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" REQUIRES = [ "msrest==0.6.10", "requests==2.22.0", From 5217edbfc95b4e7ff91cde9770c0d2245af51cf0 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 31 Jan 2020 13:34:18 -0800 Subject: [PATCH 201/616] continue conversation fix for skills --- .../botbuilder/core/bot_framework_adapter.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 6f4681437..cedc42ace 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -222,6 +222,9 @@ async def continue_conversation( context = TurnContext(self, get_continuation_activity(reference)) context.turn_state[BOT_IDENTITY_KEY] = claims_identity context.turn_state["BotCallbackHandler"] = callback + await self._ensure_channel_connector_client_is_created( + reference.service_url, claims_identity + ) return await self.run_pipeline(context, callback) async def create_conversation( @@ -965,3 +968,31 @@ def check_emulating_oauth_cards(self, context: TurnContext): ) ): self._is_emulating_oauth_cards = True + + async def _ensure_channel_connector_client_is_created( + self, service_url: str, claims_identity: ClaimsIdentity + ): + # Ensure we have a default ConnectorClient and MSAppCredentials instance for the audience. + audience = claims_identity.claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + + if ( + not audience + or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER != audience + ): + # We create a default connector for audiences that are not coming from + # the default https://api.botframework.com audience. + # We create a default claim that contains only the desired audience. + default_connector_claims = { + AuthenticationConstants.AUDIENCE_CLAIM: audience + } + connector_claims_identity = ClaimsIdentity( + claims=default_connector_claims, is_authenticated=True + ) + + await self.create_connector_client(service_url, connector_claims_identity) + + if SkillValidation.is_skill_claim(claims_identity.claims): + # Add the channel service URL to the trusted services list so we can send messages back. + # the service URL for skills is trusted because it is applied by the + # SkillHandler based on the original request received by the root bot + MicrosoftAppCredentials.trust_service_url(service_url) From 2c0966256101c38f1a7a7af3132b2deb0f884d7d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 3 Feb 2020 12:09:21 -0600 Subject: [PATCH 202/616] Fix for #544: special chars in bot_name cause remove_mention_text to fail --- .../botbuilder/core/turn_context.py | 766 +++++++++--------- .../tests/test_turn_context.py | 723 +++++++++-------- 2 files changed, 755 insertions(+), 734 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 5b3299d74..f316584a9 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -1,383 +1,383 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import re -from copy import copy, deepcopy -from datetime import datetime -from typing import List, Callable, Union, Dict -from botbuilder.schema import ( - Activity, - ActivityTypes, - ConversationReference, - InputHints, - Mention, - ResourceResponse, -) - - -class TurnContext: - def __init__(self, adapter_or_context, request: Activity = None): - """ - Creates a new TurnContext instance. - :param adapter_or_context: - :param request: - """ - if isinstance(adapter_or_context, TurnContext): - adapter_or_context.copy_to(self) - else: - self.adapter = adapter_or_context - self._activity = request - self.responses: List[Activity] = [] - self._services: dict = {} - self._on_send_activities: Callable[ - ["TurnContext", List[Activity], Callable], List[ResourceResponse] - ] = [] - self._on_update_activity: Callable[ - ["TurnContext", Activity, Callable], ResourceResponse - ] = [] - self._on_delete_activity: Callable[ - ["TurnContext", ConversationReference, Callable], None - ] = [] - self._responded: bool = False - - if self.adapter is None: - raise TypeError("TurnContext must be instantiated with an adapter.") - if self.activity is None: - raise TypeError( - "TurnContext must be instantiated with a request parameter of type Activity." - ) - - self._turn_state = {} - - @property - def turn_state(self) -> Dict[str, object]: - return self._turn_state - - def copy_to(self, context: "TurnContext") -> None: - """ - Called when this TurnContext instance is passed into the constructor of a new TurnContext - instance. Can be overridden in derived classes. - :param context: - :return: - """ - for attribute in [ - "adapter", - "activity", - "_responded", - "_services", - "_on_send_activities", - "_on_update_activity", - "_on_delete_activity", - ]: - setattr(context, attribute, getattr(self, attribute)) - - @property - def activity(self): - """ - The received activity. - :return: - """ - return self._activity - - @activity.setter - def activity(self, value): - """ - Used to set TurnContext._activity when a context object is created. Only takes instances of Activities. - :param value: - :return: - """ - if not isinstance(value, Activity): - raise TypeError( - "TurnContext: cannot set `activity` to a type other than Activity." - ) - self._activity = value - - @property - def responded(self) -> bool: - """ - If `true` at least one response has been sent for the current turn of conversation. - :return: - """ - return self._responded - - @responded.setter - def responded(self, value: bool): - if not value: - raise ValueError("TurnContext: cannot set TurnContext.responded to False.") - self._responded = True - - @property - def services(self): - """ - Map of services and other values cached for the lifetime of the turn. - :return: - """ - return self._services - - def get(self, key: str) -> object: - if not key or not isinstance(key, str): - raise TypeError('"key" must be a valid string.') - try: - return self._services[key] - except KeyError: - raise KeyError("%s not found in TurnContext._services." % key) - - def has(self, key: str) -> bool: - """ - Returns True is set() has been called for a key. The cached value may be of type 'None'. - :param key: - :return: - """ - if key in self._services: - return True - return False - - def set(self, key: str, value: object) -> None: - """ - Caches a value for the lifetime of the current turn. - :param key: - :param value: - :return: - """ - if not key or not isinstance(key, str): - raise KeyError('"key" must be a valid string.') - - self._services[key] = value - - async def send_activity( - self, - activity_or_text: Union[Activity, str], - speak: str = None, - input_hint: str = None, - ) -> ResourceResponse: - """ - Sends a single activity or message to the user. - :param activity_or_text: - :return: - """ - if isinstance(activity_or_text, str): - activity_or_text = Activity( - text=activity_or_text, - input_hint=input_hint or InputHints.accepting_input, - speak=speak, - ) - - result = await self.send_activities([activity_or_text]) - return result[0] if result else None - - async def send_activities( - self, activities: List[Activity] - ) -> List[ResourceResponse]: - sent_non_trace_activity = False - ref = TurnContext.get_conversation_reference(self.activity) - - def activity_validator(activity: Activity) -> Activity: - if not getattr(activity, "type", None): - activity.type = ActivityTypes.message - if activity.type != ActivityTypes.trace: - nonlocal sent_non_trace_activity - sent_non_trace_activity = True - if not activity.input_hint: - activity.input_hint = "acceptingInput" - activity.id = None - return activity - - output = [ - activity_validator( - TurnContext.apply_conversation_reference(deepcopy(act), ref) - ) - for act in activities - ] - - async def logic(): - responses = await self.adapter.send_activities(self, output) - if sent_non_trace_activity: - self.responded = True - return responses - - return await self._emit(self._on_send_activities, output, logic()) - - async def update_activity(self, activity: Activity): - """ - Replaces an existing activity. - :param activity: - :return: - """ - reference = TurnContext.get_conversation_reference(self.activity) - - return await self._emit( - self._on_update_activity, - TurnContext.apply_conversation_reference(activity, reference), - self.adapter.update_activity(self, activity), - ) - - async def delete_activity(self, id_or_reference: Union[str, ConversationReference]): - """ - Deletes an existing activity. - :param id_or_reference: - :return: - """ - if isinstance(id_or_reference, str): - reference = TurnContext.get_conversation_reference(self.activity) - reference.activity_id = id_or_reference - else: - reference = id_or_reference - return await self._emit( - self._on_delete_activity, - reference, - self.adapter.delete_activity(self, reference), - ) - - def on_send_activities(self, handler) -> "TurnContext": - """ - Registers a handler to be notified of and potentially intercept the sending of activities. - :param handler: - :return: - """ - self._on_send_activities.append(handler) - return self - - def on_update_activity(self, handler) -> "TurnContext": - """ - Registers a handler to be notified of and potentially intercept an activity being updated. - :param handler: - :return: - """ - self._on_update_activity.append(handler) - return self - - def on_delete_activity(self, handler) -> "TurnContext": - """ - Registers a handler to be notified of and potentially intercept an activity being deleted. - :param handler: - :return: - """ - self._on_delete_activity.append(handler) - return self - - async def _emit(self, plugins, arg, logic): - handlers = copy(plugins) - - async def emit_next(i: int): - context = self - try: - if i < len(handlers): - - async def next_handler(): - await emit_next(i + 1) - - await handlers[i](context, arg, next_handler) - - except Exception as error: - raise error - - await emit_next(0) - # logic does not use parentheses because it's a coroutine - return await logic - - async def send_trace_activity( - self, name: str, value: object, value_type: str, label: str - ) -> ResourceResponse: - trace_activity = Activity( - type=ActivityTypes.trace, - timestamp=datetime.utcnow(), - name=name, - value=value, - value_type=value_type, - label=label, - ) - - return await self.send_activity(trace_activity) - - @staticmethod - def get_conversation_reference(activity: Activity) -> ConversationReference: - """ - Returns the conversation reference for an activity. This can be saved as a plain old JSON - object and then later used to message the user proactively. - - Usage Example: - reference = TurnContext.get_conversation_reference(context.request) - :param activity: - :return: - """ - return ConversationReference( - activity_id=activity.id, - user=copy(activity.from_property), - bot=copy(activity.recipient), - conversation=copy(activity.conversation), - channel_id=activity.channel_id, - service_url=activity.service_url, - ) - - @staticmethod - def apply_conversation_reference( - activity: Activity, reference: ConversationReference, is_incoming: bool = False - ) -> Activity: - """ - Updates an activity with the delivery information from a conversation reference. Calling - this after get_conversation_reference on an incoming activity - will properly address the reply to a received activity. - :param activity: - :param reference: - :param is_incoming: - :return: - """ - activity.channel_id = reference.channel_id - activity.service_url = reference.service_url - activity.conversation = reference.conversation - if is_incoming: - activity.from_property = reference.user - activity.recipient = reference.bot - if reference.activity_id: - activity.id = reference.activity_id - else: - activity.from_property = reference.bot - activity.recipient = reference.user - if reference.activity_id: - activity.reply_to_id = reference.activity_id - - return activity - - @staticmethod - def get_reply_conversation_reference( - activity: Activity, reply: ResourceResponse - ) -> ConversationReference: - reference: ConversationReference = TurnContext.get_conversation_reference( - activity - ) - - # Update the reference with the new outgoing Activity's id. - reference.activity_id = reply.id - - return reference - - @staticmethod - def remove_recipient_mention(activity: Activity) -> str: - return TurnContext.remove_mention_text(activity, activity.recipient.id) - - @staticmethod - def remove_mention_text(activity: Activity, identifier: str) -> str: - mentions = TurnContext.get_mentions(activity) - for mention in mentions: - if mention.additional_properties["mentioned"]["id"] == identifier: - mention_name_match = re.match( - r"(.*?)<\/at>", - mention.additional_properties["text"], - re.IGNORECASE, - ) - if mention_name_match: - activity.text = re.sub( - mention_name_match.groups()[1], "", activity.text - ) - activity.text = re.sub(r"<\/at>", "", activity.text) - return activity.text - - @staticmethod - def get_mentions(activity: Activity) -> List[Mention]: - result: List[Mention] = [] - if activity.entities is not None: - for entity in activity.entities: - if entity.type.lower() == "mention": - result.append(entity) - - return result +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +from copy import copy, deepcopy +from datetime import datetime +from typing import List, Callable, Union, Dict +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + InputHints, + Mention, + ResourceResponse, +) + + +class TurnContext: + def __init__(self, adapter_or_context, request: Activity = None): + """ + Creates a new TurnContext instance. + :param adapter_or_context: + :param request: + """ + if isinstance(adapter_or_context, TurnContext): + adapter_or_context.copy_to(self) + else: + self.adapter = adapter_or_context + self._activity = request + self.responses: List[Activity] = [] + self._services: dict = {} + self._on_send_activities: Callable[ + ["TurnContext", List[Activity], Callable], List[ResourceResponse] + ] = [] + self._on_update_activity: Callable[ + ["TurnContext", Activity, Callable], ResourceResponse + ] = [] + self._on_delete_activity: Callable[ + ["TurnContext", ConversationReference, Callable], None + ] = [] + self._responded: bool = False + + if self.adapter is None: + raise TypeError("TurnContext must be instantiated with an adapter.") + if self.activity is None: + raise TypeError( + "TurnContext must be instantiated with a request parameter of type Activity." + ) + + self._turn_state = {} + + @property + def turn_state(self) -> Dict[str, object]: + return self._turn_state + + def copy_to(self, context: "TurnContext") -> None: + """ + Called when this TurnContext instance is passed into the constructor of a new TurnContext + instance. Can be overridden in derived classes. + :param context: + :return: + """ + for attribute in [ + "adapter", + "activity", + "_responded", + "_services", + "_on_send_activities", + "_on_update_activity", + "_on_delete_activity", + ]: + setattr(context, attribute, getattr(self, attribute)) + + @property + def activity(self): + """ + The received activity. + :return: + """ + return self._activity + + @activity.setter + def activity(self, value): + """ + Used to set TurnContext._activity when a context object is created. Only takes instances of Activities. + :param value: + :return: + """ + if not isinstance(value, Activity): + raise TypeError( + "TurnContext: cannot set `activity` to a type other than Activity." + ) + self._activity = value + + @property + def responded(self) -> bool: + """ + If `true` at least one response has been sent for the current turn of conversation. + :return: + """ + return self._responded + + @responded.setter + def responded(self, value: bool): + if not value: + raise ValueError("TurnContext: cannot set TurnContext.responded to False.") + self._responded = True + + @property + def services(self): + """ + Map of services and other values cached for the lifetime of the turn. + :return: + """ + return self._services + + def get(self, key: str) -> object: + if not key or not isinstance(key, str): + raise TypeError('"key" must be a valid string.') + try: + return self._services[key] + except KeyError: + raise KeyError("%s not found in TurnContext._services." % key) + + def has(self, key: str) -> bool: + """ + Returns True is set() has been called for a key. The cached value may be of type 'None'. + :param key: + :return: + """ + if key in self._services: + return True + return False + + def set(self, key: str, value: object) -> None: + """ + Caches a value for the lifetime of the current turn. + :param key: + :param value: + :return: + """ + if not key or not isinstance(key, str): + raise KeyError('"key" must be a valid string.') + + self._services[key] = value + + async def send_activity( + self, + activity_or_text: Union[Activity, str], + speak: str = None, + input_hint: str = None, + ) -> ResourceResponse: + """ + Sends a single activity or message to the user. + :param activity_or_text: + :return: + """ + if isinstance(activity_or_text, str): + activity_or_text = Activity( + text=activity_or_text, + input_hint=input_hint or InputHints.accepting_input, + speak=speak, + ) + + result = await self.send_activities([activity_or_text]) + return result[0] if result else None + + async def send_activities( + self, activities: List[Activity] + ) -> List[ResourceResponse]: + sent_non_trace_activity = False + ref = TurnContext.get_conversation_reference(self.activity) + + def activity_validator(activity: Activity) -> Activity: + if not getattr(activity, "type", None): + activity.type = ActivityTypes.message + if activity.type != ActivityTypes.trace: + nonlocal sent_non_trace_activity + sent_non_trace_activity = True + if not activity.input_hint: + activity.input_hint = "acceptingInput" + activity.id = None + return activity + + output = [ + activity_validator( + TurnContext.apply_conversation_reference(deepcopy(act), ref) + ) + for act in activities + ] + + async def logic(): + responses = await self.adapter.send_activities(self, output) + if sent_non_trace_activity: + self.responded = True + return responses + + return await self._emit(self._on_send_activities, output, logic()) + + async def update_activity(self, activity: Activity): + """ + Replaces an existing activity. + :param activity: + :return: + """ + reference = TurnContext.get_conversation_reference(self.activity) + + return await self._emit( + self._on_update_activity, + TurnContext.apply_conversation_reference(activity, reference), + self.adapter.update_activity(self, activity), + ) + + async def delete_activity(self, id_or_reference: Union[str, ConversationReference]): + """ + Deletes an existing activity. + :param id_or_reference: + :return: + """ + if isinstance(id_or_reference, str): + reference = TurnContext.get_conversation_reference(self.activity) + reference.activity_id = id_or_reference + else: + reference = id_or_reference + return await self._emit( + self._on_delete_activity, + reference, + self.adapter.delete_activity(self, reference), + ) + + def on_send_activities(self, handler) -> "TurnContext": + """ + Registers a handler to be notified of and potentially intercept the sending of activities. + :param handler: + :return: + """ + self._on_send_activities.append(handler) + return self + + def on_update_activity(self, handler) -> "TurnContext": + """ + Registers a handler to be notified of and potentially intercept an activity being updated. + :param handler: + :return: + """ + self._on_update_activity.append(handler) + return self + + def on_delete_activity(self, handler) -> "TurnContext": + """ + Registers a handler to be notified of and potentially intercept an activity being deleted. + :param handler: + :return: + """ + self._on_delete_activity.append(handler) + return self + + async def _emit(self, plugins, arg, logic): + handlers = copy(plugins) + + async def emit_next(i: int): + context = self + try: + if i < len(handlers): + + async def next_handler(): + await emit_next(i + 1) + + await handlers[i](context, arg, next_handler) + + except Exception as error: + raise error + + await emit_next(0) + # logic does not use parentheses because it's a coroutine + return await logic + + async def send_trace_activity( + self, name: str, value: object, value_type: str, label: str + ) -> ResourceResponse: + trace_activity = Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name=name, + value=value, + value_type=value_type, + label=label, + ) + + return await self.send_activity(trace_activity) + + @staticmethod + def get_conversation_reference(activity: Activity) -> ConversationReference: + """ + Returns the conversation reference for an activity. This can be saved as a plain old JSON + object and then later used to message the user proactively. + + Usage Example: + reference = TurnContext.get_conversation_reference(context.request) + :param activity: + :return: + """ + return ConversationReference( + activity_id=activity.id, + user=copy(activity.from_property), + bot=copy(activity.recipient), + conversation=copy(activity.conversation), + channel_id=activity.channel_id, + service_url=activity.service_url, + ) + + @staticmethod + def apply_conversation_reference( + activity: Activity, reference: ConversationReference, is_incoming: bool = False + ) -> Activity: + """ + Updates an activity with the delivery information from a conversation reference. Calling + this after get_conversation_reference on an incoming activity + will properly address the reply to a received activity. + :param activity: + :param reference: + :param is_incoming: + :return: + """ + activity.channel_id = reference.channel_id + activity.service_url = reference.service_url + activity.conversation = reference.conversation + if is_incoming: + activity.from_property = reference.user + activity.recipient = reference.bot + if reference.activity_id: + activity.id = reference.activity_id + else: + activity.from_property = reference.bot + activity.recipient = reference.user + if reference.activity_id: + activity.reply_to_id = reference.activity_id + + return activity + + @staticmethod + def get_reply_conversation_reference( + activity: Activity, reply: ResourceResponse + ) -> ConversationReference: + reference: ConversationReference = TurnContext.get_conversation_reference( + activity + ) + + # Update the reference with the new outgoing Activity's id. + reference.activity_id = reply.id + + return reference + + @staticmethod + def remove_recipient_mention(activity: Activity) -> str: + return TurnContext.remove_mention_text(activity, activity.recipient.id) + + @staticmethod + def remove_mention_text(activity: Activity, identifier: str) -> str: + mentions = TurnContext.get_mentions(activity) + for mention in mentions: + if mention.additional_properties["mentioned"]["id"] == identifier: + mention_name_match = re.match( + r"(.*?)<\/at>", + re.escape(mention.additional_properties["text"]), + re.IGNORECASE, + ) + if mention_name_match: + activity.text = re.sub( + mention_name_match.groups()[1], "", activity.text + ) + activity.text = re.sub(r"<\/at>", "", activity.text) + return activity.text + + @staticmethod + def get_mentions(activity: Activity) -> List[Mention]: + result: List[Mention] = [] + if activity.entities is not None: + for entity in activity.entities: + if entity.type.lower() == "mention": + result.append(entity) + + return result diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 381287d3d..be48fdc04 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -1,351 +1,372 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Callable, List -import aiounittest - -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - Entity, - Mention, - ResourceResponse, -) -from botbuilder.core import BotAdapter, MessageFactory, TurnContext - -ACTIVITY = Activity( - id="1234", - type="message", - text="test", - from_property=ChannelAccount(id="user", name="User Name"), - recipient=ChannelAccount(id="bot", name="Bot Name"), - conversation=ConversationAccount(id="convo", name="Convo Name"), - channel_id="UnitTest", - service_url="https://example.org", -) - - -class SimpleAdapter(BotAdapter): - async def send_activities(self, context, activities) -> List[ResourceResponse]: - responses = [] - assert context is not None - assert activities is not None - assert isinstance(activities, list) - assert activities - for (idx, activity) in enumerate(activities): # pylint: disable=unused-variable - assert isinstance(activity, Activity) - assert activity.type == "message" or activity.type == ActivityTypes.trace - responses.append(ResourceResponse(id="5678")) - return responses - - async def update_activity(self, context, activity): - assert context is not None - assert activity is not None - return ResourceResponse(id=activity.id) - - async def delete_activity(self, context, reference): - assert context is not None - assert reference is not None - assert reference.activity_id == ACTIVITY.id - - -class TestBotContext(aiounittest.AsyncTestCase): - def test_should_create_context_with_request_and_adapter(self): - TurnContext(SimpleAdapter(), ACTIVITY) - - def test_should_not_create_context_without_request(self): - try: - TurnContext(SimpleAdapter(), None) - except TypeError: - pass - except Exception as error: - raise error - - def test_should_not_create_context_without_adapter(self): - try: - TurnContext(None, ACTIVITY) - except TypeError: - pass - except Exception as error: - raise error - - def test_should_create_context_with_older_context(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - TurnContext(context) - - def test_copy_to_should_copy_all_references(self): - # pylint: disable=protected-access - old_adapter = SimpleAdapter() - old_activity = Activity(id="2", type="message", text="test copy") - old_context = TurnContext(old_adapter, old_activity) - old_context.responded = True - - async def send_activities_handler(context, activities, next_handler): - assert context is not None - assert activities is not None - assert next_handler is not None - await next_handler - - async def delete_activity_handler(context, reference, next_handler): - assert context is not None - assert reference is not None - assert next_handler is not None - await next_handler - - async def update_activity_handler(context, activity, next_handler): - assert context is not None - assert activity is not None - assert next_handler is not None - await next_handler - - old_context.on_send_activities(send_activities_handler) - old_context.on_delete_activity(delete_activity_handler) - old_context.on_update_activity(update_activity_handler) - - adapter = SimpleAdapter() - new_context = TurnContext(adapter, ACTIVITY) - assert not new_context._on_send_activities # pylint: disable=protected-access - assert not new_context._on_update_activity # pylint: disable=protected-access - assert not new_context._on_delete_activity # pylint: disable=protected-access - - old_context.copy_to(new_context) - - assert new_context.adapter == old_adapter - assert new_context.activity == old_activity - assert new_context.responded is True - assert ( - len(new_context._on_send_activities) == 1 - ) # pylint: disable=protected-access - assert ( - len(new_context._on_update_activity) == 1 - ) # pylint: disable=protected-access - assert ( - len(new_context._on_delete_activity) == 1 - ) # pylint: disable=protected-access - - def test_responded_should_be_automatically_set_to_false(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - assert context.responded is False - - def test_should_be_able_to_set_responded_to_true(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - assert context.responded is False - context.responded = True - assert context.responded - - def test_should_not_be_able_to_set_responded_to_false(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - try: - context.responded = False - except ValueError: - pass - except Exception as error: - raise error - - async def test_should_call_on_delete_activity_handlers_before_deletion(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def delete_handler(context, reference, next_handler_coroutine): - nonlocal called - called = True - assert reference is not None - assert context is not None - assert reference.activity_id == "1234" - await next_handler_coroutine() - - context.on_delete_activity(delete_handler) - await context.delete_activity(ACTIVITY.id) - assert called is True - - async def test_should_call_multiple_on_delete_activity_handlers_in_order(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called_first = False - called_second = False - - async def first_delete_handler(context, reference, next_handler_coroutine): - nonlocal called_first, called_second - assert ( - called_first is False - ), "called_first should not be True before first_delete_handler is called." - called_first = True - assert ( - called_second is False - ), "Second on_delete_activity handler was called before first." - assert reference is not None - assert context is not None - assert reference.activity_id == "1234" - await next_handler_coroutine() - - async def second_delete_handler(context, reference, next_handler_coroutine): - nonlocal called_first, called_second - assert called_first - assert ( - called_second is False - ), "called_second was set to True before second handler was called." - called_second = True - assert reference is not None - assert context is not None - assert reference.activity_id == "1234" - await next_handler_coroutine() - - context.on_delete_activity(first_delete_handler) - context.on_delete_activity(second_delete_handler) - await context.delete_activity(ACTIVITY.id) - assert called_first is True - assert called_second is True - - async def test_should_call_send_on_activities_handler_before_send(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def send_handler(context, activities, next_handler_coroutine): - nonlocal called - called = True - assert activities is not None - assert context is not None - assert not activities[0].id - await next_handler_coroutine() - - context.on_send_activities(send_handler) - await context.send_activity(ACTIVITY) - assert called is True - - async def test_should_call_on_update_activity_handler_before_update(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def update_handler(context, activity, next_handler_coroutine): - nonlocal called - called = True - assert activity is not None - assert context is not None - assert activity.id == "1234" - await next_handler_coroutine() - - context.on_update_activity(update_handler) - await context.update_activity(ACTIVITY) - assert called is True - - async def test_update_activity_should_apply_conversation_reference(self): - activity_id = "activity ID" - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def update_handler(context, activity, next_handler_coroutine): - nonlocal called - called = True - assert context is not None - assert activity.id == activity_id - assert activity.conversation.id == ACTIVITY.conversation.id - await next_handler_coroutine() - - context.on_update_activity(update_handler) - new_activity = MessageFactory.text("test text") - new_activity.id = activity_id - update_result = await context.update_activity(new_activity) - assert called is True - assert update_result.id == activity_id - - def test_get_conversation_reference_should_return_valid_reference(self): - reference = TurnContext.get_conversation_reference(ACTIVITY) - - assert reference.activity_id == ACTIVITY.id - assert reference.user == ACTIVITY.from_property - assert reference.bot == ACTIVITY.recipient - assert reference.conversation == ACTIVITY.conversation - assert reference.channel_id == ACTIVITY.channel_id - assert reference.service_url == ACTIVITY.service_url - - def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( - self, - ): - reference = TurnContext.get_conversation_reference(ACTIVITY) - reply = TurnContext.apply_conversation_reference( - Activity(type="message", text="reply"), reference - ) - - assert reply.recipient == ACTIVITY.from_property - assert reply.from_property == ACTIVITY.recipient - assert reply.conversation == ACTIVITY.conversation - assert reply.service_url == ACTIVITY.service_url - assert reply.channel_id == ACTIVITY.channel_id - - def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepare_a_reply( - self, - ): - reference = TurnContext.get_conversation_reference(ACTIVITY) - reply = TurnContext.apply_conversation_reference( - Activity(type="message", text="reply"), reference, True - ) - - assert reply.recipient == ACTIVITY.recipient - assert reply.from_property == ACTIVITY.from_property - assert reply.conversation == ACTIVITY.conversation - assert reply.service_url == ACTIVITY.service_url - assert reply.channel_id == ACTIVITY.channel_id - - async def test_should_get_conversation_reference_using_get_reply_conversation_reference( - self, - ): - context = TurnContext(SimpleAdapter(), ACTIVITY) - reply = await context.send_activity("test") - - assert reply.id, "reply has an id" - - reference = TurnContext.get_reply_conversation_reference( - context.activity, reply - ) - - assert reference.activity_id, "reference has an activity id" - assert ( - reference.activity_id == reply.id - ), "reference id matches outgoing reply id" - - def test_should_remove_at_mention_from_activity(self): - activity = Activity( - type="message", - text="TestOAuth619 test activity", - recipient=ChannelAccount(id="TestOAuth619"), - entities=[ - Entity().deserialize( - Mention( - type="mention", - text="TestOAuth619", - mentioned=ChannelAccount(name="Bot", id="TestOAuth619"), - ).serialize() - ) - ], - ) - - text = TurnContext.remove_recipient_mention(activity) - - assert text, " test activity" - assert activity.text, " test activity" - - async def test_should_send_a_trace_activity(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - # pylint: disable=unused-argument - async def aux_func( - ctx: TurnContext, activities: List[Activity], next: Callable - ): - nonlocal called - called = True - assert isinstance(activities, list), "activities not array." - assert len(activities) == 1, "invalid count of activities." - assert activities[0].type == ActivityTypes.trace, "type wrong." - assert activities[0].name == "name-text", "name wrong." - assert activities[0].value == "value-text", "value worng." - assert activities[0].value_type == "valueType-text", "valeuType wrong." - assert activities[0].label == "label-text", "label wrong." - return [] - - context.on_send_activities(aux_func) - await context.send_trace_activity( - "name-text", "value-text", "valueType-text", "label-text" - ) - assert called +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Callable, List +import aiounittest + +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + Entity, + Mention, + ResourceResponse, +) +from botbuilder.core import BotAdapter, MessageFactory, TurnContext + +ACTIVITY = Activity( + id="1234", + type="message", + text="test", + from_property=ChannelAccount(id="user", name="User Name"), + recipient=ChannelAccount(id="bot", name="Bot Name"), + conversation=ConversationAccount(id="convo", name="Convo Name"), + channel_id="UnitTest", + service_url="https://example.org", +) + + +class SimpleAdapter(BotAdapter): + async def send_activities(self, context, activities) -> List[ResourceResponse]: + responses = [] + assert context is not None + assert activities is not None + assert isinstance(activities, list) + assert activities + for (idx, activity) in enumerate(activities): # pylint: disable=unused-variable + assert isinstance(activity, Activity) + assert activity.type == "message" or activity.type == ActivityTypes.trace + responses.append(ResourceResponse(id="5678")) + return responses + + async def update_activity(self, context, activity): + assert context is not None + assert activity is not None + return ResourceResponse(id=activity.id) + + async def delete_activity(self, context, reference): + assert context is not None + assert reference is not None + assert reference.activity_id == ACTIVITY.id + + +class TestBotContext(aiounittest.AsyncTestCase): + def test_should_create_context_with_request_and_adapter(self): + TurnContext(SimpleAdapter(), ACTIVITY) + + def test_should_not_create_context_without_request(self): + try: + TurnContext(SimpleAdapter(), None) + except TypeError: + pass + except Exception as error: + raise error + + def test_should_not_create_context_without_adapter(self): + try: + TurnContext(None, ACTIVITY) + except TypeError: + pass + except Exception as error: + raise error + + def test_should_create_context_with_older_context(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + TurnContext(context) + + def test_copy_to_should_copy_all_references(self): + # pylint: disable=protected-access + old_adapter = SimpleAdapter() + old_activity = Activity(id="2", type="message", text="test copy") + old_context = TurnContext(old_adapter, old_activity) + old_context.responded = True + + async def send_activities_handler(context, activities, next_handler): + assert context is not None + assert activities is not None + assert next_handler is not None + await next_handler + + async def delete_activity_handler(context, reference, next_handler): + assert context is not None + assert reference is not None + assert next_handler is not None + await next_handler + + async def update_activity_handler(context, activity, next_handler): + assert context is not None + assert activity is not None + assert next_handler is not None + await next_handler + + old_context.on_send_activities(send_activities_handler) + old_context.on_delete_activity(delete_activity_handler) + old_context.on_update_activity(update_activity_handler) + + adapter = SimpleAdapter() + new_context = TurnContext(adapter, ACTIVITY) + assert not new_context._on_send_activities # pylint: disable=protected-access + assert not new_context._on_update_activity # pylint: disable=protected-access + assert not new_context._on_delete_activity # pylint: disable=protected-access + + old_context.copy_to(new_context) + + assert new_context.adapter == old_adapter + assert new_context.activity == old_activity + assert new_context.responded is True + assert ( + len(new_context._on_send_activities) == 1 + ) # pylint: disable=protected-access + assert ( + len(new_context._on_update_activity) == 1 + ) # pylint: disable=protected-access + assert ( + len(new_context._on_delete_activity) == 1 + ) # pylint: disable=protected-access + + def test_responded_should_be_automatically_set_to_false(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + assert context.responded is False + + def test_should_be_able_to_set_responded_to_true(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + assert context.responded is False + context.responded = True + assert context.responded + + def test_should_not_be_able_to_set_responded_to_false(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + try: + context.responded = False + except ValueError: + pass + except Exception as error: + raise error + + async def test_should_call_on_delete_activity_handlers_before_deletion(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def delete_handler(context, reference, next_handler_coroutine): + nonlocal called + called = True + assert reference is not None + assert context is not None + assert reference.activity_id == "1234" + await next_handler_coroutine() + + context.on_delete_activity(delete_handler) + await context.delete_activity(ACTIVITY.id) + assert called is True + + async def test_should_call_multiple_on_delete_activity_handlers_in_order(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called_first = False + called_second = False + + async def first_delete_handler(context, reference, next_handler_coroutine): + nonlocal called_first, called_second + assert ( + called_first is False + ), "called_first should not be True before first_delete_handler is called." + called_first = True + assert ( + called_second is False + ), "Second on_delete_activity handler was called before first." + assert reference is not None + assert context is not None + assert reference.activity_id == "1234" + await next_handler_coroutine() + + async def second_delete_handler(context, reference, next_handler_coroutine): + nonlocal called_first, called_second + assert called_first + assert ( + called_second is False + ), "called_second was set to True before second handler was called." + called_second = True + assert reference is not None + assert context is not None + assert reference.activity_id == "1234" + await next_handler_coroutine() + + context.on_delete_activity(first_delete_handler) + context.on_delete_activity(second_delete_handler) + await context.delete_activity(ACTIVITY.id) + assert called_first is True + assert called_second is True + + async def test_should_call_send_on_activities_handler_before_send(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def send_handler(context, activities, next_handler_coroutine): + nonlocal called + called = True + assert activities is not None + assert context is not None + assert not activities[0].id + await next_handler_coroutine() + + context.on_send_activities(send_handler) + await context.send_activity(ACTIVITY) + assert called is True + + async def test_should_call_on_update_activity_handler_before_update(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def update_handler(context, activity, next_handler_coroutine): + nonlocal called + called = True + assert activity is not None + assert context is not None + assert activity.id == "1234" + await next_handler_coroutine() + + context.on_update_activity(update_handler) + await context.update_activity(ACTIVITY) + assert called is True + + async def test_update_activity_should_apply_conversation_reference(self): + activity_id = "activity ID" + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def update_handler(context, activity, next_handler_coroutine): + nonlocal called + called = True + assert context is not None + assert activity.id == activity_id + assert activity.conversation.id == ACTIVITY.conversation.id + await next_handler_coroutine() + + context.on_update_activity(update_handler) + new_activity = MessageFactory.text("test text") + new_activity.id = activity_id + update_result = await context.update_activity(new_activity) + assert called is True + assert update_result.id == activity_id + + def test_get_conversation_reference_should_return_valid_reference(self): + reference = TurnContext.get_conversation_reference(ACTIVITY) + + assert reference.activity_id == ACTIVITY.id + assert reference.user == ACTIVITY.from_property + assert reference.bot == ACTIVITY.recipient + assert reference.conversation == ACTIVITY.conversation + assert reference.channel_id == ACTIVITY.channel_id + assert reference.service_url == ACTIVITY.service_url + + def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( + self, + ): + reference = TurnContext.get_conversation_reference(ACTIVITY) + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply"), reference + ) + + assert reply.recipient == ACTIVITY.from_property + assert reply.from_property == ACTIVITY.recipient + assert reply.conversation == ACTIVITY.conversation + assert reply.service_url == ACTIVITY.service_url + assert reply.channel_id == ACTIVITY.channel_id + + def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepare_a_reply( + self, + ): + reference = TurnContext.get_conversation_reference(ACTIVITY) + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply"), reference, True + ) + + assert reply.recipient == ACTIVITY.recipient + assert reply.from_property == ACTIVITY.from_property + assert reply.conversation == ACTIVITY.conversation + assert reply.service_url == ACTIVITY.service_url + assert reply.channel_id == ACTIVITY.channel_id + + async def test_should_get_conversation_reference_using_get_reply_conversation_reference( + self, + ): + context = TurnContext(SimpleAdapter(), ACTIVITY) + reply = await context.send_activity("test") + + assert reply.id, "reply has an id" + + reference = TurnContext.get_reply_conversation_reference( + context.activity, reply + ) + + assert reference.activity_id, "reference has an activity id" + assert ( + reference.activity_id == reply.id + ), "reference id matches outgoing reply id" + + def test_should_remove_at_mention_from_activity(self): + activity = Activity( + type="message", + text="TestOAuth619 test activity", + recipient=ChannelAccount(id="TestOAuth619"), + entities=[ + Entity().deserialize( + Mention( + type="mention", + text="TestOAuth619", + mentioned=ChannelAccount(name="Bot", id="TestOAuth619"), + ).serialize() + ) + ], + ) + + text = TurnContext.remove_recipient_mention(activity) + + assert text, " test activity" + assert activity.text, " test activity" + + def test_should_remove_at_mention_with_regex_characters(self): + activity = Activity( + type="message", + text="Test (*.[]$%#^&?) test activity", + recipient=ChannelAccount(id="Test (*.[]$%#^&?)"), + entities=[ + Entity().deserialize( + Mention( + type="mention", + text="Test (*.[]$%#^&?)", + mentioned=ChannelAccount(name="Bot", id="Test (*.[]$%#^&?)"), + ).serialize() + ) + ], + ) + + text = TurnContext.remove_recipient_mention(activity) + + assert text, " test activity" + assert activity.text, " test activity" + + async def test_should_send_a_trace_activity(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + # pylint: disable=unused-argument + async def aux_func( + ctx: TurnContext, activities: List[Activity], next: Callable + ): + nonlocal called + called = True + assert isinstance(activities, list), "activities not array." + assert len(activities) == 1, "invalid count of activities." + assert activities[0].type == ActivityTypes.trace, "type wrong." + assert activities[0].name == "name-text", "name wrong." + assert activities[0].value == "value-text", "value worng." + assert activities[0].value_type == "valueType-text", "valeuType wrong." + assert activities[0].label == "label-text", "label wrong." + return [] + + context.on_send_activities(aux_func) + await context.send_trace_activity( + "name-text", "value-text", "valueType-text", "label-text" + ) + assert called From f3033f39245981dcbab922ab829a9ddd6fc0fd4c Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 3 Feb 2020 10:27:04 -0800 Subject: [PATCH 203/616] mm-api-ref-docs-fixes-wip Changed .. note:: to .. remarks:: added missing `BotStatePropertyAcccessor` documentation --- .../botbuilder/core/bot_state.py | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 0b7aaa53d..9056a15ee 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -231,17 +231,41 @@ async def set_property_value( cached_state.state[property_name] = value -## class BotStatePropertyAccessor(StatePropertyAccessor): + """ + Defines methods for accessing a state property created in a :class:`BotState` object. + """ + def __init__(self, bot_state: BotState, name: str): + """ + Initializes a new instance of the :class:`BotStatePropertyAccessor` class. + + :param bot_state: The state object to access + :type bot_state: :class:`BotState` + :param name: The name of the state property to access + :type name: str + + """ self._bot_state = bot_state self._name = name @property def name(self) -> str: + """ + Gets the name of the property. + + :return: The name of the property + :rtype: str + """ return self._name async def delete(self, turn_context: TurnContext) -> None: + """ + Deletes the property. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + """ await self._bot_state.load(turn_context, False) await self._bot_state.delete_property_value(turn_context, self._name) @@ -250,6 +274,14 @@ async def get( turn_context: TurnContext, default_value_or_factory: Union[Callable, object] = None, ) -> object: + """ + Gets the property value. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + :param default_value_or_factory: Defines the default value when no value is set for the property + :type default_value_or_factory: :typing:Union + """ await self._bot_state.load(turn_context, False) try: result = await self._bot_state.get_property_value(turn_context, self._name) @@ -268,5 +300,14 @@ async def get( return result async def set(self, turn_context: TurnContext, value: object) -> None: + """ + Sets the property value. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + + :param value: The value to assign to the property + :type value: :typing:`Object` + """ await self._bot_state.load(turn_context, False) await self._bot_state.set_property_value(turn_context, self._name, value) From e7721f2810ffe9f520b401d819ec4c636e912775 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 3 Feb 2020 10:28:11 -0800 Subject: [PATCH 204/616] Update bot_state.py Fixed ref link. --- .../botbuilder/core/bot_state.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 9056a15ee..bf75ef2e8 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -50,7 +50,7 @@ def __init__(self, storage: Storage, context_service_key: str): :param context_service_key: The key for the state cache for this :class:`BotState` :type context_service_key: str - .. note:: + .. remarks:: This constructor creates a state management object and associated scope. The object uses the :param storage: to persist state property values and the :param context_service_key: to cache state within the context for each turn. @@ -84,7 +84,7 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: Reads the current state object and caches it in the context object for this turn. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param force: Optional, true to bypass the cache :type force: bool """ @@ -107,7 +107,7 @@ async def save_changes( If the state has changed, it saves the state cached in the current context for this turn. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param force: Optional, true to save state to storage whether or not there are changes :type force: bool """ @@ -127,11 +127,11 @@ async def clear_state(self, turn_context: TurnContext): Clears any state currently stored in this state scope. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :return: None - .. note:: + .. remarks:: This function must be called in order for the cleared state to be persisted to the underlying store. """ if turn_context is None: @@ -147,7 +147,7 @@ async def delete(self, turn_context: TurnContext) -> None: Deletes any state currently stored in this state scope. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :return: None """ @@ -168,7 +168,7 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str Gets the value of the specified property in the turn context. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param property_name: The property name :type property_name: str @@ -195,7 +195,7 @@ async def delete_property_value( Deletes a property from the state cache in the turn context. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param property_name: The name of the property to delete :type property_name: str @@ -215,7 +215,7 @@ async def set_property_value( Sets a property to the specified value in the turn context. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param property_name: The property name :type property_name: str :param value: The value to assign to the property From 8e1ef4ffb9a261f2465a0bfb011c65b83c8f87c8 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 3 Feb 2020 11:06:21 -0800 Subject: [PATCH 205/616] Update conversation_state.py Fixed comments and ref link. --- .../botbuilder-core/botbuilder/core/conversation_state.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 445b8949d..ffbec86b2 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -9,7 +9,6 @@ class ConversationState(BotState): """ Defines a state management object for conversation state. - Extends :class:`BootState` base class. .. remarks:: Conversation state is available in any turn in a specific conversation, regardless of the user, such as @@ -33,7 +32,7 @@ def get_storage_key(self, turn_context: TurnContext) -> object: Gets the key to use when reading and writing state to and from storage. :param turn_context: The context object for this turn. - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :raise: :class:`TypeError` if the :meth:`TurnContext.activity` for the current turn is missing :class:`botbuilder.schema.Activity` channelId or conversation information or the conversation's From 1702348949dba6561c531050490fb8feacdef7c1 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 3 Feb 2020 11:29:06 -0800 Subject: [PATCH 206/616] Update activity_handler.py activity_handler.py - Fixed ref links; changed ..notes:: to .. remarks:: --- .../botbuilder/core/activity_handler.py | 56 +++++++++---------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 8d7b7cccc..cbe65ed73 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -13,7 +13,7 @@ async def on_turn(self, turn_context: TurnContext): in order to process an inbound :class:`botbuilder.schema.Activity`. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute @@ -21,12 +21,9 @@ async def on_turn(self, turn_context: TurnContext): It calls other methods in this class based on the type of the activity to process, which allows a derived class to provide type-specific logic in a controlled way. In a derived class, override this method to add logic that applies to all activity types. - - .. note:: - - Add logic to apply before the type-specific logic and before the call to the - :meth:`ActivityHandler.on_turn()` method. - - Add logic to apply after the type-specific logic after the call to the - :meth:`ActivityHandler.on_turn()` method. + Also + - Add logic to apply before the type-specific logic and before calling :meth:`ActivityHandler.on_turn()`. + - Add logic to apply after the type-specific logic after calling :meth:`ActivityHandler.on_turn()`. """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -65,10 +62,9 @@ async def on_message_activity( # pylint: disable=unused-argument such as the conversational logic. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - """ return @@ -78,11 +74,11 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this method. If the conversation update activity indicates that members other than the bot joined the conversation, @@ -119,11 +115,11 @@ async def on_members_added_activity( conversation update activity :type members_added: :class:`typing.List` :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. @@ -141,11 +137,11 @@ async def on_members_removed_activity( conversation update activity :type members_added: :class:`typing.List` :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are leaving the conversation, it calls this method. @@ -159,11 +155,11 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. Message reactions are only supported by a few channels. The activity that the message reaction corresponds @@ -200,11 +196,11 @@ async def on_reactions_added( # pylint: disable=unused-argument :param message_reactions: The list of reactions added :type message_reactions: :class:`typing.List` :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent message on the conversation. Message reactions are supported by only a few channels. The activity that the message is in reaction to is identified by the activity's reply to Id property. @@ -223,11 +219,11 @@ async def on_reactions_removed( # pylint: disable=unused-argument :param message_reactions: The list of reactions removed :type message_reactions: :class:`typing.List` :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent message on the conversation. Message reactions are supported by only a few channels. The activity that the message is in reaction to is identified by the activity's reply to Id property. @@ -242,11 +238,11 @@ async def on_event_activity(self, turn_context: TurnContext): :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; otherwise, it calls :meth:'ActivityHandler.on_event()`. @@ -273,11 +269,11 @@ async def on_token_response_event( # pylint: disable=unused-argument If using an `oauth_prompt`, override this method to forward this activity to the current dialog. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of `tokens/response`, it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. @@ -293,11 +289,11 @@ async def on_event( # pylint: disable=unused-argument :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an activity name other than `tokens/response`, it calls this method. This method could optionally be overridden if the bot is meant to handle miscellaneous events. @@ -311,7 +307,7 @@ async def on_end_of_conversation_activity( # pylint: disable=unused-argument Invoked when a conversation end activity is received from the channel. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute """ return @@ -325,11 +321,11 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument If overridden, this method could potentially respond to any of the other activity types. :param turn_context: The context object for this turn - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute - .. note:: + .. remarks:: When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, conversation update, message reaction, or event activity, it calls this method. """ From 030a3468beda139dfcc4578d0dade4699f0c7f4b Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 3 Feb 2020 11:53:41 -0800 Subject: [PATCH 207/616] Update bot_framework_adapter.py Fixed ref links; replaced note with remarks. --- .../botbuilder/core/bot_framework_adapter.py | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index cedc42ace..b9a6574d8 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -87,11 +87,9 @@ def __init__( """ Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. - :param app_id: The bot application ID. This is the appId returned by the Azure portal registration, and is - the value of the `MicrosoftAppId` parameter in the `config.py` file. + :param app_id: The bot application ID. :type app_id: str - :param app_password: The bot application password. This is the password returned by the Azure portal - registration, and is + :param app_password: The bot application password. the value os the `MicrosoftAppPassword` parameter in the `config.py` file. :type app_password: str :param channel_auth_tenant: The channel tenant to use in conversation @@ -201,7 +199,7 @@ async def continue_conversation( :return: A task that represents the work queued to execute. - .. note:: + .. remarks:: This is often referred to as the bots *proactive messaging* flow as it lets the bot proactively send messages to a conversation or user that are already in a communication. Scenarios such as sending notifications or coupons to a user are enabled by this function. @@ -247,7 +245,7 @@ async def create_conversation( :return: A task representing the work queued to execute. - .. note:: + .. remarks:: To start a conversation, your bot must know its account information and the user's account information on that channel. Most channels only support initiating a direct message (non-group) conversation. @@ -306,9 +304,7 @@ async def create_conversation( async def process_activity(self, req, auth_header: str, logic: Callable): """ - Creates a turn context and runs the middleware pipeline for an incoming activity, - Processes an activity received by the bots web server. This includes any messages sent from a - user and is the method that drives what's often referred to as the bots *reactive messaging* flow. + Creates a turn context and runs the middleware pipeline for an incoming activity. :param req: The incoming activity :type req: :class:`typing.str` @@ -317,15 +313,15 @@ async def process_activity(self, req, auth_header: str, logic: Callable): :param logic: The logic to execute at the end of the adapter's middleware pipeline. :type logic: :class:`typing.Callable` - :return: A task that represents the work queued to execute. If the activity type - was `Invoke` and the corresponding key (`channelId` + `activityId`) was found then - an :class:`InvokeResponse` is returned; otherwise, `null` is returned. + :return: A task that represents the work queued to execute. - .. note:: + .. remarks:: + This class processes an activity received by the bots web server. This includes any messages + sent from a user and is the method that drives what's often referred to as the + bots *reactive messaging* flow. Call this method to reactively send a message to a conversation. If the task completes successfully, then an :class:`InvokeResponse` is returned; otherwise. `null` is returned. - """ activity = await self.parse_request(req) auth_header = auth_header or "" @@ -457,7 +453,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): :return: A task that represents the work queued to execute - .. note:: + .. remarks:: If the activity is successfully sent, the task result contains a :class:`botbuilder.schema.ResourceResponse` object containing the ID that the receiving channel assigned to the activity. @@ -490,6 +486,7 @@ async def delete_activity( :return: A task that represents the work queued to execute .. note:: + The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: @@ -565,7 +562,7 @@ async def delete_conversation_member( Deletes a member from the current conversation. :param context: The context object for the turn - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param member_id: The ID of the member to remove from the conversation :type member_id: str @@ -603,7 +600,7 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): Lists the members of a given activity. :param context: The context object for the turn - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param activity_id: (Optional) Activity ID to enumerate. If not specified the current activities ID will be used. @@ -645,7 +642,7 @@ async def get_conversation_members(self, context: TurnContext): Lists the members of a current conversation. :param context: The context object for the turn - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :raises: An exception error @@ -674,9 +671,7 @@ async def get_conversation_members(self, context: TurnContext): async def get_conversations(self, service_url: str, continuation_token: str = None): """ - Lists the Conversations in which this bot has participated for a given channel server. The channel server - returns results in pages and each page will include a `continuationToken` that can be used to fetch the next - page of results from the server. + Lists the Conversations in which this bot has participated for a given channel server. :param service_url: The URL of the channel server to query. This can be retrieved from `context.activity.serviceUrl` @@ -689,8 +684,10 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :return: A task that represents the work queued to execute - .. note:: If the task completes successfully, the result contains a page of the members of the current - conversation. + .. remarks:: + The channel server returns results in pages and each page will include a `continuationToken` that + can be used to fetch the next page of results from the server. + If the task completes successfully, the result contains a page of the members of the current conversation. This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. """ @@ -705,7 +702,7 @@ async def get_user_token( Attempts to retrieve the token for a user that's in a login flow. :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str :param magic_code" (Optional) user entered code to validate @@ -752,7 +749,7 @@ async def sign_out_user( Signs the user out with the token server. :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str :param user_id: User id of user to sign out @@ -781,13 +778,14 @@ async def get_oauth_sign_in_link( Gets the raw sign-in link to be sent to the user for sign-in for a connection name. :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str :returns: A task that represents the work queued to execute .. note:: + If the task completes successfully, the result contains the raw sign-in link """ self.check_emulating_oauth_cards(context) @@ -814,7 +812,7 @@ async def get_token_status( Retrieves the token status for each configured connection for the given user. :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param user_id: The user Id for which token status is retrieved :type user_id: str :param include_filter: (Optional) Comma separated list of connection's to include. @@ -848,14 +846,11 @@ async def get_aad_tokens( Retrieves Azure Active Directory tokens for particular resources on a configured connection. :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` - + :type context: :class:`botbuilder.core.TurnContext` :param connection_name: The name of the Azure Active Directory connection configured with this bot :type connection_name: str - :param resource_urls: The list of resource URLs to retrieve tokens for :type resource_urls: :class:`typing.List` - :returns: Dictionary of resource Urls to the corresponding :class:'botbuilder.schema.TokenResponse` :rtype: :class:`typing.Dict` """ From aa571edc3d48a7a26fd13ccd0cb263f71c91eeb7 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 3 Feb 2020 12:12:23 -0800 Subject: [PATCH 208/616] Fixed ref links and comments --- .../dialogs/prompts/oauth_prompt.py | 11 +++---- .../botbuilder/dialogs/prompts/prompt.py | 30 +++++++++---------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 736637d30..d593bfb2d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -47,17 +47,14 @@ class OAuthPrompt(Dialog): Both flows are automatically supported by the `OAuthPrompt` and they only thing you need to be careful of is that you don't block the `event` and `invoke` activities that the prompt might be waiting on. - .. note:: You should avoid persisting the access token with your bots other state. The Bot Frameworks SSO service will securely store the token on your behalf. If you store it in your bots state, it could expire or be revoked in between turns. When calling the prompt from within a waterfall step, you should use the token within the step following the prompt and then let the token go out of scope at the end of your function. - **Prompt Usage** When used with your bots :class:`DialogSet`, you can simply add a new instance of the prompt as a named - dialog using - :meth`DialogSet.add()`. + dialog using :meth`DialogSet.add()`. You can then start the prompt from a waterfall step using either :meth:`DialogContext.begin()` or :meth:`DialogContext.prompt()`. The user will be prompted to sign in as needed and their access token will be passed as an argument to @@ -112,7 +109,7 @@ async def begin_dialog( .. note:: If the task is successful, the result indicates whether the prompt is still active after the turn - has been processed by the prompt. + has been processed. """ if dialog_context is None: raise TypeError( @@ -163,7 +160,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :return: Dialog turn result :rtype: :class:DialogTurnResult - .. note:: + .. remarks:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The prompt generally continues to receive the user's replies until it accepts the @@ -245,7 +242,7 @@ async def sign_out_user(self, context: TurnContext): Signs out the user :param context: Context for the current turn of conversation with the user - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :return: A :class:`Task` representing the work queued to execute :rtype: :class:`Task` diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 3ed1a51f0..d0ab57084 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -31,9 +31,8 @@ class Prompt(Dialog): Use :meth:`DialogSet.add()` or :meth:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or component dialog, respectively. Use :meth:`DialogContext.prompt()` or :meth:`DialogContext.begin_dialog()` to start the prompt. - .. note:: - If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result - will be available in the next step of the waterfall. + If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result + will be available in the next step of the waterfall. """ ATTEMPT_COUNT_KEY = "AttemptCount" @@ -70,7 +69,7 @@ async def begin_dialog( .. note:: If the task is successful, the result indicates whether the prompt is still active - after the turn has been processed by the prompt. + after the turn has been processed. """ if not dialog_context: raise TypeError("Prompt(): dc cannot be None.") @@ -107,7 +106,7 @@ async def continue_dialog(self, dialog_context: DialogContext): :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. note:: + .. remarks:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The prompt generally continues to receive the user's replies until it accepts the @@ -151,9 +150,7 @@ async def resume_dialog( self, dialog_context: DialogContext, reason: DialogReason, result: object ) -> DialogTurnResult: """ - Resumes a dialog. Called when a prompt dialog resumes being the active dialog - on the dialog stack, such as when the previous active dialog on the stack completes. - + Resumes a dialog. C :param dialog_context: The dialog context for the current turn of the conversation. :type dialog_context: :class:DialogContext :param reason: An enum indicating why the dialog resumed. @@ -164,7 +161,9 @@ async def resume_dialog( :return: The dialog turn result :rtype: :class:`DialogTurnResult` - .. note:: + .. remarks:: + Called when a prompt dialog resumes being the active dialog on the dialog stack, + such as when the previous active dialog on the stack completes. If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs @@ -202,7 +201,7 @@ async def on_prompt( Prompts user for input. When overridden in a derived class, prompts the user for input. :param turn_context: Context for the current turn of conversation with the user - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided @@ -227,14 +226,14 @@ async def on_recognize( Recognizes the user's input. When overridden in a derived class, attempts to recognize the user's input. :param turn_context: Context for the current turn of conversation with the user - :type turn_context: :class:`TurnContext` + :type turn_context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:Dict :param options: A prompt options object constructed from the options initially provided in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` :type options: :class:PromptOptions - :return: A :class:Task representing the asynchronous operation. + :return: A task representing the asynchronous operation. :rtype: :class:Task """ @@ -248,8 +247,6 @@ def append_choices( ) -> Activity: """ Composes an output activity containing a set of choices. - When overridden in a derived class, appends choices to the activity when the user is prompted for input. - Helper function to compose an output activity containing a set of choices. :param prompt: The prompt to append the user's choice to :type prompt: @@ -264,8 +261,11 @@ def append_choices( :return: A :class:Task representing the asynchronous operation :rtype: :class:Task - .. note:: + .. remarks:: If the task is successful, the result contains the updated activity. + When overridden in a derived class, appends choices to the activity when the user + is prompted for input. This is an helper function to compose an output activity + containing a set of choices. """ # Get base prompt text (if any) text = prompt.text if prompt is not None and prompt.text else "" From 3805c0fda45f5dd285700bd2927f50945a742bd7 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 4 Feb 2020 10:43:34 -0800 Subject: [PATCH 209/616] emolsh-api-ref-docs-r2-dialogturnresult --- .../botbuilder/dialogs/dialog_turn_result.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 7fd1b5632..706dcc757 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -8,7 +8,7 @@ class DialogTurnResult: """ Result returned to the caller of one of the various stack manipulation methods. - Use :meth:`DialogContext.end_dialogAsync()` to end a :class:`Dialog` and + Use :meth:`DialogContext.end_dialog()` to end a :class:`Dialog` and return a result to the calling context. """ @@ -27,7 +27,7 @@ def status(self): """ Gets or sets the current status of the stack. - :return self._status: + :return self._status: The status of the stack. :rtype self._status: :class:`DialogTurnStatus` """ return self._status @@ -37,10 +37,12 @@ def result(self): """ Final result returned by a dialog that just completed. - .. note:: + .. remarks:: This will only be populated in certain cases: - * The bot calls :meth:`DialogContext.begin_dialog()` to start a new dialog and the dialog ends immediately. - * The bot calls :meth:`DialogContext.continue_dialog()` and a dialog that was active ends. + * The bot calls :meth:`DialogContext.begin_dialog()` to start a new + dialog and the dialog ends immediately. + * The bot calls :meth:`DialogContext.continue_dialog()` and a dialog + that was active ends. :return self._result: Final result returned by a dialog that just completed. :rtype self._result: object From 3d0e05c9f51a62c1e659329236052cc361f79bb5 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 4 Feb 2020 10:51:06 -0800 Subject: [PATCH 210/616] Fixed misspelled variable name --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index 7c8eb9bef..7c12e3032 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -9,7 +9,7 @@ class DialogReason(Enum): :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. :vartype BeginCalled: int - :var ContinuCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. + :var ContinueCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. :vartype ContinueCalled: int :var EndCalled: A dialog ended normally through a call to `DialogContext.end_dialog() :vartype EndCalled: int From 1f5894688b04ea3504a69de1b566e6cde1ef766c Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 4 Feb 2020 12:40:48 -0800 Subject: [PATCH 211/616] Update dialog_state.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 940ee73ff..5306540f8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -26,10 +26,10 @@ def __init__(self, stack: List[DialogInstance] = None): @property def dialog_stack(self): """ - Initializes a new instance of the :class:`DialogState` class. + Initializes a new instance of the :class:`DialogState` class. - :return: The state information to initialize the stack with. - :rtype: list + :return: The state information to initialize the stack with. + :rtype: list """ return self._dialog_stack From 6eacc2983b4e5b1844699be7a2f21d0dcad9aa78 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 4 Feb 2020 12:54:07 -0800 Subject: [PATCH 212/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 6857ad5b4..c98de80f9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -82,7 +82,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. - .. note:: + .. remarks:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. The result may also contain a return value. @@ -119,23 +119,13 @@ async def resume_dialog( Called when a child dialog on the parent's dialog stack completed this turn, returning control to this dialog component. - .. note:: + .. remarks:: Containers are typically leaf nodes on the stack but the dev is free to push other dialogs on top of the stack which will result in the container receiving an unexpected call to - :meth:resume_dialog() when the pushed on dialog ends. + :meth:`ComponentDialog.resume_dialog()` when the pushed on dialog ends. To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - If the task is successful, the result indicates whether this dialog is still - active after this dialog turn has been processed. - - Generally, the child dialog was started with a call to :meth:`def async begin_dialog()` - in the parent's context. However, if the :meth:`DialogContext.replace_dialog()` method is - is called, the logical child dialog may be different than the original. - - If this method is *not* overridden, the dialog automatically calls its - :meth:`asyn def reprompt_dialog()` when the user replies. - :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. @@ -157,7 +147,7 @@ async def reprompt_dialog( Called when the dialog should re-prompt the user for input. :param context: The context object for this turn. - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param instance: State information for this dialog. :type instance: :class:`DialogInstance` """ @@ -176,7 +166,7 @@ async def end_dialog( Called when the dialog is ending. :param context: The context object for this turn. - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. :type instance: :class:`DialogInstance` @@ -193,7 +183,6 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ Adds a :class:`Dialog` to the component dialog and returns the updated component. - Adding a new dialog will inherit the :class:`BotTelemetryClient` of the :class:`ComponentDialog`. :param dialog: The dialog to add. :return: The updated :class:`ComponentDialog` @@ -220,7 +209,7 @@ async def on_begin_dialog( """ Called when the dialog is started and pushed onto the parent's dialog stack. - .. note:: + .. remarks:: If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. @@ -237,6 +226,12 @@ async def on_begin_dialog( return await inner_dc.begin_dialog(self.initial_dialog_id, options) async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + """ + Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. + + :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. + :type inner_dc: :class:`DialogContext` + """ return await inner_dc.continue_dialog() async def on_end_dialog( # pylint: disable=unused-argument @@ -245,8 +240,8 @@ async def on_end_dialog( # pylint: disable=unused-argument """ Ends the component dialog in its parent's context. - :param turn_context: The :class:`TurnContext` for the current turn of the conversation. - :type turn_context: :class:`TurnContext` + :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. + :type turn_context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. :type instance: :class:`DialogInstance` @@ -259,7 +254,7 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument self, turn_context: TurnContext, instance: DialogInstance ) -> None: """ - :param turn_context: The :class:`TurnContext` for the current turn of the conversation. + :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`DialogInstance` :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. @@ -273,7 +268,7 @@ async def end_component( """ Ends the component dialog in its parent's context. - .. note:: + .. remarks:: If the task is successful, the result indicates that the dialog ended after the turn was processed by the dialog. From 5787084f21b44483368de8c74e6c053690d0787e Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 4 Feb 2020 13:15:10 -0800 Subject: [PATCH 213/616] Update activity_prompt.py --- .../botbuilder/dialogs/prompts/activity_prompt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index db0e87e01..de42e1f4d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -24,7 +24,7 @@ class ActivityPrompt(Dialog, ABC): """ Waits for an activity to be received. - .. remarks: + .. remarks:: This prompt requires a validator be passed in and is useful when waiting for non-message activities like an event to be received. The validator can ignore received events until the expected activity is received. @@ -156,10 +156,10 @@ async def resume_dialog( # pylint: disable=unused-argument Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. - .. note: + .. remarks:: Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs on top of the stack which will result in the prompt receiving an unexpected call to - :meth:resume_dialog() when the pushed on dialog ends. + :meth:`ActivityPrompt.resume_dialog()` when the pushed on dialog ends. To avoid the prompt prematurely ending, we need to implement this method and simply re-prompt the user. From 57022452a8b53a812c19620942c0c234ac4cb46c Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 4 Feb 2020 13:27:19 -0800 Subject: [PATCH 214/616] Update activity_prompt.py --- .../botbuilder/dialogs/prompts/activity_prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index de42e1f4d..70e02f457 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -212,7 +212,7 @@ async def on_recognize( # pylint: disable=unused-argument When overridden in a derived class, attempts to recognize the incoming activity. :param context: Context for the current turn of conversation with the user. - :type context: :class:`TurnContext` + :type context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack. :type state: :class:`typing.Dict[str, dict]` :param options: A prompt options object From 5cc177f8d3dd1c8e4ae34b74df8e791755dc9644 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Tue, 4 Feb 2020 14:14:37 -0800 Subject: [PATCH 215/616] Fixed build errors --- .../botbuilder/core/bot_framework_adapter.py | 10 +++++----- libraries/botbuilder-core/botbuilder/core/bot_state.py | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index b9a6574d8..688f7ccda 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -685,11 +685,11 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :return: A task that represents the work queued to execute .. remarks:: - The channel server returns results in pages and each page will include a `continuationToken` that - can be used to fetch the next page of results from the server. - If the task completes successfully, the result contains a page of the members of the current conversation. - This overload may be called from outside the context of a conversation, as only the bot's service URL and - credentials are required. + The channel server returns results in pages and each page will include a `continuationToken` that + can be used to fetch the next page of results from the server. + If the task completes successfully, the result contains a page of the members of the current conversation. + This overload may be called from outside the context of a conversation, as only the bot's service URL and + credentials are required. """ client = await self.create_connector_client(service_url) return await client.conversations.get_conversations(continuation_token) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index bf75ef2e8..7d429d9df 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -279,8 +279,7 @@ async def get( :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :param default_value_or_factory: Defines the default value when no value is set for the property - :type default_value_or_factory: :typing:Union + :param default_value_or_factory: Defines the default value for the property """ await self._bot_state.load(turn_context, False) try: @@ -307,7 +306,6 @@ async def set(self, turn_context: TurnContext, value: object) -> None: :type turn_context: :class:`botbuilder.core.TurnContext` :param value: The value to assign to the property - :type value: :typing:`Object` """ await self._bot_state.load(turn_context, False) await self._bot_state.set_property_value(turn_context, self._name, value) From ec836cf5dc58df06fe0cc1c293af22c80e88fe21 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 4 Feb 2020 23:55:41 -0600 Subject: [PATCH 216/616] Add FindChoiceOptions for recognize_numbers and recognize_ordinals (#691) * Add FindChoiceOptions for recognize_numbers and recognize_ordinals * Corrected if statement --- .../dialogs/choices/choice_recognizers.py | 281 ++-- .../dialogs/choices/find_choices_options.py | 61 +- .../tests/test_choice_prompt.py | 1412 +++++++++-------- 3 files changed, 896 insertions(+), 858 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_recognizers.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_recognizers.py index 8ac8eb1a4..02fb71e6e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_recognizers.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_recognizers.py @@ -1,140 +1,141 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List, Union -from recognizers_number import NumberModel, NumberRecognizer, OrdinalModel -from recognizers_text import Culture - - -from .choice import Choice -from .find import Find -from .find_choices_options import FindChoicesOptions -from .found_choice import FoundChoice -from .model_result import ModelResult - - -class ChoiceRecognizers: - """ Contains methods for matching user input against a list of choices. """ - - @staticmethod - def recognize_choices( - utterance: str, - choices: List[Union[str, Choice]], - options: FindChoicesOptions = None, - ) -> List[ModelResult]: - """ - Matches user input against a list of choices. - - This is layered above the `Find.find_choices()` function, and adds logic to let the user specify - their choice by index (they can say "one" to pick `choice[0]`) or ordinal position - (they can say "the second one" to pick `choice[1]`.) - The user's utterance is recognized in the following order: - - - By name using `find_choices()` - - By 1's based ordinal position. - - By 1's based index position. - - Parameters: - ----------- - - utterance: The input. - - choices: The list of choices. - - options: (Optional) Options to control the recognition strategy. - - Returns: - -------- - A list of found choices, sorted by most relevant first. - """ - if utterance is None: - utterance = "" - - # Normalize list of choices - choices_list = [ - Choice(value=choice) if isinstance(choice, str) else choice - for choice in choices - ] - - # Try finding choices by text search first - # - We only want to use a single strategy for returning results to avoid issues where utterances - # like the "the third one" or "the red one" or "the first division book" would miss-recognize as - # a numerical index or ordinal as well. - locale = options.locale if (options and options.locale) else Culture.English - matched = Find.find_choices(utterance, choices_list, options) - if not matched: - # Next try finding by ordinal - matches = ChoiceRecognizers._recognize_ordinal(utterance, locale) - - if matches: - for match in matches: - ChoiceRecognizers._match_choice_by_index( - choices_list, matched, match - ) - else: - # Finally try by numerical index - matches = ChoiceRecognizers._recognize_number(utterance, locale) - - for match in matches: - ChoiceRecognizers._match_choice_by_index( - choices_list, matched, match - ) - - # Sort any found matches by their position within the utterance. - # - The results from find_choices() are already properly sorted so we just need this - # for ordinal & numerical lookups. - matched = sorted(matched, key=lambda model_result: model_result.start) - - return matched - - @staticmethod - def _recognize_ordinal(utterance: str, culture: str) -> List[ModelResult]: - model: OrdinalModel = NumberRecognizer(culture).get_ordinal_model(culture) - - return list( - map(ChoiceRecognizers._found_choice_constructor, model.parse(utterance)) - ) - - @staticmethod - def _match_choice_by_index( - choices: List[Choice], matched: List[ModelResult], match: ModelResult - ): - try: - index: int = int(match.resolution.value) - 1 - if 0 <= index < len(choices): - choice = choices[index] - - matched.append( - ModelResult( - start=match.start, - end=match.end, - type_name="choice", - text=match.text, - resolution=FoundChoice( - value=choice.value, index=index, score=1.0 - ), - ) - ) - except: - # noop here, as in dotnet/node repos - pass - - @staticmethod - def _recognize_number(utterance: str, culture: str) -> List[ModelResult]: - model: NumberModel = NumberRecognizer(culture).get_number_model(culture) - - return list( - map(ChoiceRecognizers._found_choice_constructor, model.parse(utterance)) - ) - - @staticmethod - def _found_choice_constructor(value_model: ModelResult) -> ModelResult: - return ModelResult( - start=value_model.start, - end=value_model.end, - type_name="choice", - text=value_model.text, - resolution=FoundChoice( - value=value_model.resolution["value"], index=0, score=1.0 - ), - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List, Union +from recognizers_number import NumberModel, NumberRecognizer, OrdinalModel +from recognizers_text import Culture + + +from .choice import Choice +from .find import Find +from .find_choices_options import FindChoicesOptions +from .found_choice import FoundChoice +from .model_result import ModelResult + + +class ChoiceRecognizers: + """ Contains methods for matching user input against a list of choices. """ + + @staticmethod + def recognize_choices( + utterance: str, + choices: List[Union[str, Choice]], + options: FindChoicesOptions = None, + ) -> List[ModelResult]: + """ + Matches user input against a list of choices. + + This is layered above the `Find.find_choices()` function, and adds logic to let the user specify + their choice by index (they can say "one" to pick `choice[0]`) or ordinal position + (they can say "the second one" to pick `choice[1]`.) + The user's utterance is recognized in the following order: + + - By name using `find_choices()` + - By 1's based ordinal position. + - By 1's based index position. + + Parameters: + ----------- + + utterance: The input. + + choices: The list of choices. + + options: (Optional) Options to control the recognition strategy. + + Returns: + -------- + A list of found choices, sorted by most relevant first. + """ + if utterance is None: + utterance = "" + + # Normalize list of choices + choices_list = [ + Choice(value=choice) if isinstance(choice, str) else choice + for choice in choices + ] + + # Try finding choices by text search first + # - We only want to use a single strategy for returning results to avoid issues where utterances + # like the "the third one" or "the red one" or "the first division book" would miss-recognize as + # a numerical index or ordinal as well. + locale = options.locale if (options and options.locale) else Culture.English + matched = Find.find_choices(utterance, choices_list, options) + if not matched: + matches = [] + + if not options or options.recognize_ordinals: + # Next try finding by ordinal + matches = ChoiceRecognizers._recognize_ordinal(utterance, locale) + for match in matches: + ChoiceRecognizers._match_choice_by_index( + choices_list, matched, match + ) + + if not matches and (not options or options.recognize_numbers): + # Then try by numerical index + matches = ChoiceRecognizers._recognize_number(utterance, locale) + for match in matches: + ChoiceRecognizers._match_choice_by_index( + choices_list, matched, match + ) + + # Sort any found matches by their position within the utterance. + # - The results from find_choices() are already properly sorted so we just need this + # for ordinal & numerical lookups. + matched = sorted(matched, key=lambda model_result: model_result.start) + + return matched + + @staticmethod + def _recognize_ordinal(utterance: str, culture: str) -> List[ModelResult]: + model: OrdinalModel = NumberRecognizer(culture).get_ordinal_model(culture) + + return list( + map(ChoiceRecognizers._found_choice_constructor, model.parse(utterance)) + ) + + @staticmethod + def _match_choice_by_index( + choices: List[Choice], matched: List[ModelResult], match: ModelResult + ): + try: + index: int = int(match.resolution.value) - 1 + if 0 <= index < len(choices): + choice = choices[index] + + matched.append( + ModelResult( + start=match.start, + end=match.end, + type_name="choice", + text=match.text, + resolution=FoundChoice( + value=choice.value, index=index, score=1.0 + ), + ) + ) + except: + # noop here, as in dotnet/node repos + pass + + @staticmethod + def _recognize_number(utterance: str, culture: str) -> List[ModelResult]: + model: NumberModel = NumberRecognizer(culture).get_number_model(culture) + + return list( + map(ChoiceRecognizers._found_choice_constructor, model.parse(utterance)) + ) + + @staticmethod + def _found_choice_constructor(value_model: ModelResult) -> ModelResult: + return ModelResult( + start=value_model.start, + end=value_model.end, + type_name="choice", + text=value_model.text, + resolution=FoundChoice( + value=value_model.resolution["value"], index=0, score=1.0 + ), + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find_choices_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find_choices_options.py index 8a51fce8e..418781ddb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find_choices_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/find_choices_options.py @@ -1,23 +1,38 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .find_values_options import FindValuesOptions - - -class FindChoicesOptions(FindValuesOptions): - """ Contains options to control how input is matched against a list of choices """ - - def __init__(self, no_value: bool = None, no_action: bool = None, **kwargs): - """ - Parameters: - ----------- - - no_value: (Optional) If `True`, the choices `value` field will NOT be search over. Defaults to `False`. - - no_action: (Optional) If `True`, the choices `action.title` field will NOT be searched over. - Defaults to `False`. - """ - - super().__init__(**kwargs) - self.no_value = no_value - self.no_action = no_action +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .find_values_options import FindValuesOptions + + +class FindChoicesOptions(FindValuesOptions): + """ Contains options to control how input is matched against a list of choices """ + + def __init__( + self, + no_value: bool = None, + no_action: bool = None, + recognize_numbers: bool = True, + recognize_ordinals: bool = True, + **kwargs, + ): + """ + Parameters: + ----------- + + no_value: (Optional) If `True`, the choices `value` field will NOT be search over. Defaults to `False`. + + no_action: (Optional) If `True`, the choices `action.title` field will NOT be searched over. + Defaults to `False`. + + recognize_numbers: (Optional) Indicates whether the recognizer should check for Numbers using the + NumberRecognizer's NumberModel. + + recognize_ordinals: (Options) Indicates whether the recognizer should check for Ordinal Numbers using + the NumberRecognizer's OrdinalModel. + """ + + super().__init__(**kwargs) + self.no_value = no_value + self.no_action = no_action + self.recognize_numbers = recognize_numbers + self.recognize_ordinals = recognize_ordinals diff --git a/libraries/botbuilder-dialogs/tests/test_choice_prompt.py b/libraries/botbuilder-dialogs/tests/test_choice_prompt.py index d0f581647..16cb16c9e 100644 --- a/libraries/botbuilder-dialogs/tests/test_choice_prompt.py +++ b/libraries/botbuilder-dialogs/tests/test_choice_prompt.py @@ -1,695 +1,717 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -import aiounittest -from recognizers_text import Culture - -from botbuilder.core import CardFactory, ConversationState, MemoryStorage, TurnContext -from botbuilder.core.adapters import TestAdapter -from botbuilder.dialogs import DialogSet, DialogTurnResult, DialogTurnStatus -from botbuilder.dialogs.choices import Choice, ListStyle -from botbuilder.dialogs.prompts import ( - ChoicePrompt, - PromptOptions, - PromptValidatorContext, -) -from botbuilder.schema import Activity, ActivityTypes - -_color_choices: List[Choice] = [ - Choice(value="red"), - Choice(value="green"), - Choice(value="blue"), -] - -_answer_message: Activity = Activity(text="red", type=ActivityTypes.message) -_invalid_message: Activity = Activity(text="purple", type=ActivityTypes.message) - - -class ChoicePromptTest(aiounittest.AsyncTestCase): - def test_choice_prompt_with_empty_id_should_fail(self): - empty_id = "" - - with self.assertRaises(TypeError): - ChoicePrompt(empty_id) - - def test_choice_prompt_with_none_id_should_fail(self): - none_id = None - - with self.assertRaises(TypeError): - ChoicePrompt(none_id) - - async def test_should_call_choice_prompt_using_dc_prompt(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("ChoicePrompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - # Initialize TestAdapter. - adapter = TestAdapter(exec_test) - - # Create new ConversationState with MemoryStorage and register the state as middleware. - convo_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet, and ChoicePrompt. - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - choice_prompt = ChoicePrompt("ChoicePrompt") - dialogs.add(choice_prompt) - - step1 = await adapter.send("hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_call_choice_prompt_with_custom_validator(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - return prompt.recognized.succeeded - - choice_prompt = ChoicePrompt("prompt", validator) - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step5 = await step4.send(_answer_message) - await step5.assert_reply("red") - - async def test_should_send_custom_retry_prompt(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please choose red, blue, or green.", - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - choice_prompt = ChoicePrompt("prompt") - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply( - "Please choose red, blue, or green. (1) red, (2) green, or (3) blue" - ) - step5 = await step4.send(_answer_message) - await step5.assert_reply("red") - - async def test_should_send_ignore_retry_prompt_if_validator_replies(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - retry_prompt=Activity( - type=ActivityTypes.message, - text="Please choose red, blue, or green.", - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - choice_prompt = ChoicePrompt("prompt", validator) - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply("Bad input.") - step5 = await step4.send(_answer_message) - await step5.assert_reply("red") - - async def test_should_use_default_locale_when_rendering_choices(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - choice_prompt = ChoicePrompt( - "prompt", validator, default_locale=Culture.Spanish - ) - - dialogs.add(choice_prompt) - - step1 = await adapter.send(Activity(type=ActivityTypes.message, text="Hello")) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, o (3) blue" - ) - step3 = await step2.send(_invalid_message) - step4 = await step3.assert_reply("Bad input.") - step5 = await step4.send(Activity(type=ActivityTypes.message, text="red")) - await step5.assert_reply("red") - - async def test_should_use_context_activity_locale_when_rendering_choices(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - choice_prompt = ChoicePrompt("prompt", validator) - dialogs.add(choice_prompt) - - step1 = await adapter.send( - Activity(type=ActivityTypes.message, text="Hello", locale=Culture.Spanish) - ) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, o (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_use_context_activity_locale_over_default_locale_when_rendering_choices( - self, - ): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def validator(prompt: PromptValidatorContext) -> bool: - assert prompt - - if not prompt.recognized.succeeded: - await prompt.context.send_activity("Bad input.") - - return prompt.recognized.succeeded - - choice_prompt = ChoicePrompt( - "prompt", validator, default_locale=Culture.Spanish - ) - dialogs.add(choice_prompt) - - step1 = await adapter.send( - Activity(type=ActivityTypes.message, text="Hello", locale=Culture.English) - ) - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_not_render_choices_if_list_style_none_is_specified(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - style=ListStyle.none, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply("Please choose a color.") - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_create_prompt_with_inline_choices_when_specified(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - choice_prompt.style = ListStyle.in_line - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_create_prompt_with_list_choices_when_specified(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - choice_prompt.style = ListStyle.list_style - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color.\n\n 1. red\n 2. green\n 3. blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_create_prompt_with_suggested_action_style_when_specified( - self, - ): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - style=ListStyle.suggested_action, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply("Please choose a color.") - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_create_prompt_with_auto_style_when_specified(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - style=ListStyle.auto, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send(_answer_message) - await step3.assert_reply("red") - - async def test_should_recognize_valid_number_choice(self): - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a color." - ), - choices=_color_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply( - "Please choose a color. (1) red, (2) green, or (3) blue" - ) - step3 = await step2.send("1") - await step3.assert_reply("red") - - async def test_should_display_choices_on_hero_card(self): - size_choices = ["large", "medium", "small"] - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions( - prompt=Activity( - type=ActivityTypes.message, text="Please choose a size." - ), - choices=size_choices, - ) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - def assert_expected_activity( - activity: Activity, description - ): # pylint: disable=unused-argument - assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.hero_card - ) - assert activity.attachments[0].content.text == "Please choose a size." - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - - # Change the ListStyle of the prompt to ListStyle.none. - choice_prompt.style = ListStyle.hero_card - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - step2 = await step1.assert_reply(assert_expected_activity) - step3 = await step2.send("1") - await step3.assert_reply(size_choices[0]) - - async def test_should_display_choices_on_hero_card_with_additional_attachment(self): - size_choices = ["large", "medium", "small"] - card = CardFactory.adaptive_card( - { - "type": "AdaptiveCard", - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "version": "1.2", - "body": [], - } - ) - card_activity = Activity(attachments=[card]) - - async def exec_test(turn_context: TurnContext): - dialog_context = await dialogs.create_context(turn_context) - - results: DialogTurnResult = await dialog_context.continue_dialog() - - if results.status == DialogTurnStatus.Empty: - options = PromptOptions(prompt=card_activity, choices=size_choices) - await dialog_context.prompt("prompt", options) - elif results.status == DialogTurnStatus.Complete: - selected_choice = results.result - await turn_context.send_activity(selected_choice.value) - - await convo_state.save_changes(turn_context) - - def assert_expected_activity( - activity: Activity, description - ): # pylint: disable=unused-argument - assert len(activity.attachments) == 2 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.adaptive_card - ) - assert ( - activity.attachments[1].content_type - == CardFactory.content_types.hero_card - ) - - adapter = TestAdapter(exec_test) - - convo_state = ConversationState(MemoryStorage()) - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - choice_prompt = ChoicePrompt("prompt") - - # Change the ListStyle of the prompt to ListStyle.none. - choice_prompt.style = ListStyle.hero_card - - dialogs.add(choice_prompt) - - step1 = await adapter.send("Hello") - await step1.assert_reply(assert_expected_activity) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +import aiounittest +from recognizers_text import Culture + +from botbuilder.core import CardFactory, ConversationState, MemoryStorage, TurnContext +from botbuilder.core.adapters import TestAdapter +from botbuilder.dialogs import ( + DialogSet, + DialogTurnResult, + DialogTurnStatus, + ChoiceRecognizers, + FindChoicesOptions, +) +from botbuilder.dialogs.choices import Choice, ListStyle +from botbuilder.dialogs.prompts import ( + ChoicePrompt, + PromptOptions, + PromptValidatorContext, +) +from botbuilder.schema import Activity, ActivityTypes + +_color_choices: List[Choice] = [ + Choice(value="red"), + Choice(value="green"), + Choice(value="blue"), +] + +_answer_message: Activity = Activity(text="red", type=ActivityTypes.message) +_invalid_message: Activity = Activity(text="purple", type=ActivityTypes.message) + + +class ChoicePromptTest(aiounittest.AsyncTestCase): + def test_choice_prompt_with_empty_id_should_fail(self): + empty_id = "" + + with self.assertRaises(TypeError): + ChoicePrompt(empty_id) + + def test_choice_prompt_with_none_id_should_fail(self): + none_id = None + + with self.assertRaises(TypeError): + ChoicePrompt(none_id) + + async def test_should_call_choice_prompt_using_dc_prompt(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("ChoicePrompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + # Initialize TestAdapter. + adapter = TestAdapter(exec_test) + + # Create new ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet, and ChoicePrompt. + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + choice_prompt = ChoicePrompt("ChoicePrompt") + dialogs.add(choice_prompt) + + step1 = await adapter.send("hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_call_choice_prompt_with_custom_validator(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + return prompt.recognized.succeeded + + choice_prompt = ChoicePrompt("prompt", validator) + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step5 = await step4.send(_answer_message) + await step5.assert_reply("red") + + async def test_should_send_custom_retry_prompt(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please choose red, blue, or green.", + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + choice_prompt = ChoicePrompt("prompt") + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply( + "Please choose red, blue, or green. (1) red, (2) green, or (3) blue" + ) + step5 = await step4.send(_answer_message) + await step5.assert_reply("red") + + async def test_should_send_ignore_retry_prompt_if_validator_replies(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + retry_prompt=Activity( + type=ActivityTypes.message, + text="Please choose red, blue, or green.", + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + choice_prompt = ChoicePrompt("prompt", validator) + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply("Bad input.") + step5 = await step4.send(_answer_message) + await step5.assert_reply("red") + + async def test_should_use_default_locale_when_rendering_choices(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + choice_prompt = ChoicePrompt( + "prompt", validator, default_locale=Culture.Spanish + ) + + dialogs.add(choice_prompt) + + step1 = await adapter.send(Activity(type=ActivityTypes.message, text="Hello")) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, o (3) blue" + ) + step3 = await step2.send(_invalid_message) + step4 = await step3.assert_reply("Bad input.") + step5 = await step4.send(Activity(type=ActivityTypes.message, text="red")) + await step5.assert_reply("red") + + async def test_should_use_context_activity_locale_when_rendering_choices(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + choice_prompt = ChoicePrompt("prompt", validator) + dialogs.add(choice_prompt) + + step1 = await adapter.send( + Activity(type=ActivityTypes.message, text="Hello", locale=Culture.Spanish) + ) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, o (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_use_context_activity_locale_over_default_locale_when_rendering_choices( + self, + ): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def validator(prompt: PromptValidatorContext) -> bool: + assert prompt + + if not prompt.recognized.succeeded: + await prompt.context.send_activity("Bad input.") + + return prompt.recognized.succeeded + + choice_prompt = ChoicePrompt( + "prompt", validator, default_locale=Culture.Spanish + ) + dialogs.add(choice_prompt) + + step1 = await adapter.send( + Activity(type=ActivityTypes.message, text="Hello", locale=Culture.English) + ) + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_not_render_choices_if_list_style_none_is_specified(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + style=ListStyle.none, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply("Please choose a color.") + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_create_prompt_with_inline_choices_when_specified(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + choice_prompt.style = ListStyle.in_line + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_create_prompt_with_list_choices_when_specified(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + choice_prompt.style = ListStyle.list_style + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color.\n\n 1. red\n 2. green\n 3. blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_create_prompt_with_suggested_action_style_when_specified( + self, + ): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + style=ListStyle.suggested_action, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply("Please choose a color.") + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_create_prompt_with_auto_style_when_specified(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + style=ListStyle.auto, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send(_answer_message) + await step3.assert_reply("red") + + async def test_should_recognize_valid_number_choice(self): + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a color." + ), + choices=_color_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply( + "Please choose a color. (1) red, (2) green, or (3) blue" + ) + step3 = await step2.send("1") + await step3.assert_reply("red") + + async def test_should_display_choices_on_hero_card(self): + size_choices = ["large", "medium", "small"] + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="Please choose a size." + ), + choices=size_choices, + ) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + def assert_expected_activity( + activity: Activity, description + ): # pylint: disable=unused-argument + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.hero_card + ) + assert activity.attachments[0].content.text == "Please choose a size." + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + + # Change the ListStyle of the prompt to ListStyle.none. + choice_prompt.style = ListStyle.hero_card + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(assert_expected_activity) + step3 = await step2.send("1") + await step3.assert_reply(size_choices[0]) + + async def test_should_display_choices_on_hero_card_with_additional_attachment(self): + size_choices = ["large", "medium", "small"] + card = CardFactory.adaptive_card( + { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.2", + "body": [], + } + ) + card_activity = Activity(attachments=[card]) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results: DialogTurnResult = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions(prompt=card_activity, choices=size_choices) + await dialog_context.prompt("prompt", options) + elif results.status == DialogTurnStatus.Complete: + selected_choice = results.result + await turn_context.send_activity(selected_choice.value) + + await convo_state.save_changes(turn_context) + + def assert_expected_activity( + activity: Activity, description + ): # pylint: disable=unused-argument + assert len(activity.attachments) == 2 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.adaptive_card + ) + assert ( + activity.attachments[1].content_type + == CardFactory.content_types.hero_card + ) + + adapter = TestAdapter(exec_test) + + convo_state = ConversationState(MemoryStorage()) + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + choice_prompt = ChoicePrompt("prompt") + + # Change the ListStyle of the prompt to ListStyle.none. + choice_prompt.style = ListStyle.hero_card + + dialogs.add(choice_prompt) + + step1 = await adapter.send("Hello") + await step1.assert_reply(assert_expected_activity) + + async def test_should_not_find_a_choice_in_an_utterance_by_ordinal(self): + found = ChoiceRecognizers.recognize_choices( + "the first one please", + _color_choices, + FindChoicesOptions(recognize_numbers=False, recognize_ordinals=False), + ) + assert not found + + async def test_should_not_find_a_choice_in_an_utterance_by_numerical_index(self): + found = ChoiceRecognizers.recognize_choices( + "one", + _color_choices, + FindChoicesOptions(recognize_numbers=False, recognize_ordinals=False), + ) + assert not found From 427b41e3705192e8ca6980f9fa22ee19052a2840 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 5 Feb 2020 10:30:17 -0600 Subject: [PATCH 217/616] Updated Whats New link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a9392f33..06705fa91 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![Bot Framework SDK v4 Python](./doc/media/FrameWorkPython.png) -### [Click here to find out what's new with Bot Framework](https://github.com/Microsoft/botframework/blob/master/whats-new.md#whats-new) +### [Click here to find out what's new with Bot Framework](https://docs.microsoft.com/en-us/azure/bot-service/what-is-new?view=azure-bot-service-4.0) # Bot Framework SDK v4 for Python [![Build status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=431) From e6b670136ff219193237aef360b777da995f5cca Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 5 Feb 2020 12:20:56 -0600 Subject: [PATCH 218/616] Added botbuilder-adapters-slack (#688) * Added botbuilder-adapters-slack * black fixes * setup.py black fixes * pylint fixes --- .../botbuilder-adapters-slack/README.rst | 83 ++++ .../botbuilder/adapters/slack/__init__.py | 30 ++ .../botbuilder/adapters/slack/about.py | 14 + .../slack/activity_resourceresponse.py | 11 + .../adapters/slack/slack_adapter.py | 210 ++++++++ .../botbuilder/adapters/slack/slack_client.py | 449 ++++++++++++++++++ .../botbuilder/adapters/slack/slack_event.py | 33 ++ .../botbuilder/adapters/slack/slack_helper.py | 271 +++++++++++ .../adapters/slack/slack_message.py | 33 ++ .../adapters/slack/slack_options.py | 46 ++ .../adapters/slack/slack_payload.py | 27 ++ .../adapters/slack/slack_request_body.py | 36 ++ .../requirements.txt | 4 + libraries/botbuilder-adapters-slack/setup.cfg | 2 + libraries/botbuilder-adapters-slack/setup.py | 47 ++ 15 files changed, 1296 insertions(+) create mode 100644 libraries/botbuilder-adapters-slack/README.rst create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py create mode 100644 libraries/botbuilder-adapters-slack/requirements.txt create mode 100644 libraries/botbuilder-adapters-slack/setup.cfg create mode 100644 libraries/botbuilder-adapters-slack/setup.py diff --git a/libraries/botbuilder-adapters-slack/README.rst b/libraries/botbuilder-adapters-slack/README.rst new file mode 100644 index 000000000..a3813c8b3 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/README.rst @@ -0,0 +1,83 @@ + +================================= +BotBuilder-Adapters SDK for Python +================================= + +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-dialogs.svg + :target: https://badge.fury.io/py/botbuilder-dialogs + :alt: Latest PyPI package version + +A dialog stack based conversation manager for Microsoft BotBuilder. + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-dialogs + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.7.0`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.7.0: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py new file mode 100644 index 000000000..1ab395b75 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py @@ -0,0 +1,30 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .about import __version__ +from .slack_options import SlackAdapterOptions +from .slack_client import SlackClient +from .slack_adapter import SlackAdapter +from .slack_payload import SlackPayload +from .slack_message import SlackMessage +from .slack_event import SlackEvent +from .activity_resourceresponse import ActivityResourceResponse +from .slack_request_body import SlackRequestBody +from .slack_helper import SlackHelper + +__all__ = [ + "__version__", + "SlackAdapterOptions", + "SlackClient", + "SlackAdapter", + "SlackPayload", + "SlackMessage", + "SlackEvent", + "ActivityResourceResponse", + "SlackRequestBody", + "SlackHelper", +] diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py new file mode 100644 index 000000000..2babae85d --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-adapters-slack" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py new file mode 100644 index 000000000..e99b2edd9 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import ResourceResponse, ConversationAccount + + +class ActivityResourceResponse(ResourceResponse): + def __init__(self, activity_id: str, conversation: ConversationAccount, **kwargs): + super().__init__(**kwargs) + self.activity_id = activity_id + self.conversation = conversation diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py new file mode 100644 index 000000000..93fac05b9 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -0,0 +1,210 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from typing import List, Callable, Awaitable + +from aiohttp.web_request import Request +from aiohttp.web_response import Response +from botframework.connector.auth import ClaimsIdentity +from botbuilder.core import conversation_reference_extension +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import ( + Activity, + ResourceResponse, + ActivityTypes, + ConversationAccount, + ConversationReference, +) + +from .activity_resourceresponse import ActivityResourceResponse +from .slack_client import SlackClient +from .slack_helper import SlackHelper + + +class SlackAdapter(BotAdapter, ABC): + """ + BotAdapter that can handle incoming slack events. Incoming slack events are deserialized to an Activity + that is dispatch through the middleware and bot pipeline. + """ + + def __init__( + self, + client: SlackClient, + on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None, + ): + super().__init__(on_turn_error) + self.slack_client = client + self.slack_logged_in = False + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + """ + Standard BotBuilder adapter method to send a message from the bot to the messaging API. + + :param context: A TurnContext representing the current incoming message and environment. + :param activities: An array of outgoing activities to be sent back to the messaging API. + :return: An array of ResourceResponse objects containing the IDs that Slack assigned to the sent messages. + """ + + if not context: + raise Exception("TurnContext is required") + if not activities: + raise Exception("List[Activity] is required") + + responses = [] + + for activity in activities: + if activity.type == ActivityTypes.message: + message = SlackHelper.activity_to_slack(activity) + + slack_response = await self.slack_client.post_message_to_slack(message) + + if slack_response and slack_response.status_code / 100 == 2: + resource_response = ActivityResourceResponse( + id=slack_response.data["ts"], + activity_id=slack_response.data["ts"], + conversation=ConversationAccount( + id=slack_response.data["channel"] + ), + ) + + responses.append(resource_response) + + return responses + + async def update_activity(self, context: TurnContext, activity: Activity): + """ + Standard BotBuilder adapter method to update a previous message with new content. + + :param context: A TurnContext representing the current incoming message and environment. + :param activity: The updated activity in the form '{id: `id of activity to update`, ...}'. + :return: A resource response with the Id of the updated activity. + """ + + if not context: + raise Exception("TurnContext is required") + if not activity: + raise Exception("Activity is required") + if not activity.id: + raise Exception("Activity.id is required") + if not activity.conversation: + raise Exception("Activity.conversation is required") + + message = SlackHelper.activity_to_slack(activity) + results = await self.slack_client.update( + timestamp=message.ts, channel_id=message.channel, text=message.text, + ) + + if results.status_code / 100 != 2: + raise Exception(f"Error updating activity on slack: {results}") + + return ResourceResponse(id=activity.id) + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + """ + Standard BotBuilder adapter method to delete a previous message. + + :param context: A TurnContext representing the current incoming message and environment. + :param reference: An object in the form "{activityId: `id of message to delete`, + conversation: { id: `id of slack channel`}}". + """ + + if not context: + raise Exception("TurnContext is required") + if not reference: + raise Exception("ConversationReference is required") + if not reference.channel_id: + raise Exception("ConversationReference.channel_id is required") + if not context.activity.timestamp: + raise Exception("Activity.timestamp is required") + + await self.slack_client.delete_message( + channel_id=reference.channel_id, timestamp=context.activity.timestamp + ) + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ): + """ + Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. + Most _channels require a user to initiate a conversation with a bot before the bot can send activities + to the user. + :param bot_id: The application ID of the bot. This parameter is ignored in + single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. + :param claims_identity: + """ + + if not reference: + raise Exception("ConversationReference is required") + if not callback: + raise Exception("callback is required") + + request = TurnContext.apply_conversation_reference( + conversation_reference_extension.get_continuation_activity(reference), + reference, + ) + context = TurnContext(self, request) + + return await self.run_pipeline(context, callback) + + async def process(self, req: Request, logic: Callable) -> Response: + """ + Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. + + :param req: The aoihttp Request object + :param logic: The method to call for the resulting bot turn. + :return: The aoihttp Response + """ + if not req: + raise Exception("Request is required") + + if not self.slack_logged_in: + await self.slack_client.login_with_slack() + self.slack_logged_in = True + + body = await req.text() + slack_body = SlackHelper.deserialize_body(req.content_type, body) + + if slack_body.type == "url_verification": + return SlackHelper.response(req, 200, slack_body.challenge) + + if not self.slack_client.verify_signature(req, body): + text = "Rejected due to mismatched header signature" + return SlackHelper.response(req, 401, text) + + if ( + not self.slack_client.options.slack_verification_token + and slack_body.token != self.slack_client.options.slack_verification_token + ): + text = f"Rejected due to mismatched verificationToken:{body}" + return SlackHelper.response(req, 403, text) + + if slack_body.payload: + # handle interactive_message callbacks and block_actions + activity = SlackHelper.payload_to_activity(slack_body.payload) + elif slack_body.type == "event_callback": + activity = await SlackHelper.event_to_activity( + slack_body.event, self.slack_client + ) + elif slack_body.command: + activity = await SlackHelper.command_to_activity( + slack_body, self.slack_client + ) + else: + raise Exception(f"Unknown Slack event type {slack_body.type}") + + context = TurnContext(self, activity) + await self.run_pipeline(context, logic) + + return SlackHelper.response(req, 200) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py new file mode 100644 index 000000000..d5e645f3f --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py @@ -0,0 +1,449 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import hashlib +import hmac +import json +from io import IOBase +from typing import Union + +import aiohttp +from aiohttp.web_request import Request +from slack.web.client import WebClient +from slack.web.slack_response import SlackResponse + +from botbuilder.schema import Activity +from botbuilder.adapters.slack import SlackAdapterOptions +from botbuilder.adapters.slack.slack_message import SlackMessage + +POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage" +POST_EPHEMERAL_MESSAGE_URL = "https://slack.com/api/chat.postEphemeral" + + +class SlackClient(WebClient): + """ + Slack client that extends https://github.com/slackapi/python-slackclient. + """ + + def __init__(self, options: SlackAdapterOptions): + if not options or not options.slack_bot_token: + raise Exception("SlackAdapterOptions and bot_token are required") + + if ( + not options.slack_verification_token + and not options.slack_client_signing_secret + ): + warning = ( + "\n****************************************************************************************\n" + "* WARNING: Your bot is operating without recommended security mechanisms in place. *\n" + "* Initialize your adapter with a clientSigningSecret parameter to enable *\n" + "* verification that all incoming webhooks originate with Slack: *\n" + "* *\n" + "* adapter = new SlackAdapter({clientSigningSecret: }); *\n" + "* *\n" + "****************************************************************************************\n" + ">> Slack docs: https://api.slack.com/docs/verifying-requests-from-slack" + ) + raise Exception( + warning + + "Required: include a verificationToken or clientSigningSecret to verify incoming Events API webhooks" + ) + + super().__init__(token=options.slack_bot_token, run_async=True) + + self.options = options + self.identity = None + + async def login_with_slack(self): + if self.options.slack_bot_token: + self.identity = await self.test_auth() + elif ( + not self.options.slack_client_id + or not self.options.slack_client_secret + or not self.options.slack_redirect_uri + or not self.options.slack_scopes + ): + raise Exception( + "Missing Slack API credentials! Provide SlackClientId, SlackClientSecret, scopes and SlackRedirectUri " + "as part of the SlackAdapter options." + ) + + def is_logged_in(self): + return self.identity is not None + + async def test_auth(self) -> str: + auth = await self.auth_test() + return auth.data["user_id"] + + async def channels_list_ex(self, exclude_archived: bool = True) -> SlackResponse: + args = {"exclude_archived": "1" if exclude_archived else "0"} + return await self.channels_list(**args) + + async def users_counts(self) -> SlackResponse: + return await self.api_call("users.counts") + + async def im_history_ex( + self, + channel: str, + latest_timestamp: str = None, + oldest_timestamp: str = None, + count: int = None, + unreads: bool = None, + ) -> SlackResponse: + args = {} + if latest_timestamp: + args["latest"] = latest_timestamp + if oldest_timestamp: + args["oldest"] = oldest_timestamp + if count: + args["count"] = str(count) + if unreads: + args["unreads"] = "1" if unreads else "0" + + return await self.im_history(channel=channel, **args) + + async def files_info_ex( + self, file_id: str, page: int = None, count: int = None + ) -> SlackResponse: + args = {"count": str(count), "page": str(page)} + return await self.files_info(file=file_id, **args) + + async def files_list_ex( + self, + user_id: str = None, + date_from: str = None, + date_to: str = None, + count: int = None, + page: int = None, + types: [str] = None, + ) -> SlackResponse: + args = {} + + if user_id: + args["user"] = user_id + + if date_from: + args["ts_from"] = date_from + if date_to: + args["ts_to"] = date_to + + if count: + args["count"] = str(count) + if page: + args["page"] = str(page) + + if types: + args["types"] = ",".join(types) + + return await self.files_list(**args) + + async def groups_history_ex( + self, channel: str, latest: str = None, oldest: str = None, count: int = None + ) -> SlackResponse: + args = {} + + if latest: + args["latest"] = latest + if oldest: + args["oldest"] = oldest + + if count: + args["count"] = count + + return await self.groups_history(channel=channel, **args) + + async def groups_list_ex(self, exclude_archived: bool = True) -> SlackResponse: + args = {"exclude_archived": "1" if exclude_archived else "0"} + return await self.groups_list(**args) + + async def get_preferences(self) -> SlackResponse: + return await self.api_call("users.prefs.get", http_verb="GET") + + async def stars_list_ex( + self, user: str = None, count: int = None, page: int = None + ) -> SlackResponse: + args = {} + + if user: + args["user"] = user + if count: + args["count"] = str(count) + if page: + args["page"] = str(page) + + return await self.stars_list(**args) + + async def groups_close(self, channel: str) -> SlackResponse: + args = {"channel": channel} + return await self.api_call("groups.close", params=args) + + async def chat_post_ephemeral_ex( + self, + channel: str, + text: str, + target_user: str, + parse: str = None, + link_names: bool = False, + attachments: [str] = None, # pylint: disable=unused-argument + as_user: bool = False, + ) -> SlackResponse: + args = { + "text": text, + "link_names": "1" if link_names else "0", + "as_user": "1" if as_user else "0", + } + + if parse: + args["parse"] = parse + + # TODO: attachments (see PostEphemeralMessageAsync) + # See: https://api.slack.com/messaging/composing/layouts#attachments + # See: https://github.com/Inumedia/SlackAPI/blob/master/SlackAPI/Attachment.cs + + return await self.chat_postEphemeral(channel=channel, user=target_user, **args) + + async def chat_post_message_ex( + self, + channel: str, + text: str, + bot_name: str = None, + parse: str = None, + link_names: bool = False, + blocks: [str] = None, # pylint: disable=unused-argument + attachments: [str] = None, # pylint: disable=unused-argument + unfurl_links: bool = False, + icon_url: str = None, + icon_emoji: str = None, + as_user: bool = False, + ) -> SlackResponse: + args = { + "text": text, + "link_names": "1" if link_names else "0", + "as_user": "1" if as_user else "0", + } + + if bot_name: + args["username"] = bot_name + + if parse: + args["parse"] = parse + + if unfurl_links: + args["unfurl_links"] = "1" if unfurl_links else "0" + + if icon_url: + args["icon_url"] = icon_url + + if icon_emoji: + args["icon_emoji"] = icon_emoji + + # TODO: blocks and attachments (see PostMessageAsync) + # the blocks and attachments are combined into a single dict + # See: https://api.slack.com/messaging/composing/layouts#attachments + # See: https://github.com/Inumedia/SlackAPI/blob/master/SlackAPI/Attachment.cs + + return await self.chat_postMessage(channel=channel, **args) + + async def search_all_ex( + self, + query: str, + sorting: str = None, + direction: str = None, + enable_highlights: bool = False, + count: int = None, + page: int = None, + ) -> SlackResponse: + args = {"highlight": "1" if enable_highlights else "0"} + + if sorting: + args["sort"] = sorting + + if direction: + args["sort_dir"] = direction + + if count: + args["count"] = str(count) + + if page: + args["page"] = str(page) + + return await self.search_all(query=query, **args) + + async def search_files_ex( + self, + query: str, + sorting: str = None, + direction: str = None, + enable_highlights: bool = False, + count: int = None, + page: int = None, + ) -> SlackResponse: + args = {"highlight": "1" if enable_highlights else "0"} + + if sorting: + args["sort"] = sorting + + if direction: + args["sort_dir"] = direction + + if count: + args["count"] = str(count) + + if page: + args["page"] = str(page) + + return await self.search_files(query=query, **args) + + async def search_messages_ex( + self, + query: str, + sorting: str = None, + direction: str = None, + enable_highlights: bool = False, + count: int = None, + page: int = None, + ) -> SlackResponse: + args = {"highlight": "1" if enable_highlights else "0"} + + if sorting: + args["sort"] = sorting + + if direction: + args["sort_dir"] = direction + + if count: + args["count"] = str(count) + + if page: + args["page"] = str(page) + + return await self.search_messages(query=query, **args) + + async def chat_update_ex( + self, + timestamp: str, + channel: str, + text: str, + bot_name: str = None, + parse: str = None, + link_names: bool = False, + attachments: [str] = None, # pylint: disable=unused-argument + as_user: bool = False, + ): + args = { + "text": text, + "link_names": "1" if link_names else "0", + "as_user": "1" if as_user else "0", + } + + if bot_name: + args["username"] = bot_name + + if parse: + args["parse"] = parse + + # TODO: attachments (see PostEphemeralMessageAsync) + # See: https://api.slack.com/messaging/composing/layouts#attachments + # See: https://github.com/Inumedia/SlackAPI/blob/master/SlackAPI/Attachment.cs + + return await self.chat_update(channel=channel, ts=timestamp) + + async def files_upload_ex( + self, + file: Union[str, IOBase] = None, + content: str = None, + channels: [str] = None, + title: str = None, + initial_comment: str = None, + file_type: str = None, + ): + args = {} + + if channels: + args["channels"] = ",".join(channels) + + if title: + args["title"] = title + + if initial_comment: + args["initial_comment"] = initial_comment + + if file_type: + args["filetype"] = file_type + + return await self.files_upload(file=file, content=content, **args) + + async def get_bot_user_by_team(self, activity: Activity) -> str: + if self.identity: + return self.identity + + if not activity.conversation.properties["team"]: + return None + + user = await self.options.get_bot_user_by_team( + activity.conversation.properties["team"] + ) + if user: + return user + raise Exception("Missing credentials for team.") + + def verify_signature(self, req: Request, body: str) -> bool: + timestamp = req.headers["X-Slack-Request-Timestamp"] + message = ":".join(["v0", timestamp, body]) + + computed_signature = "V0=" + hmac.new( + bytes(self.options.slack_client_signing_secret, "utf-8"), + msg=bytes(message, "utf-8"), + digestmod=hashlib.sha256, + ).hexdigest().upper().replace("-", "") + + received_signature = req.headers["X-Slack-Signature"].upper() + + return computed_signature == received_signature + + async def post_message_to_slack(self, message: SlackMessage) -> SlackResponse: + if not message: + return None + + request_content = { + "token": self.options.slack_bot_token, + "channel": message.channel, + "text": message.text, + } + + if message.thread_ts: + request_content["thread_ts"] = message.thread_ts + + if message.blocks: + request_content["blocks"] = json.dumps(message.blocks) + + session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30),) + + http_verb = "POST" + api_url = POST_EPHEMERAL_MESSAGE_URL if message.ephemeral else POST_MESSAGE_URL + req_args = {"data": request_content} + + async with session.request(http_verb, api_url, **req_args) as res: + response_content = {} + try: + response_content = await res.json() + except aiohttp.ContentTypeError: + pass + + response_data = { + "data": response_content, + "headers": res.headers, + "status_code": res.status, + } + + data = { + "client": self, + "http_verb": http_verb, + "api_url": api_url, + "req_args": req_args, + } + response = SlackResponse(**{**data, **response_data}).validate() + + await session.close() + + return response diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py new file mode 100644 index 000000000..689b0b25c --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.adapters.slack.slack_message import SlackMessage + + +class SlackEvent: + """ + Wrapper class for an incoming slack event. + """ + + def __init__(self, **kwargs): + self.client_msg_id = kwargs.get("client_msg_id") + self.type = kwargs.get("type") + self.subtype = kwargs.get("subtype") + self.text = kwargs.get("text") + self.ts = kwargs.get("ts") # pylint: disable=invalid-name + self.team = kwargs.get("team") + self.channel = kwargs.get("channel") + self.channel_id = kwargs.get("channel_id") + self.event_ts = kwargs.get("event_ts") + self.channel_type = kwargs.get("channel_type") + self.thread_ts = kwargs.get("thread_ts") + self.user = kwargs.get("user") + self.user_id = kwargs.get("user_id") + self.bot_id = kwargs.get("bot_id") + self.actions: [str] = kwargs.get("actions") + self.item = kwargs.get("item") + self.item_channel = kwargs.get("item_channel") + self.files: [] = kwargs.get("files") + self.message = ( + None if "message" not in kwargs else SlackMessage(**kwargs.get("message")) + ) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py new file mode 100644 index 000000000..bc5e471a3 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import urllib.parse + +from aiohttp.web_request import Request +from aiohttp.web_response import Response +from slack.web.classes.attachments import Attachment + +from botbuilder.schema import ( + Activity, + ConversationAccount, + ChannelAccount, + ActivityTypes, +) + +from .slack_message import SlackMessage +from .slack_client import SlackClient +from .slack_event import SlackEvent +from .slack_payload import SlackPayload +from .slack_request_body import SlackRequestBody + + +class SlackHelper: + @staticmethod + def activity_to_slack(activity: Activity) -> SlackMessage: + """ + Formats a BotBuilder activity into an outgoing Slack message. + :param activity: A BotBuilder Activity object. + :return: A Slack message object with {text, attachments, channel, thread ts} as well + as any fields found in activity.channelData + """ + + if not activity: + raise Exception("Activity required") + + # use ChannelData if available + if activity.channel_data: + message = activity.channel_data + else: + message = SlackMessage( + ts=activity.timestamp, + text=activity.text, + channel=activity.conversation.id, + ) + + if activity.attachments: + attachments = [] + for att in activity.attachments: + if att.name == "blocks": + message.blocks = att.content + else: + new_attachment = Attachment( + author_name=att.name, thumb_url=att.thumbnail_url, + ) + attachments.append(new_attachment) + + if attachments: + message.attachments = attachments + + if ( + activity.conversation.properties + and "thread_ts" in activity.conversation.properties + ): + message.thread_ts = activity.conversation.properties["thread_ts"] + + if message.ephemeral: + message.user = activity.recipient.id + + if ( + message.icon_url + or not (message.icons and message.icons.status_emoji) + or not message.username + ): + message.as_user = False + + return message + + @staticmethod + def response( # pylint: disable=unused-argument + req: Request, code: int, text: str = None, encoding: str = None + ) -> Response: + """ + Formats an aiohttp Response + + :param req: The original aoihttp Request + :param code: The HTTP result code to return + :param text: The text to return + :param encoding: The text encoding. Defaults to utf-8 + :return: The aoihttp Response + """ + + response = Response(status=code) + + if text: + response.content_type = "text/plain" + response.body = text.encode(encoding=encoding if encoding else "utf-8") + + return response + + @staticmethod + def payload_to_activity(payload: SlackPayload) -> Activity: + """ + Creates an activity based on the slack event payload. + + :param payload: The payload of the slack event. + :return: An activity containing the event data. + """ + + if not payload: + raise Exception("payload is required") + + activity = Activity( + channel_id="slack", + conversation=ConversationAccount(id=payload.channel.id, properties={}), + from_property=ChannelAccount( + id=payload.message.bot_id if payload.message.bot_id else payload.user.id + ), + recipient=ChannelAccount(), + channel_data=payload, + text=None, + type=ActivityTypes.event, + ) + + if payload.thread_ts: + activity.conversation.properties["thread_ts"] = payload.thread_ts + + if payload.actions and ( + payload.type == "block_actions" or payload.type == "interactive_message" + ): + activity.type = ActivityTypes.message + activity.text = payload.actions.value + + return activity + + @staticmethod + async def event_to_activity(event: SlackEvent, client: SlackClient) -> Activity: + """ + Creates an activity based on the slack event data. + + :param event: The data of the slack event. + :param client: The Slack client. + :return: An activity containing the event data. + """ + + if not event: + raise Exception("slack event is required") + + activity = Activity( + id=event.event_ts, + channel_id="slack", + conversation=ConversationAccount( + id=event.channel if event.channel else event.channel_id, properties={} + ), + from_property=ChannelAccount( + id=event.bot_id if event.bot_id else event.user_id + ), + recipient=ChannelAccount(id=None), + channel_data=event, + text=event.text, + type=ActivityTypes.event, + ) + + if event.thread_ts: + activity.conversation.properties["thread_ts"] = event.thread_ts + + if not activity.conversation.id: + if event.item and event.item_channel: + activity.conversation.id = event.item_channel + else: + activity.conversation.id = event.team + + activity.recipient.id = await client.get_bot_user_by_team(activity=activity) + + # If this is a message originating from a user, we'll mark it as such + # If this is a message from a bot (bot_id != None), we want to ignore it by + # leaving the activity type as Event. This will stop it from being included in dialogs, + # but still allow the Bot to act on it if it chooses (via ActivityHandler.on_event_activity). + # NOTE: This catches a message from ANY bot, including this bot. + # Note also, bot_id here is not the same as bot_user_id so we can't (yet) identify messages + # originating from this bot without doing an additional API call. + if event.type == "message" and not event.subtype and not event.bot_id: + activity.type = ActivityTypes.message + + return activity + + @staticmethod + async def command_to_activity( + body: SlackRequestBody, client: SlackClient + ) -> Activity: + """ + Creates an activity based on a slack event related to a slash command. + + :param body: The data of the slack event. + :param client: The Slack client. + :return: An activity containing the event data. + """ + + if not body: + raise Exception("body is required") + + activity = Activity( + id=body.trigger_id, + channel_id="slack", + conversation=ConversationAccount(id=body.channel_id, properties={}), + from_property=ChannelAccount(id=body.user_id), + recipient=ChannelAccount(id=None), + channel_data=body, + text=body.text, + type=ActivityTypes.event, + ) + + activity.recipient.id = await client.get_bot_user_by_team(activity) + activity.conversation.properties["team"] = body.team_id + + return activity + + @staticmethod + def query_string_to_dictionary(query: str) -> {}: + """ + Converts a query string to a dictionary with key-value pairs. + + :param query: The query string to convert. + :return: A dictionary with the query values. + """ + + values = {} + + if not query: + return values + + pairs = query.replace("+", "%20").split("&") + + for pair in pairs: + key_value = pair.split("=") + key = key_value[0] + value = urllib.parse.unquote(key_value[1]) + + values[key] = value + + return values + + @staticmethod + def deserialize_body(content_type: str, request_body: str) -> SlackRequestBody: + """ + Deserializes the request's body as a SlackRequestBody object. + + :param content_type: The content type of the body + :param request_body: The body of the request + :return: A SlackRequestBody object + """ + + if not request_body: + return None + + if content_type == "application/x-www-form-urlencoded": + request_dict = SlackHelper.query_string_to_dictionary(request_body) + elif content_type == "application/json": + request_dict = json.loads(request_body) + else: + raise Exception("Unknown request content type") + + if "command=%2F" in request_body: + return SlackRequestBody(**request_dict) + + if "payload=" in request_body: + payload = SlackPayload(**request_dict) + return SlackRequestBody(payload=payload, token=payload.token) + + return SlackRequestBody(**request_dict) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py new file mode 100644 index 000000000..38a7e3297 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from slack.web.classes.attachments import Attachment +from slack.web.classes.blocks import Block + + +class SlackMessage: + def __init__(self, **kwargs): + self.ephemeral = kwargs.get("ephemeral") + self.as_user = kwargs.get("as_user") + self.icon_url = kwargs.get("icon_url") + self.icon_emoji = kwargs.get("icon_emoji") + self.thread_ts = kwargs.get("thread_ts") + self.user = kwargs.get("user") + self.channel = kwargs.get("channel") + self.text = kwargs.get("text") + self.team = kwargs.get("team") + self.ts = kwargs.get("ts") # pylint: disable=invalid-name + self.username = kwargs.get("username") + self.bot_id = kwargs.get("bot_id") + self.icons = kwargs.get("icons") + self.blocks: [Block] = kwargs.get("blocks") + + self.attachments = None + if "attachments" in kwargs: + # Create proper Attachment objects + # It would appear that we can get dict fields from the wire that aren't defined + # in the Attachment class. So only pass in known fields. + self.attachments = [ + Attachment(**{x: att[x] for x in att if x in Attachment.attributes}) + for att in kwargs.get("attachments") + ] diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py new file mode 100644 index 000000000..a855ea98a --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class SlackAdapterOptions: + """ + Class for defining implementation of the SlackAdapter Options. + """ + + def __init__( + self, + slack_verification_token: str, + slack_bot_token: str, + slack_client_signing_secret: str, + ): + """ + Initializes new instance of SlackAdapterOptions + :param slack_verification_token: A token for validating the origin of incoming webhooks. + :param slack_bot_token: A token for a bot to work on a single workspace. + :param slack_client_signing_secret: The token used to validate that incoming webhooks are originated from Slack. + """ + self.slack_verification_token = slack_verification_token + self.slack_bot_token = slack_bot_token + self.slack_client_signing_secret = slack_client_signing_secret + self.slack_client_id = None + self.slack_client_secret = None + self.slack_redirect_uri = None + self.slack_scopes = [str] + + async def get_token_for_team(self, team_id: str) -> str: + """ + A method that receives a Slack team id and returns the bot token associated with that team. Required for + multi-team apps. + :param team_id:Team ID. + :return:The bot token associated with the team. + """ + raise NotImplementedError() + + async def get_bot_user_by_team(self, team_id: str) -> str: + """ + A method that receives a Slack team id and returns the bot user id associated with that team. Required for + multi-team apps. + :param team_id:Team ID. + :return:The bot user id associated with that team. + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py new file mode 100644 index 000000000..5a8fd90eb --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Optional, List + +from slack.web.classes.actions import Action + +from botbuilder.adapters.slack.slack_message import SlackMessage + + +class SlackPayload: + def __init__(self, **kwargs): + self.type: [str] = kwargs.get("type") + self.token: str = kwargs.get("token") + self.channel: str = kwargs.get("channel") + self.thread_ts: str = kwargs.get("thread_ts") + self.team: str = kwargs.get("team") + self.user: str = kwargs.get("user") + self.actions: Optional[List[Action]] = None + + if "message" in kwargs: + message = kwargs.get("message") + self.message = ( + message + if isinstance(message) is SlackMessage + else SlackMessage(**message) + ) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py new file mode 100644 index 000000000..7990555c7 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.adapters.slack.slack_event import SlackEvent +from botbuilder.adapters.slack.slack_payload import SlackPayload + + +class SlackRequestBody: + def __init__(self, **kwargs): + self.challenge = kwargs.get("challenge") + self.token = kwargs.get("token") + self.team_id = kwargs.get("team_id") + self.api_app_id = kwargs.get("api_app_id") + self.type = kwargs.get("type") + self.event_id = kwargs.get("event_id") + self.event_time = kwargs.get("event_time") + self.authed_users: [str] = kwargs.get("authed_users") + self.trigger_id = kwargs.get("trigger_id") + self.channel_id = kwargs.get("channel_id") + self.user_id = kwargs.get("user_id") + self.text = kwargs.get("text") + self.command = kwargs.get("command") + + self.payload: SlackPayload = None + if "payload" in kwargs: + payload = kwargs.get("payload") + self.payload = ( + payload + if isinstance(payload, SlackPayload) + else SlackPayload(**payload) + ) + + self.event: SlackEvent = None + if "event" in kwargs: + event = kwargs.get("event") + self.event = event if isinstance(event, SlackEvent) else SlackEvent(**event) diff --git a/libraries/botbuilder-adapters-slack/requirements.txt b/libraries/botbuilder-adapters-slack/requirements.txt new file mode 100644 index 000000000..4d6cdb67c --- /dev/null +++ b/libraries/botbuilder-adapters-slack/requirements.txt @@ -0,0 +1,4 @@ +aiohttp +pyslack +botbuilder-core>=4.7.1 +slackclient \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.cfg b/libraries/botbuilder-adapters-slack/setup.cfg new file mode 100644 index 000000000..57e1947c4 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py new file mode 100644 index 000000000..d154572f2 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "botbuilder-schema>=4.7.0", + "botframework-connector>=4.7.0", + "botbuilder-core>=4.7.0", +] + +TEST_REQUIRES = ["aiounittest==1.3.0"] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "botbuilder", "adapters", "slack", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords=["BotBuilderAdapters", "bots", "ai", "botframework", "botbuilder"], + long_description=long_description, + long_description_content_type="text/x-rst", + license=package_info["__license__"], + packages=["botbuilder.adapters", "botbuilder.adapters.slack",], + install_requires=REQUIRES + TEST_REQUIRES, + tests_require=TEST_REQUIRES, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.7", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) From 18051639334aac17c3a2879316ebd0f3a3b9273a Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 7 Feb 2020 15:08:28 -0800 Subject: [PATCH 219/616] Updated dialog_turn_result.py --- .../botbuilder/dialogs/dialog_turn_result.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 706dcc757..4dcc39e55 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -7,9 +7,6 @@ class DialogTurnResult: """ Result returned to the caller of one of the various stack manipulation methods. - - Use :meth:`DialogContext.end_dialog()` to end a :class:`Dialog` and - return a result to the calling context. """ def __init__(self, status: DialogTurnStatus, result: object = None): @@ -37,14 +34,7 @@ def result(self): """ Final result returned by a dialog that just completed. - .. remarks:: - This will only be populated in certain cases: - * The bot calls :meth:`DialogContext.begin_dialog()` to start a new - dialog and the dialog ends immediately. - * The bot calls :meth:`DialogContext.continue_dialog()` and a dialog - that was active ends. - - :return self._result: Final result returned by a dialog that just completed. - :rtype self._result: object - """ + :return self._result: Final result returned by a dialog that just completed. + :rtype self._result: object + """ return self._result From 4ea960eaa2be075047aae1475c43f032521e7a11 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 7 Feb 2020 15:35:15 -0800 Subject: [PATCH 220/616] Fixed method reference --- .../botbuilder/dialogs/dialog_reason.py | 12 ++++++------ .../botbuilder/dialogs/dialog_turn_status.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index 7c12e3032..471646e84 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -7,18 +7,18 @@ class DialogReason(Enum): """ Indicates in which a dialog-related method is being called. - :var BeginCalled: A dialog is being started through a call to `DialogContext.begin()`. + :var BeginCalled: A dialog is being started through a call to :meth:`DialogContext.begin()`. :vartype BeginCalled: int - :var ContinueCalled: A dialog is being continued through a call to `DialogContext.continue_dialog()`. + :var ContinueCalled: A dialog is being continued through a call to :meth:`DialogContext.continue_dialog()`. :vartype ContinueCalled: int - :var EndCalled: A dialog ended normally through a call to `DialogContext.end_dialog() + :var EndCalled: A dialog ended normally through a call to :meth:`DialogContext.end_dialog() :vartype EndCalled: int :var ReplaceCalled: A dialog is ending because it's being replaced through a call to - `DialogContext.replace_dialog()`. + :meth:``DialogContext.replace_dialog()`. :vartype ReplacedCalled: int - :var CancelCalled: A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. + :var CancelCalled: A dialog was cancelled as part of a call to :meth:`DialogContext.cancel_all_dialogs()`. :vartype CancelCalled: int - :var NextCalled: A preceding step was skipped through a call to `WaterfallStepContext.next()`. + :var NextCalled: A preceding step was skipped through a call to :meth:`WaterfallStepContext.next()`. :vartype NextCalled: int """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py index b88cd359b..6d8b61e51 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -5,7 +5,7 @@ class DialogTurnStatus(Enum): """ - Codes indicating the state of the dialog stack after a call to `DialogContext.continueDialog()` + Indicates in which a dialog-related method is being called. :var Empty: Indicates that there is currently nothing on the dialog stack. :vartype Empty: int From 10ece94dcec132472e33d765ab09ea824487eb5d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 7 Feb 2020 15:48:24 -0800 Subject: [PATCH 221/616] Update dialog_state.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 5306540f8..0c20b47c0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -13,7 +13,9 @@ class DialogState: def __init__(self, stack: List[DialogInstance] = None): """ Initializes a new instance of the :class:`DialogState` class. - The new instance is created with an empty dialog stack. + + .. remarks:: + The new instance is created with an empty dialog stack. :param stack: The state information to initialize the stack with. :type stack: :class:`typing.List[:class:`DialogInstance`]` @@ -29,7 +31,7 @@ def dialog_stack(self): Initializes a new instance of the :class:`DialogState` class. :return: The state information to initialize the stack with. - :rtype: list + :rtype: :class:`typing.List` """ return self._dialog_stack From f61f6254515c52296737ecbad5d41677576ffce4 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 7 Feb 2020 16:41:00 -0800 Subject: [PATCH 222/616] Update dialog_state.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 0c20b47c0..838294598 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -12,10 +12,7 @@ class DialogState: def __init__(self, stack: List[DialogInstance] = None): """ - Initializes a new instance of the :class:`DialogState` class. - - .. remarks:: - The new instance is created with an empty dialog stack. + Initializes a new instance of the :class:`DialogState` class. The new instance is created with an empty dialog stack. :param stack: The state information to initialize the stack with. :type stack: :class:`typing.List[:class:`DialogInstance`]` From b39d5269de487791a4fc481420051dac334f5988 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 7 Feb 2020 16:48:27 -0800 Subject: [PATCH 223/616] Update dialog_state.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 838294598..5306540f8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -12,7 +12,8 @@ class DialogState: def __init__(self, stack: List[DialogInstance] = None): """ - Initializes a new instance of the :class:`DialogState` class. The new instance is created with an empty dialog stack. + Initializes a new instance of the :class:`DialogState` class. + The new instance is created with an empty dialog stack. :param stack: The state information to initialize the stack with. :type stack: :class:`typing.List[:class:`DialogInstance`]` @@ -28,7 +29,7 @@ def dialog_stack(self): Initializes a new instance of the :class:`DialogState` class. :return: The state information to initialize the stack with. - :rtype: :class:`typing.List` + :rtype: list """ return self._dialog_stack From bf4ac24a63a49ea37ca37ed4aeec9f67ce54a575 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 7 Feb 2020 16:54:20 -0800 Subject: [PATCH 224/616] Update dialog_state.py --- libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 5306540f8..6f2a371f1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -29,7 +29,7 @@ def dialog_stack(self): Initializes a new instance of the :class:`DialogState` class. :return: The state information to initialize the stack with. - :rtype: list + :rtype: :class:`typing.List[:class:`DialogInstance`]` """ return self._dialog_stack From 0b76cb132235ed994d90c94ee183574d694729c1 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 09:23:12 -0800 Subject: [PATCH 225/616] Update dialog_state.py --- libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 6f2a371f1..a00c78701 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -13,7 +13,6 @@ class DialogState: def __init__(self, stack: List[DialogInstance] = None): """ Initializes a new instance of the :class:`DialogState` class. - The new instance is created with an empty dialog stack. :param stack: The state information to initialize the stack with. :type stack: :class:`typing.List[:class:`DialogInstance`]` From c6279af232df1aed259fd61e3d8e687580a50931 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 10:29:09 -0800 Subject: [PATCH 226/616] Update conversation_state.py Fix style --- libraries/botbuilder-core/botbuilder/core/conversation_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index ffbec86b2..4605700f6 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -32,7 +32,7 @@ def get_storage_key(self, turn_context: TurnContext) -> object: Gets the key to use when reading and writing state to and from storage. :param turn_context: The context object for this turn. - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :raise: :class:`TypeError` if the :meth:`TurnContext.activity` for the current turn is missing :class:`botbuilder.schema.Activity` channelId or conversation information or the conversation's From d2869abb22969fd14c5c72d36adbaa8e6c56d807 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 10:29:31 -0800 Subject: [PATCH 227/616] Update bot_state.py Fix style --- .../botbuilder/core/bot_state.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 7d429d9df..3c6b79329 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -63,7 +63,7 @@ def __init__(self, storage: Storage, context_service_key: str): def create_property(self, name: str) -> StatePropertyAccessor: """ - Create a property definition and register it with this :class:`BotState`. + Creates a property definition and registers it with this :class:`BotState`. :param name: The name of the property :type name: str @@ -84,7 +84,7 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: Reads the current state object and caches it in the context object for this turn. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :param force: Optional, true to bypass the cache :type force: bool """ @@ -107,7 +107,7 @@ async def save_changes( If the state has changed, it saves the state cached in the current context for this turn. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :param force: Optional, true to save state to storage whether or not there are changes :type force: bool """ @@ -127,7 +127,7 @@ async def clear_state(self, turn_context: TurnContext): Clears any state currently stored in this state scope. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :return: None @@ -147,7 +147,7 @@ async def delete(self, turn_context: TurnContext) -> None: Deletes any state currently stored in this state scope. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :return: None """ @@ -168,7 +168,7 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str Gets the value of the specified property in the turn context. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :param property_name: The property name :type property_name: str @@ -195,7 +195,7 @@ async def delete_property_value( Deletes a property from the state cache in the turn context. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :TurnContext` :param property_name: The name of the property to delete :type property_name: str @@ -215,7 +215,7 @@ async def set_property_value( Sets a property to the specified value in the turn context. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :param property_name: The property name :type property_name: str :param value: The value to assign to the property @@ -252,10 +252,7 @@ def __init__(self, bot_state: BotState, name: str): @property def name(self) -> str: """ - Gets the name of the property. - - :return: The name of the property - :rtype: str + The name of the property. """ return self._name @@ -264,7 +261,7 @@ async def delete(self, turn_context: TurnContext) -> None: Deletes the property. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` """ await self._bot_state.load(turn_context, False) await self._bot_state.delete_property_value(turn_context, self._name) @@ -278,7 +275,7 @@ async def get( Gets the property value. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :param default_value_or_factory: Defines the default value for the property """ await self._bot_state.load(turn_context, False) @@ -303,7 +300,7 @@ async def set(self, turn_context: TurnContext, value: object) -> None: Sets the property value. :param turn_context: The context object for this turn - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`TurnContext` :param value: The value to assign to the property """ From 6c90ffba0863102829965d1b2dc952299e85b12d Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 10:47:52 -0800 Subject: [PATCH 228/616] Update prompt.py Fixed style --- .../botbuilder/dialogs/prompts/prompt.py | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index d0ab57084..268ede9ab 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -24,15 +24,17 @@ class Prompt(Dialog): """ + Defines the core behavior of prompt dialogs. Extends the :class:`Dialog` base class. .. remarks:: When the prompt ends, it returns an object that represents the value it was prompted for. - Use :meth:`DialogSet.add()` or :meth:`ComponentDialog.add_dialog()` to add a prompt to a dialog set or - component dialog, respectively. + Use :meth:`DialogSet.add()` or :meth:`ComponentDialog.add_dialog()` to add a prompt to + a dialog set or component dialog, respectively. + Use :meth:`DialogContext.prompt()` or :meth:`DialogContext.begin_dialog()` to start the prompt. - If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the prompt result - will be available in the next step of the waterfall. + If you start a prompt from a :class:`WaterfallStep` in a :class:`WaterfallDialog`, then the + prompt result will be available in the next step of the waterfall. """ ATTEMPT_COUNT_KEY = "AttemptCount" @@ -43,11 +45,10 @@ def __init__(self, dialog_id: str, validator: object = None): """ Creates a new :class:`Prompt` instance. - :param dialog_id: Unique Id of the prompt within its parent :class:`DialogSet` or - :class:`ComponentDialog`. + :param dialog_id: Unique Id of the prompt within its parent :class:`DialogSet` + :class:`ComponentDialog` :type dialog_id: str - :param validator: Optional custom validator used to provide additional validation and re-prompting - logic for the prompt. + :param validator: Optionally provide additional validation and re-prompting logic :type validator: Object """ super(Prompt, self).__init__(dialog_id) @@ -68,8 +69,7 @@ async def begin_dialog( :rtype: :class:`DialogTurnResult` .. note:: - If the task is successful, the result indicates whether the prompt is still active - after the turn has been processed. + The result indicates whether the prompt is still active after the turn has been processed. """ if not dialog_context: raise TypeError("Prompt(): dc cannot be None.") @@ -99,7 +99,7 @@ async def begin_dialog( async def continue_dialog(self, dialog_context: DialogContext): """ - Continues a dialog. Called when a prompt dialog is the active dialog and the user replied with a new activity. + Continues a dialog. :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` @@ -107,8 +107,11 @@ async def continue_dialog(self, dialog_context: DialogContext): :rtype: :class:`DialogTurnResult` .. remarks:: - If the task is successful, the result indicates whether the dialog is still - active after the turn has been processed by the dialog. + Called when a prompt dialog is the active dialog and the user replied with a new activity. + + If the task is successful, the result indicates whether the dialog is still active after + the turn has been processed by the dialog. + The prompt generally continues to receive the user's replies until it accepts the user's reply as valid input for the prompt. """ @@ -150,13 +153,13 @@ async def resume_dialog( self, dialog_context: DialogContext, reason: DialogReason, result: object ) -> DialogTurnResult: """ - Resumes a dialog. C + Resumes a dialog. + :param dialog_context: The dialog context for the current turn of the conversation. - :type dialog_context: :class:DialogContext + :type dialog_context: :class:`DialogContext` :param reason: An enum indicating why the dialog resumed. - :type reason: :class:DialogReason + :type reason: :class:`DialogReason` :param result: Optional, value returned from the previous dialog on the stack. - The type of the value returned is dependent on the previous dialog. :type result: object :return: The dialog turn result :rtype: :class:`DialogTurnResult` @@ -164,26 +167,30 @@ async def resume_dialog( .. remarks:: Called when a prompt dialog resumes being the active dialog on the dialog stack, such as when the previous active dialog on the stack completes. + If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. + Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs on top of the stack which will result in the prompt receiving an unexpected call to :meth:resume_dialog() when the pushed on dialog ends. - To avoid the prompt prematurely ending we need to simply re-prompt the user. + + Simply re-prompt the user to avoid that the prompt ends prematurely. + """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) return Dialog.end_of_turn async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): """ - Reprompts user for input. Called when a prompt dialog has been requested to re-prompt the user for input. + Reprompts user for input. :param context: Context for the current turn of conversation with the user - :type context: :class:TurnContext + :type context: :class:`botbuilder.core.TurnContext` :param instance: The instance of the dialog on the stack - :type instance: :class:DialogInstance - :return: A :class:Task representing the asynchronous operation - :rtype: :class:Task + :type instance: :class:`DialogInstance` + :return: A task representing the asynchronous operation + """ state = instance.state[self.persisted_state] options = instance.state[self.persisted_options] @@ -203,16 +210,15 @@ async def on_prompt( :param turn_context: Context for the current turn of conversation with the user :type turn_context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack - :type state: :class:Dict + :type state: :class:`Dict` :param options: A prompt options object constructed from the options initially provided - in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` + in the call :meth:`DialogContext.prompt()` :type options: :class:`PromptOptions` - :param is_retry: true if this is the first time this prompt dialog instance on the stack is prompting - the user for input; otherwise, false + :param is_retry: true if is the first time the user for input; otherwise, false :type is_retry: bool - :return: A :class:Task representing the asynchronous operation. - :rtype: :class:Task + :return: A task representing the asynchronous operation. + """ @abstractmethod @@ -223,18 +229,20 @@ async def on_recognize( options: PromptOptions, ): """ - Recognizes the user's input. When overridden in a derived class, attempts to recognize the user's input. + Recognizes the user's input. :param turn_context: Context for the current turn of conversation with the user :type turn_context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack - :type state: :class:Dict + :type state: :class:`Dict` :param options: A prompt options object constructed from the options initially provided - in the call :meth:`DialogContext.prompt(self, dialog_id: str, options)` - :type options: :class:PromptOptions + in the call to :meth:`DialogContext.prompt()` + :type options: :class:`PromptOptions` :return: A task representing the asynchronous operation. - :rtype: :class:Task + + .. note:: + When overridden in a derived class, attempts to recognize the user's input. """ def append_choices( @@ -258,14 +266,15 @@ def append_choices( :type style: :class:`ListStyle` :param: options: Optional formatting options to use when presenting the choices :type style: :class:`ChoiceFactoryOptions` - :return: A :class:Task representing the asynchronous operation - :rtype: :class:Task + + :return: A task representing the asynchronous operation .. remarks:: If the task is successful, the result contains the updated activity. When overridden in a derived class, appends choices to the activity when the user is prompted for input. This is an helper function to compose an output activity containing a set of choices. + """ # Get base prompt text (if any) text = prompt.text if prompt is not None and prompt.text else "" From f9bd8fc145ab47746f7cd82ff2d69daf08979b1f Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 10:59:48 -0800 Subject: [PATCH 229/616] Update oauth_prompt.py Fixed style --- .../dialogs/prompts/oauth_prompt.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index d593bfb2d..a8cd05048 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -74,11 +74,10 @@ def __init__( :type dialogId: str :param settings: Additional authentication settings to use with this instance of the prompt :type settings: :class:`OAuthPromptSettings` - :param validator: Optional, a :class:`PromptValidator` that contains additional, custom validation - for this prompt + :param validator: Optional, contains additional, custom validation for this prompt :type validator: :class:`PromptValidatorContext` - .. note:: + .. remarks:: The value of :param dialogId: must be unique within the :class:`DialogSet`or :class:`ComponentDialog` to which the prompt is added. """ @@ -103,11 +102,13 @@ async def begin_dialog( :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` :param options: Optional, additional information to pass to the prompt being started - :type options: :class:PromptOptions + :type options: :class:`PromptOptions` + :return: Dialog turn result - :rtype: :class:DialogTurnResult + :rtype: :class`:`DialogTurnResult` + + .. remarks:: - .. note:: If the task is successful, the result indicates whether the prompt is still active after the turn has been processed. """ @@ -157,8 +158,9 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :param dialog_context: The dialog context for the current turn of the conversation :type dialog_context: :class:`DialogContext` + :return: Dialog turn result - :rtype: :class:DialogTurnResult + :rtype: :class:`DialogTurnResult` .. remarks:: If the task is successful, the result indicates whether the dialog is still @@ -217,11 +219,12 @@ async def get_user_token( Gets the user's tokeN. :param context: Context for the current turn of conversation with the user - :type context: :class:TurnContext + :type context: :class:`TurnContext` + :return: A response that includes the user's token - :rtype: :class:TokenResponse + :rtype: :class:`TokenResponse` - .. note:: + .. remarks:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ @@ -242,11 +245,10 @@ async def sign_out_user(self, context: TurnContext): Signs out the user :param context: Context for the current turn of conversation with the user - :type context: :class:`botbuilder.core.TurnContext` - :return: A :class:`Task` representing the work queued to execute - :rtype: :class:`Task` + :type context: :class:`TurnContext` + :return: A task representing the work queued to execute - .. note:: + .. reamarks:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ From a47cc3dfc75aa42190b0601fc6cea2480ffb9111 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 11:28:36 -0800 Subject: [PATCH 230/616] Update activity_handler.py Fixed style --- .../botbuilder/core/activity_handler.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index cbe65ed73..2c14ef13d 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -79,7 +79,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this + When the :meth:`ActivityHandler.on_turn()` method receives a conversation update activity, it calls this method. If the conversation update activity indicates that members other than the bot joined the conversation, it calls the :meth:`ActivityHandler.on_members_added_activity()` method. @@ -120,7 +120,7 @@ async def on_members_added_activity( :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation + When the :meth:`ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. """ @@ -142,7 +142,7 @@ async def on_members_removed_activity( :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation + When the :meth:`ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are leaving the conversation, it calls this method. """ @@ -152,7 +152,7 @@ async def on_members_removed_activity( async def on_message_reaction_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of - :meth:'ActivityHandler.on_turn()` is used. + :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -165,12 +165,12 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response from a send call. - When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this + When the :meth:`ActivityHandler.on_turn()` method receives a message reaction activity, it calls this method. If the message reaction indicates that reactions were added to a message, it calls - :meth:'ActivityHandler.on_reaction_added(). + :meth:`ActivityHandler.on_reaction_added()`. If the message reaction indicates that reactions were removed from a message, it calls - :meth:'ActivityHandler.on_reaction_removed(). + :meth:`ActivityHandler.on_reaction_removed()`. In a derived class, override this method to add logic that applies to all message reaction activities. Add logic to apply before the reactions added or removed logic before the call to the this base class method. @@ -235,7 +235,7 @@ async def on_reactions_removed( # pylint: disable=unused-argument async def on_event_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of - :meth:'ActivityHandler.on_turn()` is used. + :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -243,9 +243,9 @@ async def on_event_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. - If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; - otherwise, it calls :meth:'ActivityHandler.on_event()`. + When the :meth:`ActivityHandler.on_turn()` method receives an event activity, it calls this method. + If the activity name is `tokens/response`, it calls :meth:`ActivityHandler.on_token_response_event()`; + otherwise, it calls :meth:`ActivityHandler.on_event()`. In a derived class, override this method to add logic that applies to all event activities. Add logic to apply before the specific event-handling logic before the call to this base class method. @@ -265,7 +265,7 @@ async def on_token_response_event( # pylint: disable=unused-argument ): """ Invoked when a `tokens/response` event is received when the base behavior of - :meth:'ActivityHandler.on_event_activity()` is used. + :meth:`ActivityHandler.on_event_activity()` is used. If using an `oauth_prompt`, override this method to forward this activity to the current dialog. :param turn_context: The context object for this turn @@ -274,7 +274,7 @@ async def on_token_response_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of + When the :meth:`ActivityHandler.on_event()` method receives an event with an activity name of `tokens/response`, it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. """ @@ -285,7 +285,7 @@ async def on_event( # pylint: disable=unused-argument ): """ Invoked when an event other than `tokens/response` is received when the base behavior of - :meth:'ActivityHandler.on_event_activity()` is used. + :meth:`ActivityHandler.on_event_activity()` is used. :param turn_context: The context object for this turn @@ -294,7 +294,7 @@ async def on_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an + When the :meth:`ActivityHandler.on_event_activity()` is used method receives an event with an activity name other than `tokens/response`, it calls this method. This method could optionally be overridden if the bot is meant to handle miscellaneous events. """ From 627afcc4aa63e58da682422d5f6feb9bdb7b4672 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 11:43:07 -0800 Subject: [PATCH 231/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index c98de80f9..6e27090bd 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -130,8 +130,7 @@ async def resume_dialog( :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` - :param result: Optional, value returned from the dialog that was called. The type of the - value returned is dependent on the child dialog. + :param result: Optional, value returned from the dialog that was called. :type result: object :return: Signals the end of the turn :rtype: :class:`Dialog.end_of_turn` @@ -167,8 +166,7 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` - :param instance: State information associated with the instance of this component dialog - on its parent's dialog stack. + :param instance: State information associated with the instance of this component dialog. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` @@ -242,8 +240,7 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.core.TurnContext` - :param instance: State information associated with the instance of this component dialog on - its parent's dialog stack. + :param instance: State information associated with the instance of this component dialog. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` From fab3f007c2d0ec9fcb9dcdc31faed242d5aa5248 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 11:50:46 -0800 Subject: [PATCH 232/616] Update activity_handler.py Fixed style --- .../botbuilder/core/activity_handler.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 2c14ef13d..b5f39b947 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -7,10 +7,18 @@ class ActivityHandler: + """ + Class to handle actviities and intended for subclassing. + + .. remarks:: + Derive from this class to handle particular activity types. + Pre- and post-processing of activities can be added by calling the base class implementation + from the derived class. + """ async def on_turn(self, turn_context: TurnContext): + """ - Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime - in order to process an inbound :class:`botbuilder.schema.Activity`. + Called by the adapter at runtime to process an inbound :class:`botbuilder.schema.Activity`. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -22,9 +30,10 @@ async def on_turn(self, turn_context: TurnContext): process, which allows a derived class to provide type-specific logic in a controlled way. In a derived class, override this method to add logic that applies to all activity types. Also - - Add logic to apply before the type-specific logic and before calling :meth:`ActivityHandler.on_turn()`. - - Add logic to apply after the type-specific logic after calling :meth:`ActivityHandler.on_turn()`. + - Add logic to apply before the type-specific logic and before calling :meth:`on_turn()`. + - Add logic to apply after the type-specific logic after calling :meth:`on_turn()`. """ + if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -71,7 +80,7 @@ async def on_message_activity( # pylint: disable=unused-argument async def on_conversation_update_activity(self, turn_context: TurnContext): """ Invoked when a conversation update activity is received from the channel when the base behavior of - :meth:`ActivityHandler.on_turn()` is used. + :meth:`on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -79,13 +88,14 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_turn()` method receives a conversation update activity, it calls this - method. - If the conversation update activity indicates that members other than the bot joined the conversation, - it calls the :meth:`ActivityHandler.on_members_added_activity()` method. - If the conversation update activity indicates that members other than the bot left the conversation, - it calls the :meth:`ActivityHandler.on_members_removed_activity()` method. - In a derived class, override this method to add logic that applies to all conversation update activities. + When the :meth:`on_turn()` method receives a conversation update activity, it calls this + method. Note the following: + + - If the conversation update activity indicates that members other than the bot joined the conversation, + it calls the :meth:`on_members_added_activity()` method. + - If the conversation update activity indicates that members other than the bot left the conversation, + it calls the :meth:`on_members_removed_activity()` method. + - In a derived class, override this method to add logic that applies to all conversation update activities. Add logic to apply before the member added or removed logic before the call to this base class method. """ if ( From 4f90d22b1234cc8910919620e4b55d0926456423 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:02:55 -0800 Subject: [PATCH 233/616] Update activity_handler.py Fixing style --- .../botbuilder/core/activity_handler.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index b5f39b947..f49964ed8 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -7,10 +7,12 @@ class ActivityHandler: + """ - Class to handle actviities and intended for subclassing. + Handles actviities and intended for subclassing. .. remarks:: + Derive from this class to handle particular activity types. Pre- and post-processing of activities can be added by calling the base class implementation from the derived class. @@ -79,7 +81,7 @@ async def on_message_activity( # pylint: disable=unused-argument async def on_conversation_update_activity(self, turn_context: TurnContext): """ - Invoked when a conversation update activity is received from the channel when the base behavior of + Called when a conversation update activity is received from the channel when the base behavior of :meth:`on_turn()` is used. :param turn_context: The context object for this turn @@ -88,15 +90,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`on_turn()` method receives a conversation update activity, it calls this - method. Note the following: - - - If the conversation update activity indicates that members other than the bot joined the conversation, - it calls the :meth:`on_members_added_activity()` method. - - If the conversation update activity indicates that members other than the bot left the conversation, - it calls the :meth:`on_members_removed_activity()` method. - - In a derived class, override this method to add logic that applies to all conversation update activities. - Add logic to apply before the member added or removed logic before the call to this base class method. + When the :meth:`on_turn()` method receives a conversation update activity, it calls this method. """ if ( turn_context.activity.members_added is not None @@ -114,6 +108,8 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): ) return + # Stop here + async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument From e2f19ed35a0ba44d031e4a56c9a83c0cc781b4ee Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 12:03:47 -0800 Subject: [PATCH 234/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 6e27090bd..8aa4c18de 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -132,8 +132,8 @@ async def resume_dialog( :type reason: :class:`DialogReason` :param result: Optional, value returned from the dialog that was called. :type result: object - :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :return Dialog.end_of_turn: Signals the end of the turn + :rtype Dialog.end_of_turn: :class:`Dialog.end_of_turn` """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) @@ -183,8 +183,9 @@ def add_dialog(self, dialog: Dialog) -> object: Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :return: The updated :class:`ComponentDialog` - :rtype: :class:`ComponentDialog` + :type dialog: :class:`Dialog` + :return self: The updated :class:`ComponentDialog` + :rtype self: :class:`ComponentDialog` """ self._dialogs.add(dialog) if not self.initial_dialog_id: @@ -196,8 +197,9 @@ def find_dialog(self, dialog_id: str) -> Dialog: Finds a dialog by ID. :param dialog_id: The dialog to add. + :type dialog_id: str :return: The dialog; or None if there is not a match for the ID. - :rtype: :class:Dialog + :rtype: :class:`Dialog` """ return self._dialogs.find(dialog_id) @@ -253,8 +255,7 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`DialogInstance` - :param instance: State information associated with the instance of this component dialog - on its parent's dialog stack. + :param instance: State information associated with the instance of this component dialog. :type instance: :class:`DialogInstance` """ return @@ -278,8 +279,8 @@ async def end_component( The returned :class:`DialogTurnResult`contains the return value in its :class:`DialogTurnResult.result` property. - :param outer_dc: The parent class:`DialogContext` for the current turn of conversation. - :type outer_dc: class:`DialogContext` + :param outer_dc: The parent :class:`DialogContext` for the current turn of conversation. + :type outer_dc: :class:`DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return: Value to return. From c3deeb61e20b88efdafa3cc27dbc2dc394b2d8ed Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:15:05 -0800 Subject: [PATCH 235/616] Update activity_handler.py Fixing style --- .../botbuilder-core/botbuilder/core/activity_handler.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index f49964ed8..37c9659ed 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -9,13 +9,7 @@ class ActivityHandler: """ - Handles actviities and intended for subclassing. - - .. remarks:: - - Derive from this class to handle particular activity types. - Pre- and post-processing of activities can be added by calling the base class implementation - from the derived class. + Handles actviities and intended for subclassing. """ async def on_turn(self, turn_context: TurnContext): From ce75b0b795147cc7c110c569bb54544e24ea4296 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:23:45 -0800 Subject: [PATCH 236/616] Update activity_handler.py Fixing style --- .../botbuilder/core/activity_handler.py | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 37c9659ed..a4fc89923 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -19,15 +19,8 @@ async def on_turn(self, turn_context: TurnContext): :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute - .. remarks:: - It calls other methods in this class based on the type of the activity to - process, which allows a derived class to provide type-specific logic in a controlled way. - In a derived class, override this method to add logic that applies to all activity types. - Also - - Add logic to apply before the type-specific logic and before calling :meth:`on_turn()`. - - Add logic to apply after the type-specific logic after calling :meth:`on_turn()`. """ if turn_context is None: @@ -69,7 +62,7 @@ async def on_message_activity( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute """ return @@ -81,7 +74,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: When the :meth:`on_turn()` method receives a conversation update activity, it calls this method. @@ -139,7 +132,7 @@ async def on_members_removed_activity( :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: When the :meth:`ActivityHandler.on_conversation_update_activity()` method receives a conversation @@ -157,7 +150,7 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously @@ -198,7 +191,7 @@ async def on_reactions_added( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) @@ -221,7 +214,7 @@ async def on_reactions_removed( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) @@ -240,7 +233,7 @@ async def on_event_activity(self, turn_context: TurnContext): :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: When the :meth:`ActivityHandler.on_turn()` method receives an event activity, it calls this method. @@ -271,7 +264,7 @@ async def on_token_response_event( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: When the :meth:`ActivityHandler.on_event()` method receives an event with an activity name of @@ -308,7 +301,7 @@ async def on_end_of_conversation_activity( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute """ return @@ -323,7 +316,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :returns: A task that represents the work queued to execute + :return: A task that represents the work queued to execute .. remarks:: When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, From 6cb30cbf1f3857fd3680e462cea6a82642320c0e Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 12:23:57 -0800 Subject: [PATCH 237/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 8aa4c18de..190061423 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -15,7 +15,7 @@ class ComponentDialog(Dialog): """ - A :class:`Dialog` that is composed of other dialogs + A :class:`botbuilder.dialogs.Dialog` that is composed of other dialogs A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, which provides an inner dialog stack that is hidden from the parent dialog. @@ -97,7 +97,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :rtype: :var:`Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -198,8 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The dialog; or None if there is not a match for the ID. - :rtype: :class:`Dialog` + :return: The :class:`Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) @@ -283,7 +282,7 @@ async def end_component( :type outer_dc: :class:`DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object - :return: Value to return. - :rtype: :class:`DialogTurnResult.result` + :return : Value to return. + :rtype: :var:`DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From 941ae4bafb8f92126a476437a2586140c89ff64c Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:29:35 -0800 Subject: [PATCH 238/616] Update activity_handler.py Fixing style issues --- .../botbuilder/core/activity_handler.py | 81 ++++++++++--------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index a4fc89923..cbe65ed73 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -7,22 +7,24 @@ class ActivityHandler: - - """ - Handles actviities and intended for subclassing. - """ async def on_turn(self, turn_context: TurnContext): - """ - Called by the adapter at runtime to process an inbound :class:`botbuilder.schema.Activity`. + Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime + in order to process an inbound :class:`botbuilder.schema.Activity`. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute + .. remarks:: + It calls other methods in this class based on the type of the activity to + process, which allows a derived class to provide type-specific logic in a controlled way. + In a derived class, override this method to add logic that applies to all activity types. + Also + - Add logic to apply before the type-specific logic and before calling :meth:`ActivityHandler.on_turn()`. + - Add logic to apply after the type-specific logic after calling :meth:`ActivityHandler.on_turn()`. """ - if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -62,22 +64,29 @@ async def on_message_activity( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute """ return async def on_conversation_update_activity(self, turn_context: TurnContext): """ - Called when a conversation update activity is received from the channel when the base behavior of - :meth:`on_turn()` is used. + Invoked when a conversation update activity is received from the channel when the base behavior of + :meth:`ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`on_turn()` method receives a conversation update activity, it calls this method. + When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this + method. + If the conversation update activity indicates that members other than the bot joined the conversation, + it calls the :meth:`ActivityHandler.on_members_added_activity()` method. + If the conversation update activity indicates that members other than the bot left the conversation, + it calls the :meth:`ActivityHandler.on_members_removed_activity()` method. + In a derived class, override this method to add logic that applies to all conversation update activities. + Add logic to apply before the member added or removed logic before the call to this base class method. """ if ( turn_context.activity.members_added is not None @@ -95,8 +104,6 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): ) return - # Stop here - async def on_members_added_activity( self, members_added: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument @@ -113,7 +120,7 @@ async def on_members_added_activity( :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_conversation_update_activity()` method receives a conversation + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. """ @@ -132,10 +139,10 @@ async def on_members_removed_activity( :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_conversation_update_activity()` method receives a conversation + When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are leaving the conversation, it calls this method. """ @@ -145,12 +152,12 @@ async def on_members_removed_activity( async def on_message_reaction_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of - :meth:`ActivityHandler.on_turn()` is used. + :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously @@ -158,12 +165,12 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response from a send call. - When the :meth:`ActivityHandler.on_turn()` method receives a message reaction activity, it calls this + When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this method. If the message reaction indicates that reactions were added to a message, it calls - :meth:`ActivityHandler.on_reaction_added()`. + :meth:'ActivityHandler.on_reaction_added(). If the message reaction indicates that reactions were removed from a message, it calls - :meth:`ActivityHandler.on_reaction_removed()`. + :meth:'ActivityHandler.on_reaction_removed(). In a derived class, override this method to add logic that applies to all message reaction activities. Add logic to apply before the reactions added or removed logic before the call to the this base class method. @@ -191,7 +198,7 @@ async def on_reactions_added( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) @@ -214,7 +221,7 @@ async def on_reactions_removed( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) @@ -228,17 +235,17 @@ async def on_reactions_removed( # pylint: disable=unused-argument async def on_event_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of - :meth:`ActivityHandler.on_turn()` is used. + :meth:'ActivityHandler.on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_turn()` method receives an event activity, it calls this method. - If the activity name is `tokens/response`, it calls :meth:`ActivityHandler.on_token_response_event()`; - otherwise, it calls :meth:`ActivityHandler.on_event()`. + When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. + If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; + otherwise, it calls :meth:'ActivityHandler.on_event()`. In a derived class, override this method to add logic that applies to all event activities. Add logic to apply before the specific event-handling logic before the call to this base class method. @@ -258,16 +265,16 @@ async def on_token_response_event( # pylint: disable=unused-argument ): """ Invoked when a `tokens/response` event is received when the base behavior of - :meth:`ActivityHandler.on_event_activity()` is used. + :meth:'ActivityHandler.on_event_activity()` is used. If using an `oauth_prompt`, override this method to forward this activity to the current dialog. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_event()` method receives an event with an activity name of + When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of `tokens/response`, it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. """ @@ -278,7 +285,7 @@ async def on_event( # pylint: disable=unused-argument ): """ Invoked when an event other than `tokens/response` is received when the base behavior of - :meth:`ActivityHandler.on_event_activity()` is used. + :meth:'ActivityHandler.on_event_activity()` is used. :param turn_context: The context object for this turn @@ -287,7 +294,7 @@ async def on_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_event_activity()` is used method receives an event with an + When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an activity name other than `tokens/response`, it calls this method. This method could optionally be overridden if the bot is meant to handle miscellaneous events. """ @@ -301,7 +308,7 @@ async def on_end_of_conversation_activity( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute """ return @@ -316,7 +323,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` - :return: A task that represents the work queued to execute + :returns: A task that represents the work queued to execute .. remarks:: When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, From e8f591861648a1fd5d3b0a3656f588d257d8fe4b Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:42:35 -0800 Subject: [PATCH 239/616] Update activity_handler.py Fixing style --- .../botbuilder/core/activity_handler.py | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index cbe65ed73..e07aa4e52 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -7,6 +7,15 @@ class ActivityHandler: + """ + Handles activities and should be subclassed. + + .. remarks:: + Derive from this class to handle particular activity types. + Yon can add pre and post processing of activities by calling the base class + in the derived class. + """ + async def on_turn(self, turn_context: TurnContext): """ Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime @@ -22,8 +31,8 @@ async def on_turn(self, turn_context: TurnContext): process, which allows a derived class to provide type-specific logic in a controlled way. In a derived class, override this method to add logic that applies to all activity types. Also - - Add logic to apply before the type-specific logic and before calling :meth:`ActivityHandler.on_turn()`. - - Add logic to apply after the type-specific logic after calling :meth:`ActivityHandler.on_turn()`. + - Add logic to apply before the type-specific logic and before calling :meth:`on_turn()`. + - Add logic to apply after the type-specific logic after calling :meth:`on_turn()`. """ if turn_context is None: raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") @@ -71,7 +80,7 @@ async def on_message_activity( # pylint: disable=unused-argument async def on_conversation_update_activity(self, turn_context: TurnContext): """ Invoked when a conversation update activity is received from the channel when the base behavior of - :meth:`ActivityHandler.on_turn()` is used. + :meth:`on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -79,12 +88,12 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_turn()` method receives a conversation update activity, it calls this + When the :meth:`on_turn()` method receives a conversation update activity, it calls this method. If the conversation update activity indicates that members other than the bot joined the conversation, - it calls the :meth:`ActivityHandler.on_members_added_activity()` method. + it calls the :meth:`on_members_added_activity()` method. If the conversation update activity indicates that members other than the bot left the conversation, - it calls the :meth:`ActivityHandler.on_members_removed_activity()` method. + it calls the :meth:`on_members_removed_activity()` method. In a derived class, override this method to add logic that applies to all conversation update activities. Add logic to apply before the member added or removed logic before the call to this base class method. """ @@ -120,7 +129,7 @@ async def on_members_added_activity( :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation + When the :meth:`on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are joining the conversation, it calls this method. """ @@ -142,7 +151,7 @@ async def on_members_removed_activity( :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_conversation_update_activity()` method receives a conversation + When the :meth:`on_conversation_update_activity()` method receives a conversation update activity that indicates one or more users other than the bot are leaving the conversation, it calls this method. """ @@ -152,7 +161,7 @@ async def on_members_removed_activity( async def on_message_reaction_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of - :meth:'ActivityHandler.on_turn()` is used. + :meth:`on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -165,12 +174,12 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response from a send call. - When the :meth:'ActivityHandler.on_turn()` method receives a message reaction activity, it calls this + When the :meth:`on_turn()` method receives a message reaction activity, it calls this method. If the message reaction indicates that reactions were added to a message, it calls - :meth:'ActivityHandler.on_reaction_added(). + :meth:`on_reaction_added()`. If the message reaction indicates that reactions were removed from a message, it calls - :meth:'ActivityHandler.on_reaction_removed(). + :meth:`on_reaction_removed()`. In a derived class, override this method to add logic that applies to all message reaction activities. Add logic to apply before the reactions added or removed logic before the call to the this base class method. @@ -235,7 +244,7 @@ async def on_reactions_removed( # pylint: disable=unused-argument async def on_event_activity(self, turn_context: TurnContext): """ Invoked when an event activity is received from the connector when the base behavior of - :meth:'ActivityHandler.on_turn()` is used. + :meth:`on_turn()` is used. :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` @@ -243,9 +252,9 @@ async def on_event_activity(self, turn_context: TurnContext): :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_turn()` method receives an event activity, it calls this method. - If the activity name is `tokens/response`, it calls :meth:'ActivityHandler.on_token_response_event()`; - otherwise, it calls :meth:'ActivityHandler.on_event()`. + When the :meth:`on_turn()` method receives an event activity, it calls this method. + If the activity name is `tokens/response`, it calls :meth:`on_token_response_event()`; + otherwise, it calls :meth:`on_event()`. In a derived class, override this method to add logic that applies to all event activities. Add logic to apply before the specific event-handling logic before the call to this base class method. @@ -265,7 +274,7 @@ async def on_token_response_event( # pylint: disable=unused-argument ): """ Invoked when a `tokens/response` event is received when the base behavior of - :meth:'ActivityHandler.on_event_activity()` is used. + :meth:`on_event_activity()` is used. If using an `oauth_prompt`, override this method to forward this activity to the current dialog. :param turn_context: The context object for this turn @@ -274,7 +283,7 @@ async def on_token_response_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_event()` method receives an event with an activity name of + When the :meth:`on_event()` method receives an event with an activity name of `tokens/response`, it calls this method. If your bot uses an `oauth_prompt`, forward the incoming activity to the current dialog. """ @@ -285,7 +294,7 @@ async def on_event( # pylint: disable=unused-argument ): """ Invoked when an event other than `tokens/response` is received when the base behavior of - :meth:'ActivityHandler.on_event_activity()` is used. + :meth:`on_event_activity()` is used. :param turn_context: The context object for this turn @@ -294,7 +303,7 @@ async def on_event( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:'ActivityHandler.on_event_activity()` is used method receives an event with an + When the :meth:`on_event_activity()` is used method receives an event with an activity name other than `tokens/response`, it calls this method. This method could optionally be overridden if the bot is meant to handle miscellaneous events. """ @@ -317,7 +326,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument ): """ Invoked when an activity other than a message, conversation update, or event is received when the base - behavior of :meth:`ActivityHandler.on_turn()` is used. + behavior of :meth:`on_turn()` is used. If overridden, this method could potentially respond to any of the other activity types. :param turn_context: The context object for this turn @@ -326,7 +335,7 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute .. remarks:: - When the :meth:`ActivityHandler.on_turn()` method receives an activity that is not a message, + When the :meth:`on_turn()` method receives an activity that is not a message, conversation update, message reaction, or event activity, it calls this method. """ return From 5bdf258110073993a23029e7a7bfb734a982b055 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:48:53 -0800 Subject: [PATCH 240/616] Update activity_handler.py Fixed formatting --- .../botbuilder-core/botbuilder/core/activity_handler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index e07aa4e52..cd3d049cd 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -90,11 +90,12 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): .. remarks:: When the :meth:`on_turn()` method receives a conversation update activity, it calls this method. - If the conversation update activity indicates that members other than the bot joined the conversation, + Also + - If the conversation update activity indicates that members other than the bot joined the conversation, it calls the :meth:`on_members_added_activity()` method. - If the conversation update activity indicates that members other than the bot left the conversation, + - If the conversation update activity indicates that members other than the bot left the conversation, it calls the :meth:`on_members_removed_activity()` method. - In a derived class, override this method to add logic that applies to all conversation update activities. + - In a derived class, override this method to add logic that applies to all conversation update activities. Add logic to apply before the member added or removed logic before the call to this base class method. """ if ( From 268fbc07cf409ef1803b4c9692c55eab2d249632 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 12:54:37 -0800 Subject: [PATCH 241/616] Update activity_handler.py Formatting fixes --- .../botbuilder/core/activity_handler.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index cd3d049cd..cecab9205 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -172,15 +172,18 @@ async def on_message_reaction_activity(self, turn_context: TurnContext): .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) to a previously sent activity. + Message reactions are only supported by a few channels. The activity that the message reaction corresponds to is indicated in the reply to Id property. The value of this property is the activity id of a previously sent activity given back to the bot as the response from a send call. When the :meth:`on_turn()` method receives a message reaction activity, it calls this method. - If the message reaction indicates that reactions were added to a message, it calls + + - If the message reaction indicates that reactions were added to a message, it calls :meth:`on_reaction_added()`. - If the message reaction indicates that reactions were removed from a message, it calls + - If the message reaction indicates that reactions were removed from a message, it calls :meth:`on_reaction_removed()`. + In a derived class, override this method to add logic that applies to all message reaction activities. Add logic to apply before the reactions added or removed logic before the call to the this base class method. @@ -212,8 +215,9 @@ async def on_reactions_added( # pylint: disable=unused-argument .. remarks:: Message reactions correspond to the user adding a 'like' or 'sad' etc. (often an emoji) - to a previously sent message on the conversation. Message reactions are supported by only a few channels. - The activity that the message is in reaction to is identified by the activity's reply to Id property. + to a previously sent message on the conversation. + Message reactions are supported by only a few channels. + The activity that the message is in reaction to is identified by the activity's reply to ID property. The value of this property is the activity ID of a previously sent activity. When the bot sends an activity, the channel assigns an ID to it, which is available in the resource response Id of the result. """ From 74d2a0aba7ebdf60cd544140f6541bb889e5719d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 13:07:03 -0800 Subject: [PATCH 242/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 190061423..1fb054ce9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -17,7 +17,7 @@ class ComponentDialog(Dialog): """ A :class:`botbuilder.dialogs.Dialog` that is composed of other dialogs - A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, + A component dialog has an inner :class:`botbuilder.dialogs.DialogSet` :class:`botbuilder.dialogs.DialogContext`, which provides an inner dialog stack that is hidden from the parent dialog. :var persisted_dialog state: @@ -52,12 +52,12 @@ async def begin_dialog( If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. - :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :rtype: :var:`botbuilder.dialogs.Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") From 8d14bbc5227c996282c5ffbb20cd494ca321b05b Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 13:10:28 -0800 Subject: [PATCH 243/616] Update bot_framework_adapter.py Formatting fixes --- .../botbuilder/core/bot_framework_adapter.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 688f7ccda..a56b727d7 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -73,6 +73,10 @@ def __init__( class BotFrameworkAdapterSettings: + """ + Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. + """ + def __init__( self, app_id: str, @@ -121,11 +125,14 @@ class BotFrameworkAdapter(BotAdapter, UserTokenProvider): .. remarks:: The bot adapter encapsulates authentication processes and sends activities to and - receives activities from the Bot Connector Service. When your bot receives an activity, - the adapter creates a context object, passes it to your bot's application logic, and - sends responses back to the user's channel. + receives activities from the Bot Connector Service. + + When your bot receives an activity, the adapter creates a context object, passes it to + your bot's application logic, and sends responses back to the user's channel. + The adapter processes and directs incoming activities in through the bot middleware pipeline to your bot’s logic and then back out again. + As each activity flows in and out of the bot, each piece of middleware can inspect or act upon the activity, both before and after the bot logic runs. """ @@ -249,6 +256,7 @@ async def create_conversation( To start a conversation, your bot must know its account information and the user's account information on that channel. Most channels only support initiating a direct message (non-group) conversation. + The adapter attempts to create a new conversation on the channel, and then sends a conversation update activity through its middleware pipeline to the the callback method. @@ -390,7 +398,7 @@ async def authenticate_request( def create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. - :param activity: + :param activity: the activity to create :return: """ return TurnContext(self, activity) @@ -399,7 +407,7 @@ def create_context(self, activity): async def parse_request(req): """ Parses and validates request - :param req: + :param req: The request to parse :return: """ @@ -688,6 +696,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No The channel server returns results in pages and each page will include a `continuationToken` that can be used to fetch the next page of results from the server. If the task completes successfully, the result contains a page of the members of the current conversation. + This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. """ From 0504368930273d8ef6795f9539a67f42757b14a9 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 13:18:06 -0800 Subject: [PATCH 244/616] Update bot_framework_adapter.py Formatting fixes. --- .../botbuilder/core/bot_framework_adapter.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a56b727d7..3ec1818d6 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -76,7 +76,6 @@ class BotFrameworkAdapterSettings: """ Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. """ - def __init__( self, app_id: str, @@ -125,14 +124,11 @@ class BotFrameworkAdapter(BotAdapter, UserTokenProvider): .. remarks:: The bot adapter encapsulates authentication processes and sends activities to and - receives activities from the Bot Connector Service. - - When your bot receives an activity, the adapter creates a context object, passes it to - your bot's application logic, and sends responses back to the user's channel. - + receives activities from the Bot Connector Service. When your bot receives an activity, + the adapter creates a context object, passes it to your bot's application logic, and + sends responses back to the user's channel. The adapter processes and directs incoming activities in through the bot middleware pipeline to your bot’s logic and then back out again. - As each activity flows in and out of the bot, each piece of middleware can inspect or act upon the activity, both before and after the bot logic runs. """ @@ -256,7 +252,6 @@ async def create_conversation( To start a conversation, your bot must know its account information and the user's account information on that channel. Most channels only support initiating a direct message (non-group) conversation. - The adapter attempts to create a new conversation on the channel, and then sends a conversation update activity through its middleware pipeline to the the callback method. @@ -398,7 +393,7 @@ async def authenticate_request( def create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. - :param activity: the activity to create + :param activity: :return: """ return TurnContext(self, activity) @@ -407,7 +402,7 @@ def create_context(self, activity): async def parse_request(req): """ Parses and validates request - :param req: The request to parse + :param req: :return: """ @@ -696,7 +691,6 @@ async def get_conversations(self, service_url: str, continuation_token: str = No The channel server returns results in pages and each page will include a `continuationToken` that can be used to fetch the next page of results from the server. If the task completes successfully, the result contains a page of the members of the current conversation. - This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. """ From 3e00925bbef2cfcb9dc886fe9badb7b495dd7c90 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 13:25:18 -0800 Subject: [PATCH 245/616] Update bot_framework_adapter.py Deleted class description --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 3ec1818d6..9f82dd758 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -73,9 +73,7 @@ def __init__( class BotFrameworkAdapterSettings: - """ - Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. - """ + def __init__( self, app_id: str, From 51d9f93fdf3f5ba3acca61c346bf31734876b772 Mon Sep 17 00:00:00 2001 From: Michael Miele Date: Mon, 10 Feb 2020 13:29:50 -0800 Subject: [PATCH 246/616] Update bot_framework_adapter.py Formatting fixes --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 9f82dd758..688f7ccda 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -73,7 +73,6 @@ def __init__( class BotFrameworkAdapterSettings: - def __init__( self, app_id: str, From 06130a345156209195b89cc29f4a3df593c09094 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 13:33:12 -0800 Subject: [PATCH 247/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 1fb054ce9..c6887abfb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -88,14 +88,14 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu contain a return value. If this method is *not* overriden the component dialog calls the - :meth:`DialogContext.continue_dialog` method on it's inner dialog + :meth:`botbuilder.dialogs.DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, - and if a :class:`DialogTurnResult.result` is available, the component dialog + and if a :var:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog uses that as it's return value. - :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn :rtype: :var:`Dialog.end_of_turn` """ @@ -126,8 +126,8 @@ async def resume_dialog( To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` :param result: Optional, value returned from the dialog that was called. @@ -148,7 +148,7 @@ async def reprompt_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` :param instance: State information for this dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` """ # Delegate to inner dialog. dialog_state = instance.state[self.persisted_dialog_state] @@ -167,9 +167,9 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. - :type reason: :class:`DialogReason` + :type reason: :class:`botbuilder.dialogs.DialogReason` """ # Forward cancel to inner dialog if reason == DialogReason.CancelCalled: @@ -183,7 +183,7 @@ def add_dialog(self, dialog: Dialog) -> object: Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`Dialog` + :type dialog: :class:`botbuilder.dialogs.Dialog` :return self: The updated :class:`ComponentDialog` :rtype self: :class:`ComponentDialog` """ @@ -198,7 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The :class:`Dialog`; or None if there is not a match for the ID. + :return: The :class:`botbuilder.dialogs.Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) @@ -212,13 +212,13 @@ async def on_begin_dialog( If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. - By default, this calls the :meth:`Dialog.begin_dialog()` method of the component + By default, this calls the :meth:`botbuilder.dialogs.Dialog.begin_dialog()` method of the component dialog's initial dialog. Override this method in a derived class to implement interrupt logic. - :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. - :type inner_dc: :class:`DialogContext` + :param inner_dc: The inner :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. + :type inner_dc: :class:`botbuilder.dialogs.DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object """ @@ -228,8 +228,8 @@ async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: """ Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. - :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. - :type inner_dc: :class:`DialogContext` + :param inner_dc: The inner :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. + :type inner_dc: :class:`botbuilder.dialogs.DialogContext` """ return await inner_dc.continue_dialog() @@ -242,9 +242,9 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. - :type reason: :class:`DialogReason` + :type reason: :class:`botbuilder.dialogs.DialogReason` """ return @@ -253,9 +253,9 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument ) -> None: """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. - :type turn_context: :class:`DialogInstance` + :type turn_context: :class:`botbuilder.dialogs.DialogInstance` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` """ return @@ -270,19 +270,19 @@ async def end_component( turn was processed by the dialog. In general, the parent context is the dialog or bot turn handler that started the dialog. - If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method + If the parent is a dialog, the stack calls the parent's :meth:`botbuilder.dialogs.Dialog.resume_dialog()` method to return a result to the parent dialog. If the parent dialog does not implement - :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next + :meth:`botbuilder.dialogs.Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next parent context, if one exists. - The returned :class:`DialogTurnResult`contains the return value in its - :class:`DialogTurnResult.result` property. + The returned :class:`botbuilder.dialogs.DialogTurnResult`contains the return value in its + :var:`botbuilder.dialogs.DialogTurnResult.result` property. - :param outer_dc: The parent :class:`DialogContext` for the current turn of conversation. - :type outer_dc: :class:`DialogContext` + :param outer_dc: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. + :type outer_dc: :class:`botbuilder.dialogs.DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return : Value to return. - :rtype: :var:`DialogTurnResult.result` + :rtype: :var:`botbuilder.dialogs.DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From 0bfa431ed508a0e9f25a87b1bb4488c2b11332dd Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 13:42:32 -0800 Subject: [PATCH 248/616] Revert "Update component_dialog.py" This reverts commit 06130a345156209195b89cc29f4a3df593c09094. --- .../botbuilder/dialogs/component_dialog.py | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index c6887abfb..1fb054ce9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -88,14 +88,14 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu contain a return value. If this method is *not* overriden the component dialog calls the - :meth:`botbuilder.dialogs.DialogContext.continue_dialog` method on it's inner dialog + :meth:`DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, - and if a :var:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog + and if a :class:`DialogTurnResult.result` is available, the component dialog uses that as it's return value. - :param dialog_context: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`botbuilder.dialogs.DialogContext` + :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` :return: Signals the end of the turn :rtype: :var:`Dialog.end_of_turn` """ @@ -126,8 +126,8 @@ async def resume_dialog( To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - :param dialog_context: The :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`botbuilder.dialogs.DialogContext` + :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` :param result: Optional, value returned from the dialog that was called. @@ -148,7 +148,7 @@ async def reprompt_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` :param instance: State information for this dialog. - :type instance: :class:`botbuilder.dialogs.DialogInstance` + :type instance: :class:`DialogInstance` """ # Delegate to inner dialog. dialog_state = instance.state[self.persisted_dialog_state] @@ -167,9 +167,9 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`botbuilder.dialogs.DialogInstance` + :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. - :type reason: :class:`botbuilder.dialogs.DialogReason` + :type reason: :class:`DialogReason` """ # Forward cancel to inner dialog if reason == DialogReason.CancelCalled: @@ -183,7 +183,7 @@ def add_dialog(self, dialog: Dialog) -> object: Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`botbuilder.dialogs.Dialog` + :type dialog: :class:`Dialog` :return self: The updated :class:`ComponentDialog` :rtype self: :class:`ComponentDialog` """ @@ -198,7 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The :class:`botbuilder.dialogs.Dialog`; or None if there is not a match for the ID. + :return: The :class:`Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) @@ -212,13 +212,13 @@ async def on_begin_dialog( If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. - By default, this calls the :meth:`botbuilder.dialogs.Dialog.begin_dialog()` method of the component + By default, this calls the :meth:`Dialog.begin_dialog()` method of the component dialog's initial dialog. Override this method in a derived class to implement interrupt logic. - :param inner_dc: The inner :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. - :type inner_dc: :class:`botbuilder.dialogs.DialogContext` + :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. + :type inner_dc: :class:`DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object """ @@ -228,8 +228,8 @@ async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: """ Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. - :param inner_dc: The inner :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. - :type inner_dc: :class:`botbuilder.dialogs.DialogContext` + :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. + :type inner_dc: :class:`DialogContext` """ return await inner_dc.continue_dialog() @@ -242,9 +242,9 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`botbuilder.dialogs.DialogInstance` + :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. - :type reason: :class:`botbuilder.dialogs.DialogReason` + :type reason: :class:`DialogReason` """ return @@ -253,9 +253,9 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument ) -> None: """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. - :type turn_context: :class:`botbuilder.dialogs.DialogInstance` + :type turn_context: :class:`DialogInstance` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`botbuilder.dialogs.DialogInstance` + :type instance: :class:`DialogInstance` """ return @@ -270,19 +270,19 @@ async def end_component( turn was processed by the dialog. In general, the parent context is the dialog or bot turn handler that started the dialog. - If the parent is a dialog, the stack calls the parent's :meth:`botbuilder.dialogs.Dialog.resume_dialog()` method + If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method to return a result to the parent dialog. If the parent dialog does not implement - :meth:`botbuilder.dialogs.Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next + :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next parent context, if one exists. - The returned :class:`botbuilder.dialogs.DialogTurnResult`contains the return value in its - :var:`botbuilder.dialogs.DialogTurnResult.result` property. + The returned :class:`DialogTurnResult`contains the return value in its + :class:`DialogTurnResult.result` property. - :param outer_dc: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. - :type outer_dc: :class:`botbuilder.dialogs.DialogContext` + :param outer_dc: The parent :class:`DialogContext` for the current turn of conversation. + :type outer_dc: :class:`DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return : Value to return. - :rtype: :var:`botbuilder.dialogs.DialogTurnResult.result` + :rtype: :var:`DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From aba000a7dc00e48ff0b6261c893e3b00e5261665 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 13:49:02 -0800 Subject: [PATCH 249/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 1fb054ce9..bf7f480b8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -283,6 +283,6 @@ async def end_component( :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return : Value to return. - :rtype: :var:`DialogTurnResult.result` + :rtype: :var:`botbuilder.dialogs.DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From f6e73bd41c137f7cfd449d6f873262d320c330f2 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 13:57:22 -0800 Subject: [PATCH 250/616] Update dialog_turn_result.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 4dcc39e55..466cfac0f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -12,7 +12,7 @@ class DialogTurnResult: def __init__(self, status: DialogTurnStatus, result: object = None): """ :param status: The current status of the stack. - :type status: :class:`DialogTurnStatus` + :type status: :class:`botbuilder.dialogs.DialogTurnStatus` :param result: The result returned by a dialog that was just ended. :type result: object """ From 00c49bd1ab8477845856e5a23a3a4f6ab6a9f85b Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:08:38 -0800 Subject: [PATCH 251/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index bf7f480b8..0d5d54910 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -90,7 +90,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu If this method is *not* overriden the component dialog calls the :meth:`DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, - and if a :class:`DialogTurnResult.result` is available, the component dialog + and if a :var:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog uses that as it's return value. @@ -270,16 +270,16 @@ async def end_component( turn was processed by the dialog. In general, the parent context is the dialog or bot turn handler that started the dialog. - If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method + If the parent is a dialog, the stack calls the parent's :meth:`botbuilder.dialogs.Dialog.resume_dialog()` method to return a result to the parent dialog. If the parent dialog does not implement - :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next + :meth:`botbuilder.dialogs.Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next parent context, if one exists. - The returned :class:`DialogTurnResult`contains the return value in its - :class:`DialogTurnResult.result` property. + The returned :class:`botbuilder.dialogs.DialogTurnResult` contains the return value in its + :var:`botbuilder.dialogs.DialogTurnResult.result` property. - :param outer_dc: The parent :class:`DialogContext` for the current turn of conversation. - :type outer_dc: :class:`DialogContext` + :param outer_dc: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. + :type outer_dc: :class:`botbuilder.dialogs.DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return : Value to return. From 00bca73e215a8063f8662ef7cb046bd69306a57b Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:09:48 -0800 Subject: [PATCH 252/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 0d5d54910..4e693cfd2 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -253,9 +253,9 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument ) -> None: """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. - :type turn_context: :class:`DialogInstance` + :type turn_context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.core.DialogInstance` """ return From d96ee5e1fbb2a1c0a691abc2b3c3196b27a5f8f9 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:17:49 -0800 Subject: [PATCH 253/616] Revert "Update component_dialog.py" This reverts commit 00bca73e215a8063f8662ef7cb046bd69306a57b. --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 4e693cfd2..0d5d54910 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -253,9 +253,9 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument ) -> None: """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. - :type turn_context: :class:`botbuilder.core.TurnContext` + :type turn_context: :class:`DialogInstance` :param instance: State information associated with the instance of this component dialog. - :type instance: :class:`botbuilder.core.DialogInstance` + :type instance: :class:`DialogInstance` """ return From daf2aaa6d02b1e5b804d8c8deee11fb82fea5654 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:19:42 -0800 Subject: [PATCH 254/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 0d5d54910..e5f07a365 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -180,10 +180,10 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ - Adds a :class:`Dialog` to the component dialog and returns the updated component. + Adds a :class:`botbuilder.dialogs.Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`Dialog` + :type dialog: :class:`botbuilder.dialogs.Dialog` :return self: The updated :class:`ComponentDialog` :rtype self: :class:`ComponentDialog` """ @@ -198,7 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The :class:`Dialog`; or None if there is not a match for the ID. + :return: The :class:`botbuilder.dialogs.Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) From 7955508b7704b1b25431d77ec053f8a00fed0cc5 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:23:03 -0800 Subject: [PATCH 255/616] Revert "Update component_dialog.py" This reverts commit daf2aaa6d02b1e5b804d8c8deee11fb82fea5654. --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index e5f07a365..0d5d54910 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -180,10 +180,10 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ - Adds a :class:`botbuilder.dialogs.Dialog` to the component dialog and returns the updated component. + Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`botbuilder.dialogs.Dialog` + :type dialog: :class:`Dialog` :return self: The updated :class:`ComponentDialog` :rtype self: :class:`ComponentDialog` """ @@ -198,7 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The :class:`botbuilder.dialogs.Dialog`; or None if there is not a match for the ID. + :return: The :class:`Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) From accfbc178ff4937efd736a77e985a3385d6a0cbb Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:25:00 -0800 Subject: [PATCH 256/616] Revert "Revert "Update component_dialog.py"" This reverts commit 7955508b7704b1b25431d77ec053f8a00fed0cc5. --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 0d5d54910..e5f07a365 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -180,10 +180,10 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ - Adds a :class:`Dialog` to the component dialog and returns the updated component. + Adds a :class:`botbuilder.dialogs.Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`Dialog` + :type dialog: :class:`botbuilder.dialogs.Dialog` :return self: The updated :class:`ComponentDialog` :rtype self: :class:`ComponentDialog` """ @@ -198,7 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The :class:`Dialog`; or None if there is not a match for the ID. + :return: The :class:`botbuilder.dialogs.Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) From 4d6251c194723ebc20fb82bb66a6c584431591f1 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 14:38:24 -0800 Subject: [PATCH 257/616] Revert "Revert "Revert "Update component_dialog.py""" This reverts commit accfbc178ff4937efd736a77e985a3385d6a0cbb. --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index e5f07a365..0d5d54910 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -180,10 +180,10 @@ async def end_dialog( def add_dialog(self, dialog: Dialog) -> object: """ - Adds a :class:`botbuilder.dialogs.Dialog` to the component dialog and returns the updated component. + Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`botbuilder.dialogs.Dialog` + :type dialog: :class:`Dialog` :return self: The updated :class:`ComponentDialog` :rtype self: :class:`ComponentDialog` """ @@ -198,7 +198,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :type dialog_id: str - :return: The :class:`botbuilder.dialogs.Dialog`; or None if there is not a match for the ID. + :return: The :class:`Dialog`; or None if there is not a match for the ID. """ return self._dialogs.find(dialog_id) From c8b5c46fd46961a637e389479a966a22ca2cc4ba Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 15:03:08 -0800 Subject: [PATCH 258/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 0d5d54910..c98de80f9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -15,9 +15,9 @@ class ComponentDialog(Dialog): """ - A :class:`botbuilder.dialogs.Dialog` that is composed of other dialogs + A :class:`Dialog` that is composed of other dialogs - A component dialog has an inner :class:`botbuilder.dialogs.DialogSet` :class:`botbuilder.dialogs.DialogContext`, + A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, which provides an inner dialog stack that is hidden from the parent dialog. :var persisted_dialog state: @@ -52,12 +52,12 @@ async def begin_dialog( If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. - :param dialog_context: The :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`botbuilder.dialogs.DialogContext` + :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object :return: Signals the end of the turn - :rtype: :var:`botbuilder.dialogs.Dialog.end_of_turn` + :rtype: :class:`Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -90,14 +90,14 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu If this method is *not* overriden the component dialog calls the :meth:`DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, - and if a :var:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog + and if a :class:`DialogTurnResult.result` is available, the component dialog uses that as it's return value. :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` :return: Signals the end of the turn - :rtype: :var:`Dialog.end_of_turn` + :rtype: :class:`Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -130,10 +130,11 @@ async def resume_dialog( :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` - :param result: Optional, value returned from the dialog that was called. + :param result: Optional, value returned from the dialog that was called. The type of the + value returned is dependent on the child dialog. :type result: object - :return Dialog.end_of_turn: Signals the end of the turn - :rtype Dialog.end_of_turn: :class:`Dialog.end_of_turn` + :return: Signals the end of the turn + :rtype: :class:`Dialog.end_of_turn` """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) @@ -166,7 +167,8 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` - :param instance: State information associated with the instance of this component dialog. + :param instance: State information associated with the instance of this component dialog + on its parent's dialog stack. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` @@ -183,9 +185,8 @@ def add_dialog(self, dialog: Dialog) -> object: Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :type dialog: :class:`Dialog` - :return self: The updated :class:`ComponentDialog` - :rtype self: :class:`ComponentDialog` + :return: The updated :class:`ComponentDialog` + :rtype: :class:`ComponentDialog` """ self._dialogs.add(dialog) if not self.initial_dialog_id: @@ -197,8 +198,8 @@ def find_dialog(self, dialog_id: str) -> Dialog: Finds a dialog by ID. :param dialog_id: The dialog to add. - :type dialog_id: str - :return: The :class:`Dialog`; or None if there is not a match for the ID. + :return: The dialog; or None if there is not a match for the ID. + :rtype: :class:Dialog """ return self._dialogs.find(dialog_id) @@ -241,7 +242,8 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.core.TurnContext` - :param instance: State information associated with the instance of this component dialog. + :param instance: State information associated with the instance of this component dialog on + its parent's dialog stack. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` @@ -254,7 +256,8 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`DialogInstance` - :param instance: State information associated with the instance of this component dialog. + :param instance: State information associated with the instance of this component dialog + on its parent's dialog stack. :type instance: :class:`DialogInstance` """ return @@ -270,19 +273,19 @@ async def end_component( turn was processed by the dialog. In general, the parent context is the dialog or bot turn handler that started the dialog. - If the parent is a dialog, the stack calls the parent's :meth:`botbuilder.dialogs.Dialog.resume_dialog()` method + If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method to return a result to the parent dialog. If the parent dialog does not implement - :meth:`botbuilder.dialogs.Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next + :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next parent context, if one exists. - The returned :class:`botbuilder.dialogs.DialogTurnResult` contains the return value in its - :var:`botbuilder.dialogs.DialogTurnResult.result` property. + The returned :class:`DialogTurnResult`contains the return value in its + :class:`DialogTurnResult.result` property. - :param outer_dc: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of conversation. - :type outer_dc: :class:`botbuilder.dialogs.DialogContext` + :param outer_dc: The parent class:`DialogContext` for the current turn of conversation. + :type outer_dc: class:`DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object - :return : Value to return. - :rtype: :var:`botbuilder.dialogs.DialogTurnResult.result` + :return: Value to return. + :rtype: :class:`DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From 9a1e0ba934d338d0cf61dfaa261aba7b9b1fee6f Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 15:11:39 -0800 Subject: [PATCH 259/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index c98de80f9..89089c389 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -15,7 +15,7 @@ class ComponentDialog(Dialog): """ - A :class:`Dialog` that is composed of other dialogs + A :class:`botbuilder.dialogs.Dialog` that is composed of other dialogs A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, which provides an inner dialog stack that is hidden from the parent dialog. From cc37db7852d544732491b359b60ddd3eb385503f Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 15:25:50 -0800 Subject: [PATCH 260/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 89089c389..0a7cc8d08 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -17,9 +17,6 @@ class ComponentDialog(Dialog): """ A :class:`botbuilder.dialogs.Dialog` that is composed of other dialogs - A component dialog has an inner :class:`DialogSet` :class:`DialogContext`, - which provides an inner dialog stack that is hidden from the parent dialog. - :var persisted_dialog state: :vartype persisted_dialog_state: str """ From 85b1fbeeeda3306615795aa8c7d2dcdbb4cfbc13 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 16:03:10 -0800 Subject: [PATCH 261/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 0a7cc8d08..03f9a5a95 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -49,12 +49,12 @@ async def begin_dialog( If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. - :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") From f68c940c2ab5fef217a15730823458dbcc1c44f9 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:02:20 -0800 Subject: [PATCH 262/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 03f9a5a95..9415a1301 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -85,14 +85,14 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu contain a return value. If this method is *not* overriden the component dialog calls the - :meth:`DialogContext.continue_dialog` method on it's inner dialog + :meth:`botbuilder.dialogs.DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, - and if a :class:`DialogTurnResult.result` is available, the component dialog + and if a :var:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog uses that as it's return value. - :param dialog_context: The parent :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn :rtype: :class:`Dialog.end_of_turn` """ From 791700014ffa471e07901d6979189cfd4ed2307a Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:09:19 -0800 Subject: [PATCH 263/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 9415a1301..aaa468ba2 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -94,7 +94,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :param dialog_context: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -123,8 +123,8 @@ async def resume_dialog( To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` :param result: Optional, value returned from the dialog that was called. The type of the From dddc5c258e01248f3a0e625f783b5c63620f3c8d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:14:23 -0800 Subject: [PATCH 264/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index aaa468ba2..4e0b5acbb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -91,7 +91,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu uses that as it's return value. - :param dialog_context: The parent :class:`botbuilder.dialogs.DialogContext` for the current turn of the conversation. + :param dialog_context: The parent dialog context for the current turn of the conversation. :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` From 3c20a540910afdb129cc5b59245e751fe225d472 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:14:38 -0800 Subject: [PATCH 265/616] Revert "Update component_dialog.py" This reverts commit 791700014ffa471e07901d6979189cfd4ed2307a. --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 4e0b5acbb..c8f3090ce 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -94,7 +94,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :param dialog_context: The parent dialog context for the current turn of the conversation. :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn - :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` + :rtype: :class:`Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -123,8 +123,8 @@ async def resume_dialog( To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - :param dialog_context: The dialog context for the current turn of the conversation. - :type dialog_context: :class:`botbuilder.dialogs.DialogContext` + :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. + :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` :param result: Optional, value returned from the dialog that was called. The type of the From 0517a146df6f8c8402a512e2993f80535b9a3346 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:21:58 -0800 Subject: [PATCH 266/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index c8f3090ce..a2ffcf34a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -283,6 +283,6 @@ async def end_component( :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return: Value to return. - :rtype: :class:`DialogTurnResult.result` + :rtype: :var:`botbuilder.dialogs.DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From 0462d069cfa20e26226a99e8bccbbd128295b9be Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:27:56 -0800 Subject: [PATCH 267/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index a2ffcf34a..541397f8e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -275,8 +275,8 @@ async def end_component( :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next parent context, if one exists. - The returned :class:`DialogTurnResult`contains the return value in its - :class:`DialogTurnResult.result` property. + The returned :class:`botbuilder.dialogs.DialogTurnResult`contains the return value in its + :var:`botbuilder.dialogs.DialogTurnResult.result` property. :param outer_dc: The parent class:`DialogContext` for the current turn of conversation. :type outer_dc: class:`DialogContext` From 94563db87a0bcae9d2a238d0c94322aba7572bde Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:40:54 -0800 Subject: [PATCH 268/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 541397f8e..c4e4c1bb8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -94,7 +94,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :param dialog_context: The parent dialog context for the current turn of the conversation. :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -131,7 +131,7 @@ async def resume_dialog( value returned is dependent on the child dialog. :type result: object :return: Signals the end of the turn - :rtype: :class:`Dialog.end_of_turn` + :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` """ await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) @@ -196,7 +196,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: The dialog to add. :return: The dialog; or None if there is not a match for the ID. - :rtype: :class:Dialog + :rtype: :class:`botbuilder.dialogs.Dialog` """ return self._dialogs.find(dialog_id) From 8cf5ff4cbc2c99178eab4c8bda406e4297b40620 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 17:54:14 -0800 Subject: [PATCH 269/616] Update component_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index c4e4c1bb8..9a4e7c1c9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -278,8 +278,8 @@ async def end_component( The returned :class:`botbuilder.dialogs.DialogTurnResult`contains the return value in its :var:`botbuilder.dialogs.DialogTurnResult.result` property. - :param outer_dc: The parent class:`DialogContext` for the current turn of conversation. - :type outer_dc: class:`DialogContext` + :param outer_dc: The parent dialog context for the current turn of conversation. + :type outer_dc: class:`botbuilder.dialogs.DialogContext` :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return: Value to return. From 2d750f4d2efee1fc6016dd1bff0a1972036e6e93 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 18:07:10 -0800 Subject: [PATCH 270/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 9a4e7c1c9..d1425386f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -269,15 +269,6 @@ async def end_component( If the task is successful, the result indicates that the dialog ended after the turn was processed by the dialog. - In general, the parent context is the dialog or bot turn handler that started the dialog. - If the parent is a dialog, the stack calls the parent's :meth:`Dialog.resume_dialog()` method - to return a result to the parent dialog. If the parent dialog does not implement - :meth:`Dialog.resume_dialog()`, then the parent will end, too, and the result is passed to the next - parent context, if one exists. - - The returned :class:`botbuilder.dialogs.DialogTurnResult`contains the return value in its - :var:`botbuilder.dialogs.DialogTurnResult.result` property. - :param outer_dc: The parent dialog context for the current turn of conversation. :type outer_dc: class:`botbuilder.dialogs.DialogContext` :param result: Optional, value to return from the dialog component to the parent context. From f1e5a45b11475cbf988aafda3a4a64982ef85c41 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 18:17:02 -0800 Subject: [PATCH 271/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index d1425386f..70bf80208 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -127,8 +127,7 @@ async def resume_dialog( :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`DialogReason` - :param result: Optional, value returned from the dialog that was called. The type of the - value returned is dependent on the child dialog. + :param result: Optional, value returned from the dialog that was called. :type result: object :return: Signals the end of the turn :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` @@ -252,10 +251,10 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument ) -> None: """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. - :type turn_context: :class:`DialogInstance` + :type turn_context: :class:`botbuilder.dialogs.DialogInstance` :param instance: State information associated with the instance of this component dialog on its parent's dialog stack. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` """ return From 1c520d66404d4cee81aeb33297653e071002841e Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 18:21:00 -0800 Subject: [PATCH 272/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 70bf80208..e4e54865d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -163,7 +163,7 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` - :param instance: State information associated with the instance of this component dialog + :param instance: State information associated with the instance of this component dialog. on its parent's dialog stack. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. @@ -238,8 +238,7 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.core.TurnContext` - :param instance: State information associated with the instance of this component dialog on - its parent's dialog stack. + :param instance: State information associated with the inner dialog stack of this component dialog. :type instance: :class:`DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` @@ -252,8 +251,7 @@ async def on_reprompt_dialog( # pylint: disable=unused-argument """ :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.dialogs.DialogInstance` - :param instance: State information associated with the instance of this component dialog - on its parent's dialog stack. + :param instance: State information associated with the inner dialog stack of this component dialog. :type instance: :class:`botbuilder.dialogs.DialogInstance` """ return From 88206326d2e739fb9764a43af1664ed4b3d6aab3 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 18:27:44 -0800 Subject: [PATCH 273/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index e4e54865d..78d208daf 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -145,7 +145,7 @@ async def reprompt_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` :param instance: State information for this dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` """ # Delegate to inner dialog. dialog_state = instance.state[self.persisted_dialog_state] @@ -165,7 +165,7 @@ async def end_dialog( :type context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. on its parent's dialog stack. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` """ @@ -239,7 +239,7 @@ async def on_end_dialog( # pylint: disable=unused-argument :param turn_context: The :class:`botbuilder.core.TurnContext` for the current turn of the conversation. :type turn_context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the inner dialog stack of this component dialog. - :type instance: :class:`DialogInstance` + :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`DialogReason` """ From 44a7da09c1dafc64239117a8cd97d769d9ff628f Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 18:28:34 -0800 Subject: [PATCH 274/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 78d208daf..2e41db1a9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -126,7 +126,7 @@ async def resume_dialog( :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. :type dialog_context: :class:`DialogContext` :param reason: Reason why the dialog resumed. - :type reason: :class:`DialogReason` + :type reason: :class:`botbuilder.dialogs.DialogReason` :param result: Optional, value returned from the dialog that was called. :type result: object :return: Signals the end of the turn @@ -167,7 +167,7 @@ async def end_dialog( on its parent's dialog stack. :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. - :type reason: :class:`DialogReason` + :type reason: :class:`botbuilder.dialogs.DialogReason` """ # Forward cancel to inner dialog if reason == DialogReason.CancelCalled: @@ -241,7 +241,7 @@ async def on_end_dialog( # pylint: disable=unused-argument :param instance: State information associated with the inner dialog stack of this component dialog. :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. - :type reason: :class:`DialogReason` + :type reason: :class:`botbuilder.dialogs.DialogReason` """ return From 93085ad00588bb525f8514883b69589c400c474f Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 10 Feb 2020 18:36:26 -0800 Subject: [PATCH 275/616] Update component_dialog.py --- .../botbuilder/dialogs/component_dialog.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 2e41db1a9..bda4b711f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -94,7 +94,7 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu :param dialog_context: The parent dialog context for the current turn of the conversation. :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn - :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` + :rtype: :var:`botbuilder.dialogs.Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -123,8 +123,8 @@ async def resume_dialog( To avoid the container prematurely ending we need to implement this method and simply ask our inner dialog stack to re-prompt. - :param dialog_context: The :class:`DialogContext` for the current turn of the conversation. - :type dialog_context: :class:`DialogContext` + :param dialog_context: The dialog context for the current turn of the conversation. + :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :param reason: Reason why the dialog resumed. :type reason: :class:`botbuilder.dialogs.DialogReason` :param result: Optional, value returned from the dialog that was called. @@ -181,7 +181,7 @@ def add_dialog(self, dialog: Dialog) -> object: Adds a :class:`Dialog` to the component dialog and returns the updated component. :param dialog: The dialog to add. - :return: The updated :class:`ComponentDialog` + :return: The updated :class:`ComponentDialog`. :rtype: :class:`ComponentDialog` """ self._dialogs.add(dialog) @@ -209,13 +209,13 @@ async def on_begin_dialog( If the task is successful, the result indicates whether the dialog is still active after the turn has been processed by the dialog. - By default, this calls the :meth:`Dialog.begin_dialog()` method of the component - dialog's initial dialog. + By default, this calls the :meth:`botbuilder.dialogs.Dialog.begin_dialog()` + method of the component dialog's initial dialog. Override this method in a derived class to implement interrupt logic. - :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. - :type inner_dc: :class:`DialogContext` + :param inner_dc: The inner dialog context for the current turn of conversation. + :type inner_dc: :class:`botbuilder.dialogs.DialogContext` :param options: Optional, initial information to pass to the dialog. :type options: object """ @@ -225,8 +225,8 @@ async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: """ Called when the dialog is continued, where it is the active dialog and the user replies with a new activity. - :param inner_dc: The inner :class:`DialogContext` for the current turn of conversation. - :type inner_dc: :class:`DialogContext` + :param inner_dc: The inner dialog context for the current turn of conversation. + :type inner_dc: :class:`botbuilder.dialogs.DialogContext` """ return await inner_dc.continue_dialog() From 5a622806f3d5b0e39d16fc3de948fe469ff81c8f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 11 Feb 2020 07:25:10 -0600 Subject: [PATCH 276/616] New object model for #320 --- .../connector/auth/app_credentials.py | 117 +++++++++++ .../connector/auth/authenticator.py | 15 ++ .../auth/credentials_authenticator.py | 27 +++ .../auth/microsoft_app_credentials.py | 182 ++---------------- .../botframework-connector/requirements.txt | 3 +- 5 files changed, 180 insertions(+), 164 deletions(-) create mode 100644 libraries/botframework-connector/botframework/connector/auth/app_credentials.py create mode 100644 libraries/botframework-connector/botframework/connector/auth/authenticator.py create mode 100644 libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py diff --git a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py new file mode 100644 index 000000000..f10373988 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timedelta +from urllib.parse import urlparse + +import requests +from msrest.authentication import Authentication + +from botframework.connector.auth import AuthenticationConstants +from botframework.connector.auth.authenticator import Authenticator + + +class AppCredentials(Authentication): + """ + MicrosoftAppCredentials auth implementation and cache. + """ + + schema = "Bearer" + + trustedHostNames = { + # "state.botframework.com": datetime.max, + # "state.botframework.azure.us": datetime.max, + "api.botframework.com": datetime.max, + "token.botframework.com": datetime.max, + "api.botframework.azure.us": datetime.max, + "token.botframework.azure.us": datetime.max, + } + cache = {} + + def __init__( + self, channel_auth_tenant: str = None, oauth_scope: str = None, + ): + """ + Initializes a new instance of MicrosoftAppCredentials class + :param channel_auth_tenant: Optional. The oauth token tenant. + """ + tenant = ( + channel_auth_tenant + if channel_auth_tenant + else AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT + ) + self.oauth_endpoint = ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + tenant + ) + self.oauth_scope = ( + oauth_scope or AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + self.microsoft_app_id = None + self.authenticator: Authenticator = None + + @staticmethod + def trust_service_url(service_url: str, expiration=None): + """ + Checks if the service url is for a trusted host or not. + :param service_url: The service url. + :param expiration: The expiration time after which this service url is not trusted anymore. + :returns: True if the host of the service url is trusted; False otherwise. + """ + if expiration is None: + expiration = datetime.now() + timedelta(days=1) + host = urlparse(service_url).hostname + if host is not None: + AppCredentials.trustedHostNames[host] = expiration + + @staticmethod + def is_trusted_service(service_url: str) -> bool: + """ + Checks if the service url is for a trusted host or not. + :param service_url: The service url. + :returns: True if the host of the service url is trusted; False otherwise. + """ + host = urlparse(service_url).hostname + if host is not None: + return AppCredentials._is_trusted_url(host) + return False + + @staticmethod + def _is_trusted_url(host: str) -> bool: + expiration = AppCredentials.trustedHostNames.get(host, datetime.min) + return expiration > (datetime.now() - timedelta(minutes=5)) + + # pylint: disable=arguments-differ + def signed_session(self, session: requests.Session = None) -> requests.Session: + """ + Gets the signed session. + :returns: Signed requests.Session object + """ + if not session: + session = requests.Session() + + # If there is no microsoft_app_id then there shouldn't be an + # "Authorization" header on the outgoing activity. + if not self.microsoft_app_id: + session.headers.pop("Authorization", None) + else: + auth_token = self.get_token() + header = "{} {}".format("Bearer", auth_token) + session.headers["Authorization"] = header + + return session + + def get_token(self) -> str: + return self._get_authenticator().acquire_token()["access_token"] + + def _get_authenticator(self) -> Authenticator: + if not self.authenticator: + self.authenticator = self._build_authenticator() + return self.authenticator + + def _build_authenticator(self) -> Authenticator: + """ + Returns an appropriate Authenticator that is provided by a subclass. + :return: An Authenticator object + """ + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/authenticator.py b/libraries/botframework-connector/botframework/connector/auth/authenticator.py new file mode 100644 index 000000000..99d15e8e4 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/authenticator.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class Authenticator: + """ + A provider of tokens + """ + + def acquire_token(self): + """ + Returns a token. The implementation is supplied by a subclass. + :return: The string token + """ + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py b/libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py new file mode 100644 index 000000000..cbb0edb10 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from msal import ConfidentialClientApplication + +from botframework.connector.auth.authenticator import Authenticator + + +class CredentialsAuthenticator(Authenticator, ABC): + def __init__(self, app_id: str, app_password: str, authority: str, scope: str): + self.app = ConfidentialClientApplication( + client_id=app_id, client_credential=app_password, authority=authority + ) + + self.scopes = [scope] + + def acquire_token(self): + # Firstly, looks up a token from cache + # Since we are looking for token for the current app, NOT for an end user, + # notice we give account parameter as None. + auth_token = self.app.acquire_token_silent(self.scopes, account=None) + if not auth_token: + # No suitable token exists in cache. Let's get a new one from AAD. + auth_token = self.app.acquire_token_for_client(scopes=self.scopes) + return auth_token diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 9da4d1d0a..e8c1b5c3e 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -1,64 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from datetime import datetime, timedelta -from urllib.parse import urlparse -from adal import AuthenticationContext -import requests +from abc import ABC -from msrest.authentication import Authentication -from .authentication_constants import AuthenticationConstants +from .app_credentials import AppCredentials +from .authenticator import Authenticator +from .credentials_authenticator import CredentialsAuthenticator -# TODO: Decide to move this to Constants or viceversa (when porting OAuth) -AUTH_SETTINGS = { - "refreshEndpoint": "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token", - "refreshScope": "https://api.botframework.com/.default", - "botConnectorOpenIdMetadata": "https://login.botframework.com/v1/.well-known/openidconfiguration", - "botConnectorIssuer": "https://api.botframework.com", - "emulatorOpenIdMetadata": "https://login.microsoftonline.com/botframework.com/v2.0/" - ".well-known/openid-configuration", - "emulatorAuthV31IssuerV1": "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", - "emulatorAuthV31IssuerV2": "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", - "emulatorAuthV32IssuerV1": "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", - "emulatorAuthV32IssuerV2": "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", -} - -class _OAuthResponse: - def __init__(self): - self.token_type = None - self.expires_in = None - self.access_token = None - self.expiration_time = None - - @staticmethod - def from_json(json_values): - result = _OAuthResponse() - try: - result.token_type = json_values["tokenType"] - result.access_token = json_values["accessToken"] - result.expires_in = json_values["expiresIn"] - except KeyError: - pass - return result - - -class MicrosoftAppCredentials(Authentication): +class MicrosoftAppCredentials(AppCredentials, ABC): """ - MicrosoftAppCredentials auth implementation and cache. + MicrosoftAppCredentials auth implementation. """ - schema = "Bearer" - - trustedHostNames = { - "state.botframework.com": datetime.max, - "api.botframework.com": datetime.max, - "token.botframework.com": datetime.max, - "state.botframework.azure.us": datetime.max, - "api.botframework.azure.us": datetime.max, - "token.botframework.azure.us": datetime.max, - } - cache = {} + MICROSOFT_APP_ID = "MicrosoftAppId" + MICROSOFT_PASSWORD = "MicrosoftPassword" def __init__( self, @@ -67,120 +23,20 @@ def __init__( channel_auth_tenant: str = None, oauth_scope: str = None, ): - """ - Initializes a new instance of MicrosoftAppCredentials class - :param app_id: The Microsoft app ID. - :param app_password: The Microsoft app password. - :param channel_auth_tenant: Optional. The oauth token tenant. - """ - # The configuration property for the Microsoft app ID. + super().__init__( + channel_auth_tenant=channel_auth_tenant, oauth_scope=oauth_scope + ) self.microsoft_app_id = app_id - # The configuration property for the Microsoft app Password. self.microsoft_app_password = password - tenant = ( - channel_auth_tenant - if channel_auth_tenant - else AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT - ) - self.oauth_endpoint = ( - AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + tenant - ) - self.oauth_scope = ( - oauth_scope or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER - ) - self.token_cache_key = app_id + self.oauth_scope + "-cache" if app_id else None - self.authentication_context = AuthenticationContext(self.oauth_endpoint) - # pylint: disable=arguments-differ - def signed_session(self, session: requests.Session = None) -> requests.Session: - """ - Gets the signed session. - :returns: Signed requests.Session object + def _build_authenticator(self) -> Authenticator: """ - if not session: - session = requests.Session() - - # If there is no microsoft_app_id and no self.microsoft_app_password, then there shouldn't - # be an "Authorization" header on the outgoing activity. - if not self.microsoft_app_id and not self.microsoft_app_password: - session.headers.pop("Authorization", None) - - else: - auth_token = self.get_access_token() - header = "{} {}".format("Bearer", auth_token) - session.headers["Authorization"] = header - - return session - - def get_access_token(self, force_refresh: bool = False) -> str: + Returns an Authenticator suitable for credential auth. + :return: An Authenticator object """ - Gets an OAuth access token. - :param force_refresh: True to force a refresh of the token; or false to get - a cached token if it exists. - :returns: Access token string - """ - if self.microsoft_app_id and self.microsoft_app_password: - if not force_refresh: - # check the global cache for the token. If we have it, and it's valid, we're done. - oauth_token = MicrosoftAppCredentials.cache.get( - self.token_cache_key, None - ) - if oauth_token is not None: - # we have the token. Is it valid? - if oauth_token.expiration_time > datetime.now(): - return oauth_token.access_token - # We need to refresh the token, because: - # 1. The user requested it via the force_refresh parameter - # 2. We have it, but it's expired - # 3. We don't have it in the cache. - oauth_token = self.refresh_token() - MicrosoftAppCredentials.cache.setdefault(self.token_cache_key, oauth_token) - return oauth_token.access_token - return "" - - def refresh_token(self) -> _OAuthResponse: - """ - returns: _OAuthResponse - """ - - token = self.authentication_context.acquire_token_with_client_credentials( - self.oauth_scope, self.microsoft_app_id, self.microsoft_app_password - ) - - oauth_response = _OAuthResponse.from_json(token) - oauth_response.expiration_time = datetime.now() + timedelta( - seconds=(int(oauth_response.expires_in) - 300) + return CredentialsAuthenticator( + app_id=self.microsoft_app_id, + app_password=self.microsoft_app_password, + authority=self.oauth_endpoint, + scope=self.oauth_scope, ) - - return oauth_response - - @staticmethod - def trust_service_url(service_url: str, expiration=None): - """ - Checks if the service url is for a trusted host or not. - :param service_url: The service url. - :param expiration: The expiration time after which this service url is not trusted anymore. - :returns: True if the host of the service url is trusted; False otherwise. - """ - if expiration is None: - expiration = datetime.now() + timedelta(days=1) - host = urlparse(service_url).hostname - if host is not None: - MicrosoftAppCredentials.trustedHostNames[host] = expiration - - @staticmethod - def is_trusted_service(service_url: str) -> bool: - """ - Checks if the service url is for a trusted host or not. - :param service_url: The service url. - :returns: True if the host of the service url is trusted; False otherwise. - """ - host = urlparse(service_url).hostname - if host is not None: - return MicrosoftAppCredentials._is_trusted_url(host) - return False - - @staticmethod - def _is_trusted_url(host: str) -> bool: - expiration = MicrosoftAppCredentials.trustedHostNames.get(host, datetime.min) - return expiration > (datetime.now() - timedelta(minutes=5)) diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 2ac3029a3..7358f97e1 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -2,4 +2,5 @@ msrest==0.6.10 botbuilder-schema>=4.7.1 requests==2.22.0 PyJWT==1.5.3 -cryptography==2.8.0 \ No newline at end of file +cryptography==2.8.0 +msal==1.1.0 From 64e2d12c197c7313e99d3f6ccb724765bdba4b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 11 Feb 2020 10:24:20 -0800 Subject: [PATCH 277/616] Appinsights telemetry for aiohttp (#634) * Initial version of aiohttp telemetry middleware * black: Initial version of aiohttp telemetry middleware * created separate package * pylint: created separate package * black: created separate package * namespace renamed and tests added * testing yaml pipeline * removing pipeline * Update ci-pr-pipeline.yml for Azure Pipelines Updating pipeline for new integration package (appinsights-aiohttp) --- ci-pr-pipeline.yml | 5 +- .../README.rst | 87 +++++++++++++++++++ .../applicationinsights/aiohttp/__init__.py | 7 ++ .../applicationinsights/aiohttp/about.py | 15 ++++ .../aiohttp/aiohttp_telemetry_middleware.py | 26 ++++++ .../aiohttp/aiohttp_telemetry_processor.py | 24 +++++ .../setup.py | 62 +++++++++++++ .../tests/test_aiohttp_processor.py | 26 ++++++ .../test_aiohttp_telemetry_middleware.py | 35 ++++++++ 9 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/__init__.py create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_processor.py create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_processor.py create mode 100644 libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_telemetry_middleware.py diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index b97d5256f..13d622d1c 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -41,6 +41,7 @@ jobs: pip install -e ./libraries/botbuilder-dialogs pip install -e ./libraries/botbuilder-azure pip install -e ./libraries/botbuilder-testing + pip install -e ./libraries/botbuilder-integration-applicationinsights-aiohttp pip install -r ./libraries/botframework-connector/tests/requirements.txt pip install -r ./libraries/botbuilder-core/tests/requirements.txt pip install coveralls @@ -48,10 +49,6 @@ jobs: pip install black displayName: 'Install dependencies' - - script: 'pip install requests_mock' - displayName: 'Install requests mock (REMOVE AFTER MERGING INSPECTION)' - enabled: false - - script: | pip install pytest pip install pytest-cov diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst b/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst new file mode 100644 index 000000000..8479d7ea1 --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst @@ -0,0 +1,87 @@ + +======================================================== +BotBuilder-ApplicationInsights SDK extension for aiohttp +======================================================== + +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-applicationinsights.svg + :target: https://badge.fury.io/py/botbuilder-applicationinsights + :alt: Latest PyPI package version + +Within the Bot Framework, BotBuilder-ApplicationInsights enables the Azure Application Insights service. + +Application Insights is an extensible Application Performance Management (APM) service for developers on multiple platforms. +Use it to monitor your live bot application. It includes powerful analytics tools to help you diagnose issues and to understand +what users actually do with your bot. + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-applicationinsights-aiohttp + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.7.0`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.7.0: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/__init__.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/__init__.py new file mode 100644 index 000000000..7dd6e6aa4 --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/__init__.py @@ -0,0 +1,7 @@ +from .aiohttp_telemetry_middleware import bot_telemetry_middleware +from .aiohttp_telemetry_processor import AiohttpTelemetryProcessor + +__all__ = [ + "bot_telemetry_middleware", + "AiohttpTelemetryProcessor", +] diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py new file mode 100644 index 000000000..1fc4d035b --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Bot Framework Application Insights integration package for aiohttp library.""" + +import os + +__title__ = "botbuilder-integration-applicationinsights-aiohttp" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py new file mode 100644 index 000000000..acc0c69cc --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py @@ -0,0 +1,26 @@ +from threading import current_thread +from aiohttp.web import middleware + +# Map of thread id => POST body text +_REQUEST_BODIES = {} + + +def retrieve_aiohttp_body(): + """ retrieve_flask_body + Retrieve the POST body text from temporary cache. + The POST body corresponds with the thread id and should resides in + cache just for lifetime of request. + """ + result = _REQUEST_BODIES.pop(current_thread().ident, None) + return result + + +@middleware +async def bot_telemetry_middleware(request, handler): + """Process the incoming Flask request.""" + if "application/json" in request.headers["Content-Type"]: + body = await request.json() + _REQUEST_BODIES[current_thread().ident] = body + + response = await handler(request) + return response diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_processor.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_processor.py new file mode 100644 index 000000000..2962a5fe8 --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_processor.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Telemetry processor for aiohttp.""" +import sys + +from botbuilder.applicationinsights.processor.telemetry_processor import ( + TelemetryProcessor, +) +from .aiohttp_telemetry_middleware import retrieve_aiohttp_body + + +class AiohttpTelemetryProcessor(TelemetryProcessor): + def can_process(self) -> bool: + return self.detect_aiohttp() + + def get_request_body(self) -> str: + if self.detect_aiohttp(): + return retrieve_aiohttp_body() + return None + + @staticmethod + def detect_aiohttp() -> bool: + """Detects if running in aiohttp.""" + return "aiohttp" in sys.modules diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py new file mode 100644 index 000000000..28b8dd9bb --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "applicationinsights>=0.11.9", + "botbuilder-schema>=4.4.0b1", + "botframework-connector>=4.4.0b1", + "botbuilder-core>=4.4.0b1", + "botbuilder-applicationinsights>=4.4.0b1", +] +TESTS_REQUIRES = [ + "aiounittest==1.3.0", + "aiohttp==3.5.4", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open( + os.path.join( + root, "botbuilder", "integration", "applicationinsights", "aiohttp", "about.py" + ) +) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords=[ + "BotBuilderApplicationInsights", + "bots", + "ai", + "botframework", + "botbuilder", + "aiohttp", + ], + long_description=long_description, + long_description_content_type="text/x-rst", + license=package_info["__license__"], + packages=["botbuilder.integration.applicationinsights.aiohttp"], + install_requires=REQUIRES + TESTS_REQUIRES, + tests_require=TESTS_REQUIRES, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.7", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_processor.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_processor.py new file mode 100644 index 000000000..37ca54267 --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_processor.py @@ -0,0 +1,26 @@ +from unittest.mock import Mock +from aiounittest import AsyncTestCase + +import aiohttp # pylint: disable=unused-import + +from botbuilder.integration.applicationinsights.aiohttp import ( + aiohttp_telemetry_middleware, + AiohttpTelemetryProcessor, +) + + +class TestAiohttpTelemetryProcessor(AsyncTestCase): + # pylint: disable=protected-access + def test_can_process(self): + assert AiohttpTelemetryProcessor.detect_aiohttp() + assert AiohttpTelemetryProcessor().can_process() + + def test_retrieve_aiohttp_body(self): + aiohttp_telemetry_middleware._REQUEST_BODIES = Mock() + aiohttp_telemetry_middleware._REQUEST_BODIES.pop = Mock( + return_value="test body" + ) + assert aiohttp_telemetry_middleware.retrieve_aiohttp_body() == "test body" + + assert AiohttpTelemetryProcessor().get_request_body() == "test body" + aiohttp_telemetry_middleware._REQUEST_BODIES = {} diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_telemetry_middleware.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_telemetry_middleware.py new file mode 100644 index 000000000..673040b4b --- /dev/null +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/tests/test_aiohttp_telemetry_middleware.py @@ -0,0 +1,35 @@ +from asyncio import Future +from unittest.mock import Mock, MagicMock +from aiounittest import AsyncTestCase + +from botbuilder.integration.applicationinsights.aiohttp import ( + bot_telemetry_middleware, + aiohttp_telemetry_middleware, +) + + +class TestAiohttpTelemetryMiddleware(AsyncTestCase): + # pylint: disable=protected-access + async def test_bot_telemetry_middleware(self): + req = Mock() + req.headers = {"Content-Type": "application/json"} + req.json = MagicMock(return_value=Future()) + req.json.return_value.set_result("mock body") + + async def handler(value): + return value + + sut = await bot_telemetry_middleware(req, handler) + + assert "mock body" in aiohttp_telemetry_middleware._REQUEST_BODIES.values() + aiohttp_telemetry_middleware._REQUEST_BODIES.clear() + assert req == sut + + def test_retrieve_aiohttp_body(self): + aiohttp_telemetry_middleware._REQUEST_BODIES = Mock() + aiohttp_telemetry_middleware._REQUEST_BODIES.pop = Mock( + return_value="test body" + ) + assert aiohttp_telemetry_middleware.retrieve_aiohttp_body() == "test body" + + aiohttp_telemetry_middleware._REQUEST_BODIES = {} From 9b4d065443e80ffd3b6b9d8473a74b1d5f20d15a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 11 Feb 2020 15:24:40 -0600 Subject: [PATCH 278/616] Added CertificateAppCredentials and modified BotFrameworkAdapter to allow cert info being passed in. --- .../botbuilder/core/bot_framework_adapter.py | 89 ++++++++++++------- .../botframework/connector/auth/__init__.py | 47 +++++----- .../auth/certificate_app_credentials.py | 42 +++++++++ .../auth/certificate_authenticator.py | 48 ++++++++++ 4 files changed, 172 insertions(+), 54 deletions(-) create mode 100644 libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py create mode 100644 libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 688f7ccda..c92890934 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -22,7 +22,7 @@ JwtTokenValidation, SimpleCredentialProvider, SkillValidation, -) + CertificateAppCredentials) from botframework.connector.token_api import TokenApiClient from botframework.connector.token_api.models import TokenStatus from botbuilder.schema import ( @@ -76,13 +76,15 @@ class BotFrameworkAdapterSettings: def __init__( self, app_id: str, - app_password: str, + app_password: str = None, channel_auth_tenant: str = None, oauth_endpoint: str = None, open_id_metadata: str = None, channel_service: str = None, channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, + certificate_thumbprint: str = None, + certificate_private_key: str = None, ): """ Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. @@ -113,6 +115,8 @@ def __init__( self.channel_service = channel_service self.channel_provider = channel_provider self.auth_configuration = auth_configuration or AuthenticationConfiguration() + self.certificate_thumbprint = certificate_thumbprint + self.certificate_private_key = certificate_private_key class BotFrameworkAdapter(BotAdapter, UserTokenProvider): @@ -141,23 +145,42 @@ def __init__(self, settings: BotFrameworkAdapterSettings): """ super(BotFrameworkAdapter, self).__init__() self.settings = settings or BotFrameworkAdapterSettings("", "") + + # If settings.certificateThumbprint & settings.certificatePrivateKey are provided, + # use CertificateAppCredentials. + if settings.certificate_thumbprint and settings.certificate_private_key: + self._credentials = CertificateAppCredentials( + self.settings.app_id, + self.settings.certificate_thumbprint, + self.settings.certificate_private_key, + self.settings.channel_auth_tenant, + ) + self._credential_provider = SimpleCredentialProvider( + self.settings.app_id, "" + ) + else: + self._credentials = MicrosoftAppCredentials( + self.settings.app_id, + self.settings.app_password, + self.settings.channel_auth_tenant, + ) + self._credential_provider = SimpleCredentialProvider( + self.settings.app_id, self.settings.app_password + ) + + self._is_emulating_oauth_cards = False + + # If no channelService or openIdMetadata values were passed in the settings, check the + # process' Environment Variables for values. + # These values may be set when a bot is provisioned on Azure and if so are required for + # the bot to properly work in Public Azure or a National Cloud. self.settings.channel_service = self.settings.channel_service or os.environ.get( AuthenticationConstants.CHANNEL_SERVICE ) - self.settings.open_id_metadata = ( self.settings.open_id_metadata or os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) ) - self._credentials = MicrosoftAppCredentials( - self.settings.app_id, - self.settings.app_password, - self.settings.channel_auth_tenant, - ) - self._credential_provider = SimpleCredentialProvider( - self.settings.app_id, self.settings.app_password - ) - self._is_emulating_oauth_cards = False if self.settings.open_id_metadata: ChannelValidation.open_id_metadata_endpoint = self.settings.open_id_metadata @@ -878,35 +901,39 @@ async def create_connector_client( :return: An instance of the :class:`ConnectorClient` class """ + + # Anonymous claims and non-skill claims should fall through without modifying the scope. + credentials = self._credentials + if identity: bot_app_id_claim = identity.claims.get( AuthenticationConstants.AUDIENCE_CLAIM ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) - credentials = None if bot_app_id_claim and SkillValidation.is_skill_claim(identity.claims): scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) - password = await self._credential_provider.get_app_password( - bot_app_id_claim - ) - credentials = MicrosoftAppCredentials( - bot_app_id_claim, password, oauth_scope=scope - ) - if ( - self.settings.channel_provider - and self.settings.channel_provider.is_government() - ): - credentials.oauth_endpoint = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + # Do nothing, if the current credentials and its scope are valid for the skill. + # i.e. the adapter instance is pre-configured to talk with one skill. + # Otherwise we will create a new instance of the AppCredentials + # so self._credentials.oauth_scope isn't overridden. + if self._credentials.oauth_scope != scope: + password = await self._credential_provider.get_app_password( + bot_app_id_claim ) - credentials.oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + credentials = MicrosoftAppCredentials( + bot_app_id_claim, password, oauth_scope=scope ) - else: - credentials = self._credentials - else: - credentials = self._credentials + if ( + self.settings.channel_provider + and self.settings.channel_provider.is_government() + ): + credentials.oauth_endpoint = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + ) + credentials.oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) client_key = ( f"{service_url}{credentials.microsoft_app_id if credentials else ''}" diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index 8d90791bb..bc97e67dc 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -1,23 +1,24 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- -# pylint: disable=missing-docstring -from .authentication_constants import * -from .government_constants import * -from .channel_provider import * -from .simple_channel_provider import * -from .microsoft_app_credentials import * -from .claims_identity import * -from .jwt_token_validation import * -from .credential_provider import * -from .channel_validation import * -from .emulator_validation import * -from .jwt_token_extractor import * -from .authentication_configuration import * +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# +# Code generated by Microsoft (R) AutoRest Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is +# regenerated. +# -------------------------------------------------------------------------- +# pylint: disable=missing-docstring +from .authentication_constants import * +from .government_constants import * +from .channel_provider import * +from .simple_channel_provider import * +from .microsoft_app_credentials import * +from .certificate_app_credentials import * +from .claims_identity import * +from .jwt_token_validation import * +from .credential_provider import * +from .channel_validation import * +from .emulator_validation import * +from .jwt_token_extractor import * +from .authentication_configuration import * diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py new file mode 100644 index 000000000..3c3136abf --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from .app_credentials import AppCredentials +from .authenticator import Authenticator +from .certificate_authenticator import CertificateAuthenticator + + +class CertificateAppCredentials(AppCredentials, ABC): + """ + CertificateAppCredentials auth implementation. + """ + + def __init__( + self, + app_id: str, + certificate_thumbprint: str, + certificate_private_key: str, + channel_auth_tenant: str = None, + oauth_scope: str = None, + ): + super().__init__( + channel_auth_tenant=channel_auth_tenant, oauth_scope=oauth_scope + ) + self.microsoft_app_id = app_id + self.certificate_thumbprint = certificate_thumbprint + self.certificate_private_key = certificate_private_key + + def _build_authenticator(self) -> Authenticator: + """ + Returns an Authenticator suitable for certificate auth. + :return: An Authenticator object + """ + return CertificateAuthenticator( + app_id=self.microsoft_app_id, + certificate_thumbprint=self.certificate_thumbprint, + certificate_private_key=self.certificate_private_key, + authority=self.oauth_endpoint, + scope=self.oauth_scope, + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py b/libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py new file mode 100644 index 000000000..ff96c65ff --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from msal import ConfidentialClientApplication + +from botframework.connector.auth.authenticator import Authenticator + + +class CertificateAuthenticator(Authenticator, ABC): + """ + Retrieves a token using a certificate. + + This class is using MSAL for AAD authentication. + + For certificate creation and setup see: + https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#client-credentials-with-certificate + """ + + def __init__( + self, + app_id: str, + certificate_thumbprint: str, + certificate_private_key: str, + authority: str, + scope: str, + ): + self.app = ConfidentialClientApplication( + client_id=app_id, + authority=authority, + client_credential={ + "thumbprint": certificate_thumbprint, + "private_key": certificate_private_key, + }, + ) + + self.scopes = [scope] + + def acquire_token(self): + # Firstly, looks up a token from cache + # Since we are looking for token for the current app, NOT for an end user, + # notice we give account parameter as None. + auth_token = self.app.acquire_token_silent(self.scopes, account=None) + if not auth_token: + # No suitable token exists in cache. Let's get a new one from AAD. + auth_token = self.app.acquire_token_for_client(scopes=self.scopes) + return auth_token From ffa4c732c5f7a85ebb549da2ef5e4e15e5e4aaad Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 11 Feb 2020 15:33:54 -0600 Subject: [PATCH 279/616] Added comments for CertificateAppCredentials use. --- .../botbuilder/core/bot_framework_adapter.py | 9 +++++++++ .../connector/auth/certificate_app_credentials.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index c92890934..2e3eb8c73 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -106,6 +106,15 @@ def __init__( :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` :param auth_configuration: :type auth_configuration: :class:`botframework.connector.auth.AuthenticationConfiguration` + :param certificate_thumbprint: X509 thumbprint + :type certificate_thumbprint: str + :param certificate_private_key: X509 private key + :type certificate_private_key: str + + .. remarks:: + For credentials authorization, both app_id and app_password are required. + For certificate authorization, app_id, certificate_thumbprint, and certificate_private_key are required. + """ self.app_id = app_id self.app_password = app_password diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index 3c3136abf..7baea08de 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -11,6 +11,9 @@ class CertificateAppCredentials(AppCredentials, ABC): """ CertificateAppCredentials auth implementation. + + See: + https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#client-credentials-with-certificate """ def __init__( From 82bd0a53193d9509e3cbf2298602540d720315e3 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 12 Feb 2020 12:09:39 -0600 Subject: [PATCH 280/616] Simplified AppCredentials object model --- .../botbuilder/core/bot_framework_adapter.py | 2 + .../connector/auth/app_credentials.py | 26 ++++------ .../connector/auth/authenticator.py | 15 ------ .../auth/certificate_app_credentials.py | 46 +++++++++++------- .../auth/certificate_authenticator.py | 48 ------------------- .../auth/credentials_authenticator.py | 27 ----------- .../auth/microsoft_app_credentials.py | 38 +++++++++------ 7 files changed, 65 insertions(+), 137 deletions(-) delete mode 100644 libraries/botframework-connector/botframework/connector/auth/authenticator.py delete mode 100644 libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py delete mode 100644 libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 2e3eb8c73..bdf982089 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +# pylint: disable=too-many-lines + import asyncio import base64 import json diff --git a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py index f10373988..b7beb6a09 100644 --- a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py @@ -8,12 +8,12 @@ from msrest.authentication import Authentication from botframework.connector.auth import AuthenticationConstants -from botframework.connector.auth.authenticator import Authenticator class AppCredentials(Authentication): """ - MicrosoftAppCredentials auth implementation and cache. + Base class for token retrieval. Subclasses MUST override get_token in + order to supply a valid token for the specific credentials. """ schema = "Bearer" @@ -29,7 +29,10 @@ class AppCredentials(Authentication): cache = {} def __init__( - self, channel_auth_tenant: str = None, oauth_scope: str = None, + self, + app_id: str = None, + channel_auth_tenant: str = None, + oauth_scope: str = None, ): """ Initializes a new instance of MicrosoftAppCredentials class @@ -47,8 +50,7 @@ def __init__( oauth_scope or AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE ) - self.microsoft_app_id = None - self.authenticator: Authenticator = None + self.microsoft_app_id = app_id @staticmethod def trust_service_url(service_url: str, expiration=None): @@ -84,7 +86,7 @@ def _is_trusted_url(host: str) -> bool: # pylint: disable=arguments-differ def signed_session(self, session: requests.Session = None) -> requests.Session: """ - Gets the signed session. + Gets the signed session. This is called by the msrest package :returns: Signed requests.Session object """ if not session: @@ -102,16 +104,8 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: return session def get_token(self) -> str: - return self._get_authenticator().acquire_token()["access_token"] - - def _get_authenticator(self) -> Authenticator: - if not self.authenticator: - self.authenticator = self._build_authenticator() - return self.authenticator - - def _build_authenticator(self) -> Authenticator: """ - Returns an appropriate Authenticator that is provided by a subclass. - :return: An Authenticator object + Returns a token for the current AppCredentials. + :return: The token """ raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/authenticator.py b/libraries/botframework-connector/botframework/connector/auth/authenticator.py deleted file mode 100644 index 99d15e8e4..000000000 --- a/libraries/botframework-connector/botframework/connector/auth/authenticator.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class Authenticator: - """ - A provider of tokens - """ - - def acquire_token(self): - """ - Returns a token. The implementation is supplied by a subclass. - :return: The string token - """ - raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index 7baea08de..1c29275fa 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -3,14 +3,14 @@ from abc import ABC +from msal import ConfidentialClientApplication + from .app_credentials import AppCredentials -from .authenticator import Authenticator -from .certificate_authenticator import CertificateAuthenticator class CertificateAppCredentials(AppCredentials, ABC): """ - CertificateAppCredentials auth implementation. + AppCredentials implementation using a certificate. See: https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#client-credentials-with-certificate @@ -24,22 +24,34 @@ def __init__( channel_auth_tenant: str = None, oauth_scope: str = None, ): + # super will set proper scope and endpoint. super().__init__( - channel_auth_tenant=channel_auth_tenant, oauth_scope=oauth_scope + app_id=app_id, + channel_auth_tenant=channel_auth_tenant, + oauth_scope=oauth_scope, ) - self.microsoft_app_id = app_id - self.certificate_thumbprint = certificate_thumbprint - self.certificate_private_key = certificate_private_key - def _build_authenticator(self) -> Authenticator: - """ - Returns an Authenticator suitable for certificate auth. - :return: An Authenticator object - """ - return CertificateAuthenticator( - app_id=self.microsoft_app_id, - certificate_thumbprint=self.certificate_thumbprint, - certificate_private_key=self.certificate_private_key, + self.scopes = [self.oauth_scope] + self.app = ConfidentialClientApplication( + client_id=self.microsoft_app_id, authority=self.oauth_endpoint, - scope=self.oauth_scope, + client_credential={ + "thumbprint": certificate_thumbprint, + "private_key": certificate_private_key, + }, ) + + def get_token(self) -> str: + """ + Implementation of AppCredentials.get_token. + :return: The access token for the given certificate. + """ + + # Firstly, looks up a token from cache + # Since we are looking for token for the current app, NOT for an end user, + # notice we give account parameter as None. + auth_token = self.app.acquire_token_silent(self.scopes, account=None) + if not auth_token: + # No suitable token exists in cache. Let's get a new one from AAD. + auth_token = self.app.acquire_token_for_client(scopes=self.scopes) + return auth_token["access_token"] diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py b/libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py deleted file mode 100644 index ff96c65ff..000000000 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_authenticator.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC - -from msal import ConfidentialClientApplication - -from botframework.connector.auth.authenticator import Authenticator - - -class CertificateAuthenticator(Authenticator, ABC): - """ - Retrieves a token using a certificate. - - This class is using MSAL for AAD authentication. - - For certificate creation and setup see: - https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Client-Credentials#client-credentials-with-certificate - """ - - def __init__( - self, - app_id: str, - certificate_thumbprint: str, - certificate_private_key: str, - authority: str, - scope: str, - ): - self.app = ConfidentialClientApplication( - client_id=app_id, - authority=authority, - client_credential={ - "thumbprint": certificate_thumbprint, - "private_key": certificate_private_key, - }, - ) - - self.scopes = [scope] - - def acquire_token(self): - # Firstly, looks up a token from cache - # Since we are looking for token for the current app, NOT for an end user, - # notice we give account parameter as None. - auth_token = self.app.acquire_token_silent(self.scopes, account=None) - if not auth_token: - # No suitable token exists in cache. Let's get a new one from AAD. - auth_token = self.app.acquire_token_for_client(scopes=self.scopes) - return auth_token diff --git a/libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py b/libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py deleted file mode 100644 index cbb0edb10..000000000 --- a/libraries/botframework-connector/botframework/connector/auth/credentials_authenticator.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC - -from msal import ConfidentialClientApplication - -from botframework.connector.auth.authenticator import Authenticator - - -class CredentialsAuthenticator(Authenticator, ABC): - def __init__(self, app_id: str, app_password: str, authority: str, scope: str): - self.app = ConfidentialClientApplication( - client_id=app_id, client_credential=app_password, authority=authority - ) - - self.scopes = [scope] - - def acquire_token(self): - # Firstly, looks up a token from cache - # Since we are looking for token for the current app, NOT for an end user, - # notice we give account parameter as None. - auth_token = self.app.acquire_token_silent(self.scopes, account=None) - if not auth_token: - # No suitable token exists in cache. Let's get a new one from AAD. - auth_token = self.app.acquire_token_for_client(scopes=self.scopes) - return auth_token diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index e8c1b5c3e..d49f3c5ed 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -3,14 +3,14 @@ from abc import ABC +from msal import ConfidentialClientApplication + from .app_credentials import AppCredentials -from .authenticator import Authenticator -from .credentials_authenticator import CredentialsAuthenticator class MicrosoftAppCredentials(AppCredentials, ABC): """ - MicrosoftAppCredentials auth implementation. + AppCredentials implementation using application ID and password. """ MICROSOFT_APP_ID = "MicrosoftAppId" @@ -23,20 +23,30 @@ def __init__( channel_auth_tenant: str = None, oauth_scope: str = None, ): + # super will set proper scope and endpoint. super().__init__( - channel_auth_tenant=channel_auth_tenant, oauth_scope=oauth_scope + app_id=app_id, + channel_auth_tenant=channel_auth_tenant, + oauth_scope=oauth_scope, ) - self.microsoft_app_id = app_id + self.microsoft_app_password = password + self.app = ConfidentialClientApplication( + client_id=app_id, client_credential=password, authority=self.oauth_endpoint + ) + self.scopes = [self.oauth_scope] - def _build_authenticator(self) -> Authenticator: + def get_token(self) -> str: """ - Returns an Authenticator suitable for credential auth. - :return: An Authenticator object + Implementation of AppCredentials.get_token. + :return: The access token for the given app id and password. """ - return CredentialsAuthenticator( - app_id=self.microsoft_app_id, - app_password=self.microsoft_app_password, - authority=self.oauth_endpoint, - scope=self.oauth_scope, - ) + + # Firstly, looks up a token from cache + # Since we are looking for token for the current app, NOT for an end user, + # notice we give account parameter as None. + auth_token = self.app.acquire_token_silent(self.scopes, account=None) + if not auth_token: + # No suitable token exists in cache. Let's get a new one from AAD. + auth_token = self.app.acquire_token_for_client(scopes=self.scopes) + return auth_token["access_token"] From a37daebe46b98e833f378f240ff0be8dc339550c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 12 Feb 2020 12:32:16 -0600 Subject: [PATCH 281/616] Updated botframework-connector setup.py requirements. --- libraries/botframework-connector/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 6c2b30e16..41de1b5c9 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -12,6 +12,7 @@ "PyJWT==1.5.3", "botbuilder-schema>=4.7.1", "adal==1.2.1", + "msal==1.1.0" ] root = os.path.abspath(os.path.dirname(__file__)) From 6990185c38b6e655d64d1154b4ef60178eb3550a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 12 Feb 2020 12:47:34 -0600 Subject: [PATCH 282/616] Change AppCredentials get_token to get_access_token to make other references happy --- .../botframework/connector/auth/app_credentials.py | 4 ++-- .../connector/auth/certificate_app_credentials.py | 2 +- .../botframework/connector/auth/microsoft_app_credentials.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py index b7beb6a09..f8c8fb979 100644 --- a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py @@ -97,13 +97,13 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: if not self.microsoft_app_id: session.headers.pop("Authorization", None) else: - auth_token = self.get_token() + auth_token = self.get_access_token() header = "{} {}".format("Bearer", auth_token) session.headers["Authorization"] = header return session - def get_token(self) -> str: + def get_access_token(self) -> str: """ Returns a token for the current AppCredentials. :return: The token diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index 1c29275fa..8d7bf1568 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -41,7 +41,7 @@ def __init__( }, ) - def get_token(self) -> str: + def get_access_token(self) -> str: """ Implementation of AppCredentials.get_token. :return: The access token for the given certificate. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index d49f3c5ed..24ebd0523 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -36,7 +36,7 @@ def __init__( ) self.scopes = [self.oauth_scope] - def get_token(self) -> str: + def get_access_token(self) -> str: """ Implementation of AppCredentials.get_token. :return: The access token for the given app id and password. From 62932419eadb27046ab875bfcfd711916cb9ad2f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 12 Feb 2020 13:03:25 -0600 Subject: [PATCH 283/616] Correction for pytest --- .../botbuilder/core/bot_framework_adapter.py | 6 +- .../connector/auth/app_credentials.py | 4 +- .../auth/certificate_app_credentials.py | 2 +- .../auth/microsoft_app_credentials.py | 2 +- .../tests/test_microsoft_app_credentials.py | 66 +++++++++---------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index bdf982089..19be9f660 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -157,9 +157,9 @@ def __init__(self, settings: BotFrameworkAdapterSettings): super(BotFrameworkAdapter, self).__init__() self.settings = settings or BotFrameworkAdapterSettings("", "") - # If settings.certificateThumbprint & settings.certificatePrivateKey are provided, + # If settings.certificate_thumbprint & settings.certificate_private_key are provided, # use CertificateAppCredentials. - if settings.certificate_thumbprint and settings.certificate_private_key: + if self.settings.certificate_thumbprint and settings.certificate_private_key: self._credentials = CertificateAppCredentials( self.settings.app_id, self.settings.certificate_thumbprint, @@ -181,7 +181,7 @@ def __init__(self, settings: BotFrameworkAdapterSettings): self._is_emulating_oauth_cards = False - # If no channelService or openIdMetadata values were passed in the settings, check the + # If no channel_service or open_id_metadata values were passed in the settings, check the # process' Environment Variables for values. # These values may be set when a bot is provisioned on Azure and if so are required for # the bot to properly work in Public Azure or a National Cloud. diff --git a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py index f8c8fb979..e83122334 100644 --- a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py @@ -12,7 +12,7 @@ class AppCredentials(Authentication): """ - Base class for token retrieval. Subclasses MUST override get_token in + Base class for token retrieval. Subclasses MUST override get_access_token in order to supply a valid token for the specific credentials. """ @@ -103,7 +103,7 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: return session - def get_access_token(self) -> str: + def get_access_token(self, force_refresh: bool = False) -> str: """ Returns a token for the current AppCredentials. :return: The token diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index 8d7bf1568..f6f6bc17e 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -41,7 +41,7 @@ def __init__( }, ) - def get_access_token(self) -> str: + def get_access_token(self, force_refresh: bool = False) -> str: """ Implementation of AppCredentials.get_token. :return: The access token for the given certificate. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 24ebd0523..639915595 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -36,7 +36,7 @@ def __init__( ) self.scopes = [self.oauth_scope] - def get_access_token(self) -> str: + def get_access_token(self, force_refresh: bool = False) -> str: """ Implementation of AppCredentials.get_token. :return: The access token for the given app id and password. diff --git a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py index c276b8e48..330219cd6 100644 --- a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py +++ b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py @@ -1,33 +1,33 @@ -import aiounittest - -from botframework.connector.auth import AuthenticationConstants, MicrosoftAppCredentials - - -class TestMicrosoftAppCredentials(aiounittest.AsyncTestCase): - async def test_app_credentials(self): - default_scope_case_1 = MicrosoftAppCredentials("some_app", "some_password") - assert ( - AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER - == default_scope_case_1.oauth_scope - ) - - # Use with default scope - default_scope_case_2 = MicrosoftAppCredentials( - "some_app", "some_password", "some_tenant" - ) - assert ( - AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER - == default_scope_case_2.oauth_scope - ) - - custom_scope = "some_scope" - custom_scope_case_1 = MicrosoftAppCredentials( - "some_app", "some_password", oauth_scope=custom_scope - ) - assert custom_scope_case_1.oauth_scope == custom_scope - - # Use with default scope - custom_scope_case_2 = MicrosoftAppCredentials( - "some_app", "some_password", "some_tenant", custom_scope - ) - assert custom_scope_case_2.oauth_scope == custom_scope +import aiounittest + +from botframework.connector.auth import AuthenticationConstants, MicrosoftAppCredentials + + +class TestMicrosoftAppCredentials(aiounittest.AsyncTestCase): + async def test_app_credentials(self): + default_scope_case_1 = MicrosoftAppCredentials("some_app", "some_password") + assert ( + AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + == default_scope_case_1.oauth_scope + ) + + # Use with default scope + default_scope_case_2 = MicrosoftAppCredentials( + "some_app", "some_password", "some_tenant" + ) + assert ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + == default_scope_case_2.oauth_scope + ) + + custom_scope = "some_scope" + custom_scope_case_1 = MicrosoftAppCredentials( + "some_app", "some_password", oauth_scope=custom_scope + ) + assert custom_scope_case_1.oauth_scope == custom_scope + + # Use with default scope + custom_scope_case_2 = MicrosoftAppCredentials( + "some_app", "some_password", "some_tenant", custom_scope + ) + assert custom_scope_case_2.oauth_scope == custom_scope From 52e87b1cfc9549fba37c45c8769a662d7ec89b2c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 12 Feb 2020 14:12:24 -0600 Subject: [PATCH 284/616] Corrected scope test --- .../tests/test_microsoft_app_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py index 330219cd6..f4cb2516d 100644 --- a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py +++ b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py @@ -7,7 +7,7 @@ class TestMicrosoftAppCredentials(aiounittest.AsyncTestCase): async def test_app_credentials(self): default_scope_case_1 = MicrosoftAppCredentials("some_app", "some_password") assert ( - AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == default_scope_case_1.oauth_scope ) From a338dde3a61b6f3c35fa42f1eb863c367626a91d Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Wed, 12 Feb 2020 17:57:26 -0800 Subject: [PATCH 285/616] Fixes #534 --- .../botbuilder/core/teams/teams_helper.py | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index f9d294c39..f9b6a30ac 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -12,21 +12,21 @@ # Optimization: The dependencies dictionary could be cached here, # and shared between the two methods. +DEPENDICIES = [ + schema_cls + for key, schema_cls in getmembers(schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) +] +DEPENDICIES += [ + schema_cls + for key, schema_cls in getmembers(teams_schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) +] +DEPENDICIES_DICT = {dependency.__name__: dependency for dependency in DEPENDICIES} def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> Model: - dependencies = [ - schema_cls - for key, schema_cls in getmembers(schema) - if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) - ] - dependencies += [ - schema_cls - for key, schema_cls in getmembers(teams_schema) - if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) - ] - dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} - deserializer = Deserializer(dependencies_dict) + deserializer = Deserializer(DEPENDICIES_DICT) return deserializer(msrest_cls.__name__, dict_to_deserialize) @@ -34,17 +34,6 @@ def serializer_helper(object_to_serialize: Model) -> dict: if object_to_serialize is None: return None - dependencies = [ - schema_cls - for key, schema_cls in getmembers(schema) - if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) - ] - dependencies += [ - schema_cls - for key, schema_cls in getmembers(teams_schema) - if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) - ] - dependencies_dict = {dependency.__name__: dependency for dependency in dependencies} - serializer = Serializer(dependencies_dict) + serializer = Serializer(DEPENDICIES_DICT) # pylint: disable=protected-access return serializer._serialize(object_to_serialize) From 5cb51037728b219450c29614263856338a43ae89 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 13 Feb 2020 08:30:40 -0600 Subject: [PATCH 286/616] Lazy creating MSAL app to make some tests happy --- .../auth/certificate_app_credentials.py | 28 ++++++++++++------- .../auth/microsoft_app_credentials.py | 16 +++++++---- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index f6f6bc17e..4bd0e2815 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -32,14 +32,9 @@ def __init__( ) self.scopes = [self.oauth_scope] - self.app = ConfidentialClientApplication( - client_id=self.microsoft_app_id, - authority=self.oauth_endpoint, - client_credential={ - "thumbprint": certificate_thumbprint, - "private_key": certificate_private_key, - }, - ) + self.app = None + self.certificate_thumbprint = certificate_thumbprint + self.certificate_private_key = certificate_private_key def get_access_token(self, force_refresh: bool = False) -> str: """ @@ -50,8 +45,21 @@ def get_access_token(self, force_refresh: bool = False) -> str: # Firstly, looks up a token from cache # Since we are looking for token for the current app, NOT for an end user, # notice we give account parameter as None. - auth_token = self.app.acquire_token_silent(self.scopes, account=None) + auth_token = self.__get_msal_app().acquire_token_silent(self.scopes, account=None) if not auth_token: # No suitable token exists in cache. Let's get a new one from AAD. - auth_token = self.app.acquire_token_for_client(scopes=self.scopes) + auth_token = self.__get_msal_app().acquire_token_for_client(scopes=self.scopes) return auth_token["access_token"] + + def __get_msal_app(self): + if not self.app: + self.app = ConfidentialClientApplication( + client_id=self.microsoft_app_id, + authority=self.oauth_endpoint, + client_credential={ + "thumbprint": self.certificate_thumbprint, + "private_key": self.certificate_private_key, + }, + ) + + return self.app diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 639915595..1efa25b30 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -31,9 +31,7 @@ def __init__( ) self.microsoft_app_password = password - self.app = ConfidentialClientApplication( - client_id=app_id, client_credential=password, authority=self.oauth_endpoint - ) + self.app = None self.scopes = [self.oauth_scope] def get_access_token(self, force_refresh: bool = False) -> str: @@ -45,8 +43,16 @@ def get_access_token(self, force_refresh: bool = False) -> str: # Firstly, looks up a token from cache # Since we are looking for token for the current app, NOT for an end user, # notice we give account parameter as None. - auth_token = self.app.acquire_token_silent(self.scopes, account=None) + auth_token = self.__get_msal_app().acquire_token_silent(self.scopes, account=None) if not auth_token: # No suitable token exists in cache. Let's get a new one from AAD. - auth_token = self.app.acquire_token_for_client(scopes=self.scopes) + auth_token = self.__get_msal_app().acquire_token_for_client(scopes=self.scopes) return auth_token["access_token"] + + def __get_msal_app(self): + if not self.app: + self.app = ConfidentialClientApplication( + client_id=self.microsoft_app_id, client_credential=self.microsoft_app_password, authority=self.oauth_endpoint + ) + + return self.app From 238cab0379cd9a0cfa38d639d883d47bed71a98c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 13 Feb 2020 08:34:31 -0600 Subject: [PATCH 287/616] Black fixes --- .../connector/auth/certificate_app_credentials.py | 8 ++++++-- .../connector/auth/microsoft_app_credentials.py | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index 4bd0e2815..298bb581f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -45,10 +45,14 @@ def get_access_token(self, force_refresh: bool = False) -> str: # Firstly, looks up a token from cache # Since we are looking for token for the current app, NOT for an end user, # notice we give account parameter as None. - auth_token = self.__get_msal_app().acquire_token_silent(self.scopes, account=None) + auth_token = self.__get_msal_app().acquire_token_silent( + self.scopes, account=None + ) if not auth_token: # No suitable token exists in cache. Let's get a new one from AAD. - auth_token = self.__get_msal_app().acquire_token_for_client(scopes=self.scopes) + auth_token = self.__get_msal_app().acquire_token_for_client( + scopes=self.scopes + ) return auth_token["access_token"] def __get_msal_app(self): diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 1efa25b30..6bcf80d08 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -43,16 +43,22 @@ def get_access_token(self, force_refresh: bool = False) -> str: # Firstly, looks up a token from cache # Since we are looking for token for the current app, NOT for an end user, # notice we give account parameter as None. - auth_token = self.__get_msal_app().acquire_token_silent(self.scopes, account=None) + auth_token = self.__get_msal_app().acquire_token_silent( + self.scopes, account=None + ) if not auth_token: # No suitable token exists in cache. Let's get a new one from AAD. - auth_token = self.__get_msal_app().acquire_token_for_client(scopes=self.scopes) + auth_token = self.__get_msal_app().acquire_token_for_client( + scopes=self.scopes + ) return auth_token["access_token"] def __get_msal_app(self): if not self.app: self.app = ConfidentialClientApplication( - client_id=self.microsoft_app_id, client_credential=self.microsoft_app_password, authority=self.oauth_endpoint + client_id=self.microsoft_app_id, + client_credential=self.microsoft_app_password, + authority=self.oauth_endpoint, ) return self.app From a7858090e4c85922b22dcd0bf662334cfc59bacb Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 13 Feb 2020 08:38:22 -0600 Subject: [PATCH 288/616] More black fixes --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 3 ++- libraries/botframework-connector/setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 19be9f660..3f248147f 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -24,7 +24,8 @@ JwtTokenValidation, SimpleCredentialProvider, SkillValidation, - CertificateAppCredentials) + CertificateAppCredentials, +) from botframework.connector.token_api import TokenApiClient from botframework.connector.token_api.models import TokenStatus from botbuilder.schema import ( diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 41de1b5c9..537d6f60b 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -12,7 +12,7 @@ "PyJWT==1.5.3", "botbuilder-schema>=4.7.1", "adal==1.2.1", - "msal==1.1.0" + "msal==1.1.0", ] root = os.path.abspath(os.path.dirname(__file__)) From 2663c94beaecd2887a203f32bcbb76066c773149 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 13 Feb 2020 09:33:46 -0600 Subject: [PATCH 289/616] Provided override for whether a request is authorized in AppCredentials --- .../botframework/connector/auth/app_credentials.py | 9 ++++++--- .../connector/auth/microsoft_app_credentials.py | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py index e83122334..148504c45 100644 --- a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py @@ -92,9 +92,7 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: if not session: session = requests.Session() - # If there is no microsoft_app_id then there shouldn't be an - # "Authorization" header on the outgoing activity. - if not self.microsoft_app_id: + if not self._should_authorize(session): session.headers.pop("Authorization", None) else: auth_token = self.get_access_token() @@ -103,6 +101,11 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: return session + def _should_authorize( + self, session: requests.Session # pylint: disable=unused-argument + ) -> bool: + return True + def get_access_token(self, force_refresh: bool = False) -> str: """ Returns a token for the current AppCredentials. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 6bcf80d08..35fa21566 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -3,6 +3,7 @@ from abc import ABC +import requests from msal import ConfidentialClientApplication from .app_credentials import AppCredentials @@ -62,3 +63,11 @@ def __get_msal_app(self): ) return self.app + + def _should_authorize(self, session: requests.Session) -> bool: + """ + Override of AppCredentials._should_authorize + :param session: + :return: + """ + return self.microsoft_app_id and self.microsoft_app_password From 73bcddbaa19c76184d8799bbc4fb5cd60c967d43 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 13 Feb 2020 15:52:31 -0800 Subject: [PATCH 290/616] aiohttp appinsights consistent package versions with the rest of the libraries --- .../setup.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py index 28b8dd9bb..ea8c2f359 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py @@ -6,14 +6,14 @@ REQUIRES = [ "applicationinsights>=0.11.9", - "botbuilder-schema>=4.4.0b1", - "botframework-connector>=4.4.0b1", - "botbuilder-core>=4.4.0b1", - "botbuilder-applicationinsights>=4.4.0b1", + "aiohttp==3.6.2", + "botbuilder-schema>=4.7.1", + "botframework-connector>=4.7.1", + "botbuilder-core>=4.7.1", + "botbuilder-applicationinsights>=4.7.1", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", - "aiohttp==3.5.4", ] root = os.path.abspath(os.path.dirname(__file__)) From 18b824228f1da48067e2db63de1b8d3d40a5c0ed Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 14 Feb 2020 15:17:30 -0600 Subject: [PATCH 291/616] Fixes failing waterfall tests on Python 3.8. Also corrected various warnings related to pytest. --- libraries/botbuilder-ai/tests/qna/test_qna.py | 1928 +++++++++-------- .../tests/test_telemetry_waterfall.py | 351 ++- .../botbuilder/core/adapters/test_adapter.py | 930 ++++---- .../botbuilder/core/show_typing_middleware.py | 190 +- .../botbuilder-core/tests/simple_adapter.py | 120 +- .../teams/test_teams_activity_handler.py | 1450 ++++++------- .../tests/test_activity_handler.py | 204 +- .../botbuilder-core/tests/test_bot_adapter.py | 172 +- .../botbuilder-core/tests/test_bot_state.py | 968 ++++----- .../botbuilder/dialogs/waterfall_dialog.py | 2 +- 10 files changed, 3161 insertions(+), 3154 deletions(-) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 10dbd5e89..cae752861 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -1,963 +1,965 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# pylint: disable=protected-access - -import json -from os import path -from typing import List, Dict -import unittest -from unittest.mock import patch -from aiohttp import ClientSession - -import aiounittest -from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions -from botbuilder.ai.qna.models import ( - FeedbackRecord, - Metadata, - QueryResult, - QnARequestContext, -) -from botbuilder.ai.qna.utils import QnATelemetryConstants -from botbuilder.core import BotAdapter, BotTelemetryClient, TurnContext -from botbuilder.core.adapters import TestAdapter -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, -) - - -class TestContext(TurnContext): - def __init__(self, request): - super().__init__(TestAdapter(), request) - self.sent: List[Activity] = list() - - self.on_send_activities(self.capture_sent_activities) - - async def capture_sent_activities( - self, context: TurnContext, activities, next - ): # pylint: disable=unused-argument - self.sent += activities - context.responded = True - - -class QnaApplicationTest(aiounittest.AsyncTestCase): - # Note this is NOT a real QnA Maker application ID nor a real QnA Maker subscription-key - # theses are GUIDs edited to look right to the parsing and validation code. - - _knowledge_base_id: str = "f028d9k3-7g9z-11d3-d300-2b8x98227q8w" - _endpoint_key: str = "1k997n7w-207z-36p3-j2u1-09tas20ci6011" - _host: str = "https://dummyqnahost.azurewebsites.net/qnamaker" - - tests_endpoint = QnAMakerEndpoint(_knowledge_base_id, _endpoint_key, _host) - - def test_qnamaker_construction(self): - # Arrange - endpoint = self.tests_endpoint - - # Act - qna = QnAMaker(endpoint) - endpoint = qna._endpoint - - # Assert - self.assertEqual( - "f028d9k3-7g9z-11d3-d300-2b8x98227q8w", endpoint.knowledge_base_id - ) - self.assertEqual("1k997n7w-207z-36p3-j2u1-09tas20ci6011", endpoint.endpoint_key) - self.assertEqual( - "https://dummyqnahost.azurewebsites.net/qnamaker", endpoint.host - ) - - def test_endpoint_with_empty_kbid(self): - empty_kbid = "" - - with self.assertRaises(TypeError): - QnAMakerEndpoint(empty_kbid, self._endpoint_key, self._host) - - def test_endpoint_with_empty_endpoint_key(self): - empty_endpoint_key = "" - - with self.assertRaises(TypeError): - QnAMakerEndpoint(self._knowledge_base_id, empty_endpoint_key, self._host) - - def test_endpoint_with_emptyhost(self): - with self.assertRaises(TypeError): - QnAMakerEndpoint(self._knowledge_base_id, self._endpoint_key, "") - - def test_qnamaker_with_none_endpoint(self): - with self.assertRaises(TypeError): - QnAMaker(None) - - def test_set_default_options_with_no_options_arg(self): - qna_without_options = QnAMaker(self.tests_endpoint) - options = qna_without_options._generate_answer_helper.options - - default_threshold = 0.3 - default_top = 1 - default_strict_filters = [] - - self.assertEqual(default_threshold, options.score_threshold) - self.assertEqual(default_top, options.top) - self.assertEqual(default_strict_filters, options.strict_filters) - - def test_options_passed_to_ctor(self): - options = QnAMakerOptions( - score_threshold=0.8, - timeout=9000, - top=5, - strict_filters=[Metadata("movie", "disney")], - ) - - qna_with_options = QnAMaker(self.tests_endpoint, options) - actual_options = qna_with_options._generate_answer_helper.options - - expected_threshold = 0.8 - expected_timeout = 9000 - expected_top = 5 - expected_strict_filters = [Metadata("movie", "disney")] - - self.assertEqual(expected_threshold, actual_options.score_threshold) - self.assertEqual(expected_timeout, actual_options.timeout) - self.assertEqual(expected_top, actual_options.top) - self.assertEqual( - expected_strict_filters[0].name, actual_options.strict_filters[0].name - ) - self.assertEqual( - expected_strict_filters[0].value, actual_options.strict_filters[0].value - ) - - async def test_returns_answer(self): - # Arrange - question: str = "how do I clean the stove?" - response_path: str = "ReturnsAnswer.json" - - # Act - result = await QnaApplicationTest._get_service_result(question, response_path) - - first_answer = result[0] - - # Assert - self.assertIsNotNone(result) - self.assertEqual(1, len(result)) - self.assertEqual( - "BaseCamp: You can use a damp rag to clean around the Power Pack", - first_answer.answer, - ) - - async def test_active_learning_enabled_status(self): - # Arrange - question: str = "how do I clean the stove?" - response_path: str = "ReturnsAnswer.json" - - # Act - result = await QnaApplicationTest._get_service_result_raw( - question, response_path - ) - - # Assert - self.assertIsNotNone(result) - self.assertEqual(1, len(result.answers)) - self.assertFalse(result.active_learning_enabled) - - async def test_returns_answer_using_options(self): - # Arrange - question: str = "up" - response_path: str = "AnswerWithOptions.json" - options = QnAMakerOptions( - score_threshold=0.8, top=5, strict_filters=[Metadata("movie", "disney")] - ) - - # Act - result = await QnaApplicationTest._get_service_result( - question, response_path, options=options - ) - - first_answer = result[0] - has_at_least_1_ans = True - first_metadata = first_answer.metadata[0] - - # Assert - self.assertIsNotNone(result) - self.assertEqual(has_at_least_1_ans, len(result) >= 1) - self.assertTrue(first_answer.answer[0]) - self.assertEqual("is a movie", first_answer.answer) - self.assertTrue(first_answer.score >= options.score_threshold) - self.assertEqual("movie", first_metadata.name) - self.assertEqual("disney", first_metadata.value) - - async def test_trace_test(self): - activity = Activity( - type=ActivityTypes.message, - text="how do I clean the stove?", - conversation=ConversationAccount(), - recipient=ChannelAccount(), - from_property=ChannelAccount(), - ) - - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - - context = TestContext(activity) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - result = await qna.get_answers(context) - - qna_trace_activities = list( - filter( - lambda act: act.type == "trace" and act.name == "QnAMaker", - context.sent, - ) - ) - trace_activity = qna_trace_activities[0] - - self.assertEqual("trace", trace_activity.type) - self.assertEqual("QnAMaker", trace_activity.name) - self.assertEqual("QnAMaker Trace", trace_activity.label) - self.assertEqual( - "https://www.qnamaker.ai/schemas/trace", trace_activity.value_type - ) - self.assertEqual(True, hasattr(trace_activity, "value")) - self.assertEqual(True, hasattr(trace_activity.value, "message")) - self.assertEqual(True, hasattr(trace_activity.value, "query_results")) - self.assertEqual(True, hasattr(trace_activity.value, "score_threshold")) - self.assertEqual(True, hasattr(trace_activity.value, "top")) - self.assertEqual(True, hasattr(trace_activity.value, "strict_filters")) - self.assertEqual( - self._knowledge_base_id, trace_activity.value.knowledge_base_id - ) - - return result - - async def test_returns_answer_with_timeout(self): - question: str = "how do I clean the stove?" - options = QnAMakerOptions(timeout=999999) - qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) - context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - result = await qna.get_answers(context, options) - - self.assertIsNotNone(result) - self.assertEqual( - options.timeout, qna._generate_answer_helper.options.timeout - ) - - async def test_telemetry_returns_answer(self): - # Arrange - question: str = "how do I clean the stove?" - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - log_personal_information = True - context = QnaApplicationTest._get_context(question, TestAdapter()) - qna = QnAMaker( - QnaApplicationTest.tests_endpoint, - telemetry_client=telemetry_client, - log_personal_information=log_personal_information, - ) - - # Act - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context) - - telemetry_args = telemetry_client.track_event.call_args_list[0][1] - telemetry_properties = telemetry_args["properties"] - telemetry_metrics = telemetry_args["measurements"] - number_of_args = len(telemetry_args) - first_answer = telemetry_args["properties"][ - QnATelemetryConstants.answer_property - ] - expected_answer = ( - "BaseCamp: You can use a damp rag to clean around the Power Pack" - ) - - # Assert - Check Telemetry logged. - self.assertEqual(1, telemetry_client.track_event.call_count) - self.assertEqual(3, number_of_args) - self.assertEqual("QnaMessage", telemetry_args["name"]) - self.assertTrue("answer" in telemetry_properties) - self.assertTrue("knowledgeBaseId" in telemetry_properties) - self.assertTrue("matchedQuestion" in telemetry_properties) - self.assertTrue("question" in telemetry_properties) - self.assertTrue("questionId" in telemetry_properties) - self.assertTrue("articleFound" in telemetry_properties) - self.assertEqual(expected_answer, first_answer) - self.assertTrue("score" in telemetry_metrics) - self.assertEqual(1, telemetry_metrics["score"]) - - # Assert - Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(1, len(results)) - self.assertEqual(expected_answer, results[0].answer) - self.assertEqual("Editorial", results[0].source) - - async def test_telemetry_returns_answer_when_no_answer_found_in_kb(self): - # Arrange - question: str = "gibberish question" - response_json = QnaApplicationTest._get_json_for_file("NoAnswerFoundInKb.json") - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - qna = QnAMaker( - QnaApplicationTest.tests_endpoint, - telemetry_client=telemetry_client, - log_personal_information=True, - ) - context = QnaApplicationTest._get_context(question, TestAdapter()) - - # Act - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context) - - telemetry_args = telemetry_client.track_event.call_args_list[0][1] - telemetry_properties = telemetry_args["properties"] - number_of_args = len(telemetry_args) - first_answer = telemetry_args["properties"][ - QnATelemetryConstants.answer_property - ] - expected_answer = "No Qna Answer matched" - expected_matched_question = "No Qna Question matched" - - # Assert - Check Telemetry logged. - self.assertEqual(1, telemetry_client.track_event.call_count) - self.assertEqual(3, number_of_args) - self.assertEqual("QnaMessage", telemetry_args["name"]) - self.assertTrue("answer" in telemetry_properties) - self.assertTrue("knowledgeBaseId" in telemetry_properties) - self.assertTrue("matchedQuestion" in telemetry_properties) - self.assertEqual( - expected_matched_question, - telemetry_properties[QnATelemetryConstants.matched_question_property], - ) - self.assertTrue("question" in telemetry_properties) - self.assertTrue("questionId" in telemetry_properties) - self.assertTrue("articleFound" in telemetry_properties) - self.assertEqual(expected_answer, first_answer) - - # Assert - Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(0, len(results)) - - async def test_telemetry_pii(self): - # Arrange - question: str = "how do I clean the stove?" - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - log_personal_information = False - context = QnaApplicationTest._get_context(question, TestAdapter()) - qna = QnAMaker( - QnaApplicationTest.tests_endpoint, - telemetry_client=telemetry_client, - log_personal_information=log_personal_information, - ) - - # Act - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context) - - telemetry_args = telemetry_client.track_event.call_args_list[0][1] - telemetry_properties = telemetry_args["properties"] - telemetry_metrics = telemetry_args["measurements"] - number_of_args = len(telemetry_args) - first_answer = telemetry_args["properties"][ - QnATelemetryConstants.answer_property - ] - expected_answer = ( - "BaseCamp: You can use a damp rag to clean around the Power Pack" - ) - - # Assert - Validate PII properties not logged. - self.assertEqual(1, telemetry_client.track_event.call_count) - self.assertEqual(3, number_of_args) - self.assertEqual("QnaMessage", telemetry_args["name"]) - self.assertTrue("answer" in telemetry_properties) - self.assertTrue("knowledgeBaseId" in telemetry_properties) - self.assertTrue("matchedQuestion" in telemetry_properties) - self.assertTrue("question" not in telemetry_properties) - self.assertTrue("questionId" in telemetry_properties) - self.assertTrue("articleFound" in telemetry_properties) - self.assertEqual(expected_answer, first_answer) - self.assertTrue("score" in telemetry_metrics) - self.assertEqual(1, telemetry_metrics["score"]) - - # Assert - Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(1, len(results)) - self.assertEqual(expected_answer, results[0].answer) - self.assertEqual("Editorial", results[0].source) - - async def test_telemetry_override(self): - # Arrange - question: str = "how do I clean the stove?" - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - context = QnaApplicationTest._get_context(question, TestAdapter()) - options = QnAMakerOptions(top=1) - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - log_personal_information = False - - # Act - Override the QnAMaker object to log custom stuff and honor params passed in. - telemetry_properties: Dict[str, str] = {"id": "MyId"} - qna = QnaApplicationTest.OverrideTelemetry( - QnaApplicationTest.tests_endpoint, - options, - None, - telemetry_client, - log_personal_information, - ) - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context, options, telemetry_properties) - - telemetry_args = telemetry_client.track_event.call_args_list - first_call_args = telemetry_args[0][0] - first_call_properties = first_call_args[1] - second_call_args = telemetry_args[1][0] - second_call_properties = second_call_args[1] - expected_answer = ( - "BaseCamp: You can use a damp rag to clean around the Power Pack" - ) - - # Assert - self.assertEqual(2, telemetry_client.track_event.call_count) - self.assertEqual(2, len(first_call_args)) - self.assertEqual("QnaMessage", first_call_args[0]) - self.assertEqual(2, len(first_call_properties)) - self.assertTrue("my_important_property" in first_call_properties) - self.assertEqual( - "my_important_value", first_call_properties["my_important_property"] - ) - self.assertTrue("id" in first_call_properties) - self.assertEqual("MyId", first_call_properties["id"]) - - self.assertEqual("my_second_event", second_call_args[0]) - self.assertTrue("my_important_property2" in second_call_properties) - self.assertEqual( - "my_important_value2", second_call_properties["my_important_property2"] - ) - - # Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(1, len(results)) - self.assertEqual(expected_answer, results[0].answer) - self.assertEqual("Editorial", results[0].source) - - async def test_telemetry_additional_props_metrics(self): - # Arrange - question: str = "how do I clean the stove?" - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - context = QnaApplicationTest._get_context(question, TestAdapter()) - options = QnAMakerOptions(top=1) - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - log_personal_information = False - - # Act - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - qna = QnAMaker( - QnaApplicationTest.tests_endpoint, - options, - None, - telemetry_client, - log_personal_information, - ) - telemetry_properties: Dict[str, str] = { - "my_important_property": "my_important_value" - } - telemetry_metrics: Dict[str, float] = {"my_important_metric": 3.14159} - - results = await qna.get_answers( - context, None, telemetry_properties, telemetry_metrics - ) - - # Assert - Added properties were added. - telemetry_args = telemetry_client.track_event.call_args_list[0][1] - telemetry_properties = telemetry_args["properties"] - expected_answer = ( - "BaseCamp: You can use a damp rag to clean around the Power Pack" - ) - - self.assertEqual(1, telemetry_client.track_event.call_count) - self.assertEqual(3, len(telemetry_args)) - self.assertEqual("QnaMessage", telemetry_args["name"]) - self.assertTrue("knowledgeBaseId" in telemetry_properties) - self.assertTrue("question" not in telemetry_properties) - self.assertTrue("matchedQuestion" in telemetry_properties) - self.assertTrue("questionId" in telemetry_properties) - self.assertTrue("answer" in telemetry_properties) - self.assertTrue(expected_answer, telemetry_properties["answer"]) - self.assertTrue("my_important_property" in telemetry_properties) - self.assertEqual( - "my_important_value", telemetry_properties["my_important_property"] - ) - - tracked_metrics = telemetry_args["measurements"] - - self.assertEqual(2, len(tracked_metrics)) - self.assertTrue("score" in tracked_metrics) - self.assertTrue("my_important_metric" in tracked_metrics) - self.assertEqual(3.14159, tracked_metrics["my_important_metric"]) - - # Assert - Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(1, len(results)) - self.assertEqual(expected_answer, results[0].answer) - self.assertEqual("Editorial", results[0].source) - - async def test_telemetry_additional_props_override(self): - question: str = "how do I clean the stove?" - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - context = QnaApplicationTest._get_context(question, TestAdapter()) - options = QnAMakerOptions(top=1) - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - log_personal_information = False - - # Act - Pass in properties during QnA invocation that override default properties - # NOTE: We are invoking this with PII turned OFF, and passing a PII property (originalQuestion). - qna = QnAMaker( - QnaApplicationTest.tests_endpoint, - options, - None, - telemetry_client, - log_personal_information, - ) - telemetry_properties = { - "knowledge_base_id": "my_important_value", - "original_question": "my_important_value2", - } - telemetry_metrics = {"score": 3.14159} - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers( - context, None, telemetry_properties, telemetry_metrics - ) - - # Assert - Added properties were added. - tracked_args = telemetry_client.track_event.call_args_list[0][1] - tracked_properties = tracked_args["properties"] - expected_answer = ( - "BaseCamp: You can use a damp rag to clean around the Power Pack" - ) - tracked_metrics = tracked_args["measurements"] - - self.assertEqual(1, telemetry_client.track_event.call_count) - self.assertEqual(3, len(tracked_args)) - self.assertEqual("QnaMessage", tracked_args["name"]) - self.assertTrue("knowledge_base_id" in tracked_properties) - self.assertEqual( - "my_important_value", tracked_properties["knowledge_base_id"] - ) - self.assertTrue("original_question" in tracked_properties) - self.assertTrue("matchedQuestion" in tracked_properties) - self.assertEqual( - "my_important_value2", tracked_properties["original_question"] - ) - self.assertTrue("question" not in tracked_properties) - self.assertTrue("questionId" in tracked_properties) - self.assertTrue("answer" in tracked_properties) - self.assertEqual(expected_answer, tracked_properties["answer"]) - self.assertTrue("my_important_property" not in tracked_properties) - self.assertEqual(1, len(tracked_metrics)) - self.assertTrue("score" in tracked_metrics) - self.assertEqual(3.14159, tracked_metrics["score"]) - - # Assert - Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(1, len(results)) - self.assertEqual(expected_answer, results[0].answer) - self.assertEqual("Editorial", results[0].source) - - async def test_telemetry_fill_props_override(self): - # Arrange - question: str = "how do I clean the stove?" - response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") - context: TurnContext = QnaApplicationTest._get_context(question, TestAdapter()) - options = QnAMakerOptions(top=1) - telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) - log_personal_information = False - - # Act - Pass in properties during QnA invocation that override default properties - # In addition Override with derivation. This presents an interesting question of order of setting - # properties. - # If I want to override "originalQuestion" property: - # - Set in "Stock" schema - # - Set in derived QnAMaker class - # - Set in GetAnswersAsync - # Logically, the GetAnswersAync should win. But ultimately OnQnaResultsAsync decides since it is the last - # code to touch the properties before logging (since it actually logs the event). - qna = QnaApplicationTest.OverrideFillTelemetry( - QnaApplicationTest.tests_endpoint, - options, - None, - telemetry_client, - log_personal_information, - ) - telemetry_properties: Dict[str, str] = { - "knowledgeBaseId": "my_important_value", - "matchedQuestion": "my_important_value2", - } - telemetry_metrics: Dict[str, float] = {"score": 3.14159} - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers( - context, None, telemetry_properties, telemetry_metrics - ) - - # Assert - Added properties were added. - first_call_args = telemetry_client.track_event.call_args_list[0][0] - first_properties = first_call_args[1] - expected_answer = ( - "BaseCamp: You can use a damp rag to clean around the Power Pack" - ) - first_metrics = first_call_args[2] - - self.assertEqual(2, telemetry_client.track_event.call_count) - self.assertEqual(3, len(first_call_args)) - self.assertEqual("QnaMessage", first_call_args[0]) - self.assertEqual(6, len(first_properties)) - self.assertTrue("knowledgeBaseId" in first_properties) - self.assertEqual("my_important_value", first_properties["knowledgeBaseId"]) - self.assertTrue("matchedQuestion" in first_properties) - self.assertEqual("my_important_value2", first_properties["matchedQuestion"]) - self.assertTrue("questionId" in first_properties) - self.assertTrue("answer" in first_properties) - self.assertEqual(expected_answer, first_properties["answer"]) - self.assertTrue("articleFound" in first_properties) - self.assertTrue("my_important_property" in first_properties) - self.assertEqual( - "my_important_value", first_properties["my_important_property"] - ) - - self.assertEqual(1, len(first_metrics)) - self.assertTrue("score" in first_metrics) - self.assertEqual(3.14159, first_metrics["score"]) - - # Assert - Validate we didn't break QnA functionality. - self.assertIsNotNone(results) - self.assertEqual(1, len(results)) - self.assertEqual(expected_answer, results[0].answer) - self.assertEqual("Editorial", results[0].source) - - async def test_call_train(self): - feedback_records = [] - - feedback1 = FeedbackRecord( - qna_id=1, user_id="test", user_question="How are you?" - ) - - feedback2 = FeedbackRecord(qna_id=2, user_id="test", user_question="What up??") - - feedback_records.extend([feedback1, feedback2]) - - with patch.object( - QnAMaker, "call_train", return_value=None - ) as mocked_call_train: - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - qna.call_train(feedback_records) - - mocked_call_train.assert_called_once_with(feedback_records) - - async def test_should_filter_low_score_variation(self): - options = QnAMakerOptions(top=5) - qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) - question: str = "Q11" - context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file("TopNAnswer.json") - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context) - self.assertEqual(4, len(results), "Should have received 4 answers.") - - filtered_results = qna.get_low_score_variation(results) - self.assertEqual( - 3, - len(filtered_results), - "Should have 3 filtered answers after low score variation.", - ) - - async def test_should_answer_with_is_test_true(self): - options = QnAMakerOptions(top=1, is_test=True) - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - question: str = "Q11" - context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file( - "QnaMaker_IsTest_true.json" - ) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context, options=options) - self.assertEqual(0, len(results), "Should have received zero answer.") - - async def test_should_answer_with_ranker_type_question_only(self): - options = QnAMakerOptions(top=1, ranker_type="QuestionOnly") - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - question: str = "Q11" - context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file( - "QnaMaker_RankerType_QuestionOnly.json" - ) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(context, options=options) - self.assertEqual(2, len(results), "Should have received two answers.") - - async def test_should_answer_with_prompts(self): - options = QnAMakerOptions(top=2) - qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) - question: str = "how do I clean the stove?" - turn_context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file("AnswerWithPrompts.json") - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(turn_context, options) - self.assertEqual(1, len(results), "Should have received 1 answers.") - self.assertEqual( - 1, len(results[0].context.prompts), "Should have received 1 prompt." - ) - - async def test_should_answer_with_high_score_provided_context(self): - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - question: str = "where can I buy?" - context = QnARequestContext( - previous_qna_id=5, prvious_user_query="how do I clean the stove?" - ) - options = QnAMakerOptions(top=2, qna_id=55, context=context) - turn_context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file( - "AnswerWithHighScoreProvidedContext.json" - ) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(turn_context, options) - self.assertEqual(1, len(results), "Should have received 1 answers.") - self.assertEqual(1, results[0].score, "Score should be high.") - - async def test_should_answer_with_high_score_provided_qna_id(self): - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - question: str = "where can I buy?" - - options = QnAMakerOptions(top=2, qna_id=55) - turn_context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file( - "AnswerWithHighScoreProvidedContext.json" - ) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(turn_context, options) - self.assertEqual(1, len(results), "Should have received 1 answers.") - self.assertEqual(1, results[0].score, "Score should be high.") - - async def test_should_answer_with_low_score_without_provided_context(self): - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - question: str = "where can I buy?" - options = QnAMakerOptions(top=2, context=None) - - turn_context = QnaApplicationTest._get_context(question, TestAdapter()) - response_json = QnaApplicationTest._get_json_for_file( - "AnswerWithLowScoreProvidedWithoutContext.json" - ) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - results = await qna.get_answers(turn_context, options) - self.assertEqual( - 2, len(results), "Should have received more than one answers." - ) - self.assertEqual(True, results[0].score < 1, "Score should be low.") - - @classmethod - async def _get_service_result( - cls, - utterance: str, - response_file: str, - bot_adapter: BotAdapter = TestAdapter(), - options: QnAMakerOptions = None, - ) -> [dict]: - response_json = QnaApplicationTest._get_json_for_file(response_file) - - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - context = QnaApplicationTest._get_context(utterance, bot_adapter) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - result = await qna.get_answers(context, options) - - return result - - @classmethod - async def _get_service_result_raw( - cls, - utterance: str, - response_file: str, - bot_adapter: BotAdapter = TestAdapter(), - options: QnAMakerOptions = None, - ) -> [dict]: - response_json = QnaApplicationTest._get_json_for_file(response_file) - - qna = QnAMaker(QnaApplicationTest.tests_endpoint) - context = QnaApplicationTest._get_context(utterance, bot_adapter) - - with patch( - "aiohttp.ClientSession.post", - return_value=aiounittest.futurized(response_json), - ): - result = await qna.get_answers_raw(context, options) - - return result - - @classmethod - def _get_json_for_file(cls, response_file: str) -> object: - curr_dir = path.dirname(path.abspath(__file__)) - response_path = path.join(curr_dir, "test_data", response_file) - - with open(response_path, "r", encoding="utf-8-sig") as file: - response_str = file.read() - response_json = json.loads(response_str) - - return response_json - - @staticmethod - def _get_context(question: str, bot_adapter: BotAdapter) -> TurnContext: - test_adapter = bot_adapter or TestAdapter() - activity = Activity( - type=ActivityTypes.message, - text=question, - conversation=ConversationAccount(), - recipient=ChannelAccount(), - from_property=ChannelAccount(), - ) - - return TurnContext(test_adapter, activity) - - class OverrideTelemetry(QnAMaker): - def __init__( # pylint: disable=useless-super-delegation - self, - endpoint: QnAMakerEndpoint, - options: QnAMakerOptions, - http_client: ClientSession, - telemetry_client: BotTelemetryClient, - log_personal_information: bool, - ): - super().__init__( - endpoint, - options, - http_client, - telemetry_client, - log_personal_information, - ) - - async def on_qna_result( # pylint: disable=unused-argument - self, - query_results: [QueryResult], - turn_context: TurnContext, - telemetry_properties: Dict[str, str] = None, - telemetry_metrics: Dict[str, float] = None, - ): - properties = telemetry_properties or {} - - # get_answers overrides derived class - properties["my_important_property"] = "my_important_value" - - # Log event - self.telemetry_client.track_event( - QnATelemetryConstants.qna_message_event, properties - ) - - # Create 2nd event. - second_event_properties = {"my_important_property2": "my_important_value2"} - self.telemetry_client.track_event( - "my_second_event", second_event_properties - ) - - class OverrideFillTelemetry(QnAMaker): - def __init__( # pylint: disable=useless-super-delegation - self, - endpoint: QnAMakerEndpoint, - options: QnAMakerOptions, - http_client: ClientSession, - telemetry_client: BotTelemetryClient, - log_personal_information: bool, - ): - super().__init__( - endpoint, - options, - http_client, - telemetry_client, - log_personal_information, - ) - - async def on_qna_result( - self, - query_results: [QueryResult], - turn_context: TurnContext, - telemetry_properties: Dict[str, str] = None, - telemetry_metrics: Dict[str, float] = None, - ): - event_data = await self.fill_qna_event( - query_results, turn_context, telemetry_properties, telemetry_metrics - ) - - # Add my property. - event_data.properties.update( - {"my_important_property": "my_important_value"} - ) - - # Log QnaMessage event. - self.telemetry_client.track_event( - QnATelemetryConstants.qna_message_event, - event_data.properties, - event_data.metrics, - ) - - # Create second event. - second_event_properties: Dict[str, str] = { - "my_important_property2": "my_important_value2" - } - - self.telemetry_client.track_event("MySecondEvent", second_event_properties) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# pylint: disable=protected-access + +import json +from os import path +from typing import List, Dict +import unittest +from unittest.mock import patch +from aiohttp import ClientSession + +import aiounittest +from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions +from botbuilder.ai.qna.models import ( + FeedbackRecord, + Metadata, + QueryResult, + QnARequestContext, +) +from botbuilder.ai.qna.utils import QnATelemetryConstants +from botbuilder.core import BotAdapter, BotTelemetryClient, TurnContext +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) + + +class TestContext(TurnContext): + __test__ = False + + def __init__(self, request): + super().__init__(TestAdapter(), request) + self.sent: List[Activity] = list() + + self.on_send_activities(self.capture_sent_activities) + + async def capture_sent_activities( + self, context: TurnContext, activities, next + ): # pylint: disable=unused-argument + self.sent += activities + context.responded = True + + +class QnaApplicationTest(aiounittest.AsyncTestCase): + # Note this is NOT a real QnA Maker application ID nor a real QnA Maker subscription-key + # theses are GUIDs edited to look right to the parsing and validation code. + + _knowledge_base_id: str = "f028d9k3-7g9z-11d3-d300-2b8x98227q8w" + _endpoint_key: str = "1k997n7w-207z-36p3-j2u1-09tas20ci6011" + _host: str = "https://dummyqnahost.azurewebsites.net/qnamaker" + + tests_endpoint = QnAMakerEndpoint(_knowledge_base_id, _endpoint_key, _host) + + def test_qnamaker_construction(self): + # Arrange + endpoint = self.tests_endpoint + + # Act + qna = QnAMaker(endpoint) + endpoint = qna._endpoint + + # Assert + self.assertEqual( + "f028d9k3-7g9z-11d3-d300-2b8x98227q8w", endpoint.knowledge_base_id + ) + self.assertEqual("1k997n7w-207z-36p3-j2u1-09tas20ci6011", endpoint.endpoint_key) + self.assertEqual( + "https://dummyqnahost.azurewebsites.net/qnamaker", endpoint.host + ) + + def test_endpoint_with_empty_kbid(self): + empty_kbid = "" + + with self.assertRaises(TypeError): + QnAMakerEndpoint(empty_kbid, self._endpoint_key, self._host) + + def test_endpoint_with_empty_endpoint_key(self): + empty_endpoint_key = "" + + with self.assertRaises(TypeError): + QnAMakerEndpoint(self._knowledge_base_id, empty_endpoint_key, self._host) + + def test_endpoint_with_emptyhost(self): + with self.assertRaises(TypeError): + QnAMakerEndpoint(self._knowledge_base_id, self._endpoint_key, "") + + def test_qnamaker_with_none_endpoint(self): + with self.assertRaises(TypeError): + QnAMaker(None) + + def test_set_default_options_with_no_options_arg(self): + qna_without_options = QnAMaker(self.tests_endpoint) + options = qna_without_options._generate_answer_helper.options + + default_threshold = 0.3 + default_top = 1 + default_strict_filters = [] + + self.assertEqual(default_threshold, options.score_threshold) + self.assertEqual(default_top, options.top) + self.assertEqual(default_strict_filters, options.strict_filters) + + def test_options_passed_to_ctor(self): + options = QnAMakerOptions( + score_threshold=0.8, + timeout=9000, + top=5, + strict_filters=[Metadata("movie", "disney")], + ) + + qna_with_options = QnAMaker(self.tests_endpoint, options) + actual_options = qna_with_options._generate_answer_helper.options + + expected_threshold = 0.8 + expected_timeout = 9000 + expected_top = 5 + expected_strict_filters = [Metadata("movie", "disney")] + + self.assertEqual(expected_threshold, actual_options.score_threshold) + self.assertEqual(expected_timeout, actual_options.timeout) + self.assertEqual(expected_top, actual_options.top) + self.assertEqual( + expected_strict_filters[0].name, actual_options.strict_filters[0].name + ) + self.assertEqual( + expected_strict_filters[0].value, actual_options.strict_filters[0].value + ) + + async def test_returns_answer(self): + # Arrange + question: str = "how do I clean the stove?" + response_path: str = "ReturnsAnswer.json" + + # Act + result = await QnaApplicationTest._get_service_result(question, response_path) + + first_answer = result[0] + + # Assert + self.assertIsNotNone(result) + self.assertEqual(1, len(result)) + self.assertEqual( + "BaseCamp: You can use a damp rag to clean around the Power Pack", + first_answer.answer, + ) + + async def test_active_learning_enabled_status(self): + # Arrange + question: str = "how do I clean the stove?" + response_path: str = "ReturnsAnswer.json" + + # Act + result = await QnaApplicationTest._get_service_result_raw( + question, response_path + ) + + # Assert + self.assertIsNotNone(result) + self.assertEqual(1, len(result.answers)) + self.assertFalse(result.active_learning_enabled) + + async def test_returns_answer_using_options(self): + # Arrange + question: str = "up" + response_path: str = "AnswerWithOptions.json" + options = QnAMakerOptions( + score_threshold=0.8, top=5, strict_filters=[Metadata("movie", "disney")] + ) + + # Act + result = await QnaApplicationTest._get_service_result( + question, response_path, options=options + ) + + first_answer = result[0] + has_at_least_1_ans = True + first_metadata = first_answer.metadata[0] + + # Assert + self.assertIsNotNone(result) + self.assertEqual(has_at_least_1_ans, len(result) >= 1) + self.assertTrue(first_answer.answer[0]) + self.assertEqual("is a movie", first_answer.answer) + self.assertTrue(first_answer.score >= options.score_threshold) + self.assertEqual("movie", first_metadata.name) + self.assertEqual("disney", first_metadata.value) + + async def test_trace_test(self): + activity = Activity( + type=ActivityTypes.message, + text="how do I clean the stove?", + conversation=ConversationAccount(), + recipient=ChannelAccount(), + from_property=ChannelAccount(), + ) + + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + + context = TestContext(activity) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + result = await qna.get_answers(context) + + qna_trace_activities = list( + filter( + lambda act: act.type == "trace" and act.name == "QnAMaker", + context.sent, + ) + ) + trace_activity = qna_trace_activities[0] + + self.assertEqual("trace", trace_activity.type) + self.assertEqual("QnAMaker", trace_activity.name) + self.assertEqual("QnAMaker Trace", trace_activity.label) + self.assertEqual( + "https://www.qnamaker.ai/schemas/trace", trace_activity.value_type + ) + self.assertEqual(True, hasattr(trace_activity, "value")) + self.assertEqual(True, hasattr(trace_activity.value, "message")) + self.assertEqual(True, hasattr(trace_activity.value, "query_results")) + self.assertEqual(True, hasattr(trace_activity.value, "score_threshold")) + self.assertEqual(True, hasattr(trace_activity.value, "top")) + self.assertEqual(True, hasattr(trace_activity.value, "strict_filters")) + self.assertEqual( + self._knowledge_base_id, trace_activity.value.knowledge_base_id + ) + + return result + + async def test_returns_answer_with_timeout(self): + question: str = "how do I clean the stove?" + options = QnAMakerOptions(timeout=999999) + qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) + context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + result = await qna.get_answers(context, options) + + self.assertIsNotNone(result) + self.assertEqual( + options.timeout, qna._generate_answer_helper.options.timeout + ) + + async def test_telemetry_returns_answer(self): + # Arrange + question: str = "how do I clean the stove?" + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + log_personal_information = True + context = QnaApplicationTest._get_context(question, TestAdapter()) + qna = QnAMaker( + QnaApplicationTest.tests_endpoint, + telemetry_client=telemetry_client, + log_personal_information=log_personal_information, + ) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context) + + telemetry_args = telemetry_client.track_event.call_args_list[0][1] + telemetry_properties = telemetry_args["properties"] + telemetry_metrics = telemetry_args["measurements"] + number_of_args = len(telemetry_args) + first_answer = telemetry_args["properties"][ + QnATelemetryConstants.answer_property + ] + expected_answer = ( + "BaseCamp: You can use a damp rag to clean around the Power Pack" + ) + + # Assert - Check Telemetry logged. + self.assertEqual(1, telemetry_client.track_event.call_count) + self.assertEqual(3, number_of_args) + self.assertEqual("QnaMessage", telemetry_args["name"]) + self.assertTrue("answer" in telemetry_properties) + self.assertTrue("knowledgeBaseId" in telemetry_properties) + self.assertTrue("matchedQuestion" in telemetry_properties) + self.assertTrue("question" in telemetry_properties) + self.assertTrue("questionId" in telemetry_properties) + self.assertTrue("articleFound" in telemetry_properties) + self.assertEqual(expected_answer, first_answer) + self.assertTrue("score" in telemetry_metrics) + self.assertEqual(1, telemetry_metrics["score"]) + + # Assert - Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(1, len(results)) + self.assertEqual(expected_answer, results[0].answer) + self.assertEqual("Editorial", results[0].source) + + async def test_telemetry_returns_answer_when_no_answer_found_in_kb(self): + # Arrange + question: str = "gibberish question" + response_json = QnaApplicationTest._get_json_for_file("NoAnswerFoundInKb.json") + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + qna = QnAMaker( + QnaApplicationTest.tests_endpoint, + telemetry_client=telemetry_client, + log_personal_information=True, + ) + context = QnaApplicationTest._get_context(question, TestAdapter()) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context) + + telemetry_args = telemetry_client.track_event.call_args_list[0][1] + telemetry_properties = telemetry_args["properties"] + number_of_args = len(telemetry_args) + first_answer = telemetry_args["properties"][ + QnATelemetryConstants.answer_property + ] + expected_answer = "No Qna Answer matched" + expected_matched_question = "No Qna Question matched" + + # Assert - Check Telemetry logged. + self.assertEqual(1, telemetry_client.track_event.call_count) + self.assertEqual(3, number_of_args) + self.assertEqual("QnaMessage", telemetry_args["name"]) + self.assertTrue("answer" in telemetry_properties) + self.assertTrue("knowledgeBaseId" in telemetry_properties) + self.assertTrue("matchedQuestion" in telemetry_properties) + self.assertEqual( + expected_matched_question, + telemetry_properties[QnATelemetryConstants.matched_question_property], + ) + self.assertTrue("question" in telemetry_properties) + self.assertTrue("questionId" in telemetry_properties) + self.assertTrue("articleFound" in telemetry_properties) + self.assertEqual(expected_answer, first_answer) + + # Assert - Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(0, len(results)) + + async def test_telemetry_pii(self): + # Arrange + question: str = "how do I clean the stove?" + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + log_personal_information = False + context = QnaApplicationTest._get_context(question, TestAdapter()) + qna = QnAMaker( + QnaApplicationTest.tests_endpoint, + telemetry_client=telemetry_client, + log_personal_information=log_personal_information, + ) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context) + + telemetry_args = telemetry_client.track_event.call_args_list[0][1] + telemetry_properties = telemetry_args["properties"] + telemetry_metrics = telemetry_args["measurements"] + number_of_args = len(telemetry_args) + first_answer = telemetry_args["properties"][ + QnATelemetryConstants.answer_property + ] + expected_answer = ( + "BaseCamp: You can use a damp rag to clean around the Power Pack" + ) + + # Assert - Validate PII properties not logged. + self.assertEqual(1, telemetry_client.track_event.call_count) + self.assertEqual(3, number_of_args) + self.assertEqual("QnaMessage", telemetry_args["name"]) + self.assertTrue("answer" in telemetry_properties) + self.assertTrue("knowledgeBaseId" in telemetry_properties) + self.assertTrue("matchedQuestion" in telemetry_properties) + self.assertTrue("question" not in telemetry_properties) + self.assertTrue("questionId" in telemetry_properties) + self.assertTrue("articleFound" in telemetry_properties) + self.assertEqual(expected_answer, first_answer) + self.assertTrue("score" in telemetry_metrics) + self.assertEqual(1, telemetry_metrics["score"]) + + # Assert - Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(1, len(results)) + self.assertEqual(expected_answer, results[0].answer) + self.assertEqual("Editorial", results[0].source) + + async def test_telemetry_override(self): + # Arrange + question: str = "how do I clean the stove?" + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + context = QnaApplicationTest._get_context(question, TestAdapter()) + options = QnAMakerOptions(top=1) + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + log_personal_information = False + + # Act - Override the QnAMaker object to log custom stuff and honor params passed in. + telemetry_properties: Dict[str, str] = {"id": "MyId"} + qna = QnaApplicationTest.OverrideTelemetry( + QnaApplicationTest.tests_endpoint, + options, + None, + telemetry_client, + log_personal_information, + ) + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context, options, telemetry_properties) + + telemetry_args = telemetry_client.track_event.call_args_list + first_call_args = telemetry_args[0][0] + first_call_properties = first_call_args[1] + second_call_args = telemetry_args[1][0] + second_call_properties = second_call_args[1] + expected_answer = ( + "BaseCamp: You can use a damp rag to clean around the Power Pack" + ) + + # Assert + self.assertEqual(2, telemetry_client.track_event.call_count) + self.assertEqual(2, len(first_call_args)) + self.assertEqual("QnaMessage", first_call_args[0]) + self.assertEqual(2, len(first_call_properties)) + self.assertTrue("my_important_property" in first_call_properties) + self.assertEqual( + "my_important_value", first_call_properties["my_important_property"] + ) + self.assertTrue("id" in first_call_properties) + self.assertEqual("MyId", first_call_properties["id"]) + + self.assertEqual("my_second_event", second_call_args[0]) + self.assertTrue("my_important_property2" in second_call_properties) + self.assertEqual( + "my_important_value2", second_call_properties["my_important_property2"] + ) + + # Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(1, len(results)) + self.assertEqual(expected_answer, results[0].answer) + self.assertEqual("Editorial", results[0].source) + + async def test_telemetry_additional_props_metrics(self): + # Arrange + question: str = "how do I clean the stove?" + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + context = QnaApplicationTest._get_context(question, TestAdapter()) + options = QnAMakerOptions(top=1) + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + log_personal_information = False + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + qna = QnAMaker( + QnaApplicationTest.tests_endpoint, + options, + None, + telemetry_client, + log_personal_information, + ) + telemetry_properties: Dict[str, str] = { + "my_important_property": "my_important_value" + } + telemetry_metrics: Dict[str, float] = {"my_important_metric": 3.14159} + + results = await qna.get_answers( + context, None, telemetry_properties, telemetry_metrics + ) + + # Assert - Added properties were added. + telemetry_args = telemetry_client.track_event.call_args_list[0][1] + telemetry_properties = telemetry_args["properties"] + expected_answer = ( + "BaseCamp: You can use a damp rag to clean around the Power Pack" + ) + + self.assertEqual(1, telemetry_client.track_event.call_count) + self.assertEqual(3, len(telemetry_args)) + self.assertEqual("QnaMessage", telemetry_args["name"]) + self.assertTrue("knowledgeBaseId" in telemetry_properties) + self.assertTrue("question" not in telemetry_properties) + self.assertTrue("matchedQuestion" in telemetry_properties) + self.assertTrue("questionId" in telemetry_properties) + self.assertTrue("answer" in telemetry_properties) + self.assertTrue(expected_answer, telemetry_properties["answer"]) + self.assertTrue("my_important_property" in telemetry_properties) + self.assertEqual( + "my_important_value", telemetry_properties["my_important_property"] + ) + + tracked_metrics = telemetry_args["measurements"] + + self.assertEqual(2, len(tracked_metrics)) + self.assertTrue("score" in tracked_metrics) + self.assertTrue("my_important_metric" in tracked_metrics) + self.assertEqual(3.14159, tracked_metrics["my_important_metric"]) + + # Assert - Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(1, len(results)) + self.assertEqual(expected_answer, results[0].answer) + self.assertEqual("Editorial", results[0].source) + + async def test_telemetry_additional_props_override(self): + question: str = "how do I clean the stove?" + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + context = QnaApplicationTest._get_context(question, TestAdapter()) + options = QnAMakerOptions(top=1) + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + log_personal_information = False + + # Act - Pass in properties during QnA invocation that override default properties + # NOTE: We are invoking this with PII turned OFF, and passing a PII property (originalQuestion). + qna = QnAMaker( + QnaApplicationTest.tests_endpoint, + options, + None, + telemetry_client, + log_personal_information, + ) + telemetry_properties = { + "knowledge_base_id": "my_important_value", + "original_question": "my_important_value2", + } + telemetry_metrics = {"score": 3.14159} + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers( + context, None, telemetry_properties, telemetry_metrics + ) + + # Assert - Added properties were added. + tracked_args = telemetry_client.track_event.call_args_list[0][1] + tracked_properties = tracked_args["properties"] + expected_answer = ( + "BaseCamp: You can use a damp rag to clean around the Power Pack" + ) + tracked_metrics = tracked_args["measurements"] + + self.assertEqual(1, telemetry_client.track_event.call_count) + self.assertEqual(3, len(tracked_args)) + self.assertEqual("QnaMessage", tracked_args["name"]) + self.assertTrue("knowledge_base_id" in tracked_properties) + self.assertEqual( + "my_important_value", tracked_properties["knowledge_base_id"] + ) + self.assertTrue("original_question" in tracked_properties) + self.assertTrue("matchedQuestion" in tracked_properties) + self.assertEqual( + "my_important_value2", tracked_properties["original_question"] + ) + self.assertTrue("question" not in tracked_properties) + self.assertTrue("questionId" in tracked_properties) + self.assertTrue("answer" in tracked_properties) + self.assertEqual(expected_answer, tracked_properties["answer"]) + self.assertTrue("my_important_property" not in tracked_properties) + self.assertEqual(1, len(tracked_metrics)) + self.assertTrue("score" in tracked_metrics) + self.assertEqual(3.14159, tracked_metrics["score"]) + + # Assert - Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(1, len(results)) + self.assertEqual(expected_answer, results[0].answer) + self.assertEqual("Editorial", results[0].source) + + async def test_telemetry_fill_props_override(self): + # Arrange + question: str = "how do I clean the stove?" + response_json = QnaApplicationTest._get_json_for_file("ReturnsAnswer.json") + context: TurnContext = QnaApplicationTest._get_context(question, TestAdapter()) + options = QnAMakerOptions(top=1) + telemetry_client = unittest.mock.create_autospec(BotTelemetryClient) + log_personal_information = False + + # Act - Pass in properties during QnA invocation that override default properties + # In addition Override with derivation. This presents an interesting question of order of setting + # properties. + # If I want to override "originalQuestion" property: + # - Set in "Stock" schema + # - Set in derived QnAMaker class + # - Set in GetAnswersAsync + # Logically, the GetAnswersAync should win. But ultimately OnQnaResultsAsync decides since it is the last + # code to touch the properties before logging (since it actually logs the event). + qna = QnaApplicationTest.OverrideFillTelemetry( + QnaApplicationTest.tests_endpoint, + options, + None, + telemetry_client, + log_personal_information, + ) + telemetry_properties: Dict[str, str] = { + "knowledgeBaseId": "my_important_value", + "matchedQuestion": "my_important_value2", + } + telemetry_metrics: Dict[str, float] = {"score": 3.14159} + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers( + context, None, telemetry_properties, telemetry_metrics + ) + + # Assert - Added properties were added. + first_call_args = telemetry_client.track_event.call_args_list[0][0] + first_properties = first_call_args[1] + expected_answer = ( + "BaseCamp: You can use a damp rag to clean around the Power Pack" + ) + first_metrics = first_call_args[2] + + self.assertEqual(2, telemetry_client.track_event.call_count) + self.assertEqual(3, len(first_call_args)) + self.assertEqual("QnaMessage", first_call_args[0]) + self.assertEqual(6, len(first_properties)) + self.assertTrue("knowledgeBaseId" in first_properties) + self.assertEqual("my_important_value", first_properties["knowledgeBaseId"]) + self.assertTrue("matchedQuestion" in first_properties) + self.assertEqual("my_important_value2", first_properties["matchedQuestion"]) + self.assertTrue("questionId" in first_properties) + self.assertTrue("answer" in first_properties) + self.assertEqual(expected_answer, first_properties["answer"]) + self.assertTrue("articleFound" in first_properties) + self.assertTrue("my_important_property" in first_properties) + self.assertEqual( + "my_important_value", first_properties["my_important_property"] + ) + + self.assertEqual(1, len(first_metrics)) + self.assertTrue("score" in first_metrics) + self.assertEqual(3.14159, first_metrics["score"]) + + # Assert - Validate we didn't break QnA functionality. + self.assertIsNotNone(results) + self.assertEqual(1, len(results)) + self.assertEqual(expected_answer, results[0].answer) + self.assertEqual("Editorial", results[0].source) + + async def test_call_train(self): + feedback_records = [] + + feedback1 = FeedbackRecord( + qna_id=1, user_id="test", user_question="How are you?" + ) + + feedback2 = FeedbackRecord(qna_id=2, user_id="test", user_question="What up??") + + feedback_records.extend([feedback1, feedback2]) + + with patch.object( + QnAMaker, "call_train", return_value=None + ) as mocked_call_train: + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + qna.call_train(feedback_records) + + mocked_call_train.assert_called_once_with(feedback_records) + + async def test_should_filter_low_score_variation(self): + options = QnAMakerOptions(top=5) + qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) + question: str = "Q11" + context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file("TopNAnswer.json") + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context) + self.assertEqual(4, len(results), "Should have received 4 answers.") + + filtered_results = qna.get_low_score_variation(results) + self.assertEqual( + 3, + len(filtered_results), + "Should have 3 filtered answers after low score variation.", + ) + + async def test_should_answer_with_is_test_true(self): + options = QnAMakerOptions(top=1, is_test=True) + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "Q11" + context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "QnaMaker_IsTest_true.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context, options=options) + self.assertEqual(0, len(results), "Should have received zero answer.") + + async def test_should_answer_with_ranker_type_question_only(self): + options = QnAMakerOptions(top=1, ranker_type="QuestionOnly") + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "Q11" + context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "QnaMaker_RankerType_QuestionOnly.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(context, options=options) + self.assertEqual(2, len(results), "Should have received two answers.") + + async def test_should_answer_with_prompts(self): + options = QnAMakerOptions(top=2) + qna = QnAMaker(QnaApplicationTest.tests_endpoint, options) + question: str = "how do I clean the stove?" + turn_context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file("AnswerWithPrompts.json") + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(turn_context, options) + self.assertEqual(1, len(results), "Should have received 1 answers.") + self.assertEqual( + 1, len(results[0].context.prompts), "Should have received 1 prompt." + ) + + async def test_should_answer_with_high_score_provided_context(self): + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "where can I buy?" + context = QnARequestContext( + previous_qna_id=5, prvious_user_query="how do I clean the stove?" + ) + options = QnAMakerOptions(top=2, qna_id=55, context=context) + turn_context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "AnswerWithHighScoreProvidedContext.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(turn_context, options) + self.assertEqual(1, len(results), "Should have received 1 answers.") + self.assertEqual(1, results[0].score, "Score should be high.") + + async def test_should_answer_with_high_score_provided_qna_id(self): + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "where can I buy?" + + options = QnAMakerOptions(top=2, qna_id=55) + turn_context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "AnswerWithHighScoreProvidedContext.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(turn_context, options) + self.assertEqual(1, len(results), "Should have received 1 answers.") + self.assertEqual(1, results[0].score, "Score should be high.") + + async def test_should_answer_with_low_score_without_provided_context(self): + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + question: str = "where can I buy?" + options = QnAMakerOptions(top=2, context=None) + + turn_context = QnaApplicationTest._get_context(question, TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "AnswerWithLowScoreProvidedWithoutContext.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(turn_context, options) + self.assertEqual( + 2, len(results), "Should have received more than one answers." + ) + self.assertEqual(True, results[0].score < 1, "Score should be low.") + + @classmethod + async def _get_service_result( + cls, + utterance: str, + response_file: str, + bot_adapter: BotAdapter = TestAdapter(), + options: QnAMakerOptions = None, + ) -> [dict]: + response_json = QnaApplicationTest._get_json_for_file(response_file) + + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(utterance, bot_adapter) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + result = await qna.get_answers(context, options) + + return result + + @classmethod + async def _get_service_result_raw( + cls, + utterance: str, + response_file: str, + bot_adapter: BotAdapter = TestAdapter(), + options: QnAMakerOptions = None, + ) -> [dict]: + response_json = QnaApplicationTest._get_json_for_file(response_file) + + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(utterance, bot_adapter) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + result = await qna.get_answers_raw(context, options) + + return result + + @classmethod + def _get_json_for_file(cls, response_file: str) -> object: + curr_dir = path.dirname(path.abspath(__file__)) + response_path = path.join(curr_dir, "test_data", response_file) + + with open(response_path, "r", encoding="utf-8-sig") as file: + response_str = file.read() + response_json = json.loads(response_str) + + return response_json + + @staticmethod + def _get_context(question: str, bot_adapter: BotAdapter) -> TurnContext: + test_adapter = bot_adapter or TestAdapter() + activity = Activity( + type=ActivityTypes.message, + text=question, + conversation=ConversationAccount(), + recipient=ChannelAccount(), + from_property=ChannelAccount(), + ) + + return TurnContext(test_adapter, activity) + + class OverrideTelemetry(QnAMaker): + def __init__( # pylint: disable=useless-super-delegation + self, + endpoint: QnAMakerEndpoint, + options: QnAMakerOptions, + http_client: ClientSession, + telemetry_client: BotTelemetryClient, + log_personal_information: bool, + ): + super().__init__( + endpoint, + options, + http_client, + telemetry_client, + log_personal_information, + ) + + async def on_qna_result( # pylint: disable=unused-argument + self, + query_results: [QueryResult], + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + ): + properties = telemetry_properties or {} + + # get_answers overrides derived class + properties["my_important_property"] = "my_important_value" + + # Log event + self.telemetry_client.track_event( + QnATelemetryConstants.qna_message_event, properties + ) + + # Create 2nd event. + second_event_properties = {"my_important_property2": "my_important_value2"} + self.telemetry_client.track_event( + "my_second_event", second_event_properties + ) + + class OverrideFillTelemetry(QnAMaker): + def __init__( # pylint: disable=useless-super-delegation + self, + endpoint: QnAMakerEndpoint, + options: QnAMakerOptions, + http_client: ClientSession, + telemetry_client: BotTelemetryClient, + log_personal_information: bool, + ): + super().__init__( + endpoint, + options, + http_client, + telemetry_client, + log_personal_information, + ) + + async def on_qna_result( + self, + query_results: [QueryResult], + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + ): + event_data = await self.fill_qna_event( + query_results, turn_context, telemetry_properties, telemetry_metrics + ) + + # Add my property. + event_data.properties.update( + {"my_important_property": "my_important_value"} + ) + + # Log QnaMessage event. + self.telemetry_client.track_event( + QnATelemetryConstants.qna_message_event, + event_data.properties, + event_data.metrics, + ) + + # Create second event. + second_event_properties: Dict[str, str] = { + "my_important_property2": "my_important_value2" + } + + self.telemetry_client.track_event("MySecondEvent", second_event_properties) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index c42adee2f..16261065f 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -1,178 +1,173 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from unittest.mock import patch -from typing import Dict -import aiounittest -from botbuilder.core.adapters import TestAdapter, TestFlow -from botbuilder.schema import Activity -from botbuilder.core import ( - ConversationState, - MemoryStorage, - TurnContext, - NullTelemetryClient, -) -from botbuilder.dialogs import ( - Dialog, - DialogSet, - WaterfallDialog, - DialogTurnResult, - DialogTurnStatus, -) - -BEGIN_MESSAGE = Activity() -BEGIN_MESSAGE.text = "begin" -BEGIN_MESSAGE.type = "message" - - -class TelemetryWaterfallTests(aiounittest.AsyncTestCase): - def test_none_telemetry_client(self): - # arrange - dialog = WaterfallDialog("myId") - # act - dialog.telemetry_client = None - # assert - self.assertEqual(type(dialog.telemetry_client), NullTelemetryClient) - - @patch("botbuilder.applicationinsights.ApplicationInsightsTelemetryClient") - async def test_execute_sequence_waterfall_steps( # pylint: disable=invalid-name - self, MockTelemetry - ): - # arrange - - # Create new ConversationState with MemoryStorage and register the state as middleware. - convo_state = ConversationState(MemoryStorage()) - telemetry = MockTelemetry() - - # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def step1(step) -> DialogTurnResult: - await step.context.send_activity("bot responding.") - return Dialog.end_of_turn - - async def step2(step) -> DialogTurnResult: - await step.context.send_activity("ending WaterfallDialog.") - return Dialog.end_of_turn - - # act - - my_dialog = WaterfallDialog("test", [step1, step2]) - my_dialog.telemetry_client = telemetry - dialogs.add(my_dialog) - - # Initialize TestAdapter - async def exec_test(turn_context: TurnContext) -> None: - - dialog_context = await dialogs.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog("test") - else: - if results.status == DialogTurnStatus.Complete: - await turn_context.send_activity(results.result) - - await convo_state.save_changes(turn_context) - - adapt = TestAdapter(exec_test) - - test_flow = TestFlow(None, adapt) - tf2 = await test_flow.send(BEGIN_MESSAGE) - tf3 = await tf2.assert_reply("bot responding.") - tf4 = await tf3.send("continue") - await tf4.assert_reply("ending WaterfallDialog.") - - # assert - - telemetry_calls = [ - ("WaterfallStart", {"DialogId": "test"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step2of2"}), - ] - self.assert_telemetry_calls(telemetry, telemetry_calls) - - @patch("botbuilder.applicationinsights.ApplicationInsightsTelemetryClient") - async def test_ensure_end_dialog_called( - self, MockTelemetry - ): # pylint: disable=invalid-name - # arrange - - # Create new ConversationState with MemoryStorage and register the state as middleware. - convo_state = ConversationState(MemoryStorage()) - telemetry = MockTelemetry() - - # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = convo_state.create_property("dialogState") - dialogs = DialogSet(dialog_state) - - async def step1(step) -> DialogTurnResult: - await step.context.send_activity("step1 response") - return Dialog.end_of_turn - - async def step2(step) -> DialogTurnResult: - await step.context.send_activity("step2 response") - return Dialog.end_of_turn - - # act - - my_dialog = WaterfallDialog("test", [step1, step2]) - my_dialog.telemetry_client = telemetry - dialogs.add(my_dialog) - - # Initialize TestAdapter - async def exec_test(turn_context: TurnContext) -> None: - - dialog_context = await dialogs.create_context(turn_context) - await dialog_context.continue_dialog() - if not turn_context.responded: - await dialog_context.begin_dialog("test", None) - await convo_state.save_changes(turn_context) - - adapt = TestAdapter(exec_test) - - test_flow = TestFlow(None, adapt) - tf2 = await test_flow.send(BEGIN_MESSAGE) - tf3 = await tf2.assert_reply("step1 response") - tf4 = await tf3.send("continue") - tf5 = await tf4.assert_reply("step2 response") - await tf5.send( - "Should hit end of steps - this will restart the dialog and trigger COMPLETE event" - ) - # assert - telemetry_calls = [ - ("WaterfallStart", {"DialogId": "test"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step2of2"}), - ("WaterfallComplete", {"DialogId": "test"}), - ("WaterfallStart", {"DialogId": "test"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), - ] - print(str(telemetry.track_event.call_args_list)) - self.assert_telemetry_calls(telemetry, telemetry_calls) - - def assert_telemetry_call( - self, telemetry_mock, index: int, event_name: str, props: Dict[str, str] - ) -> None: - # pylint: disable=unused-variable - args, kwargs = telemetry_mock.track_event.call_args_list[index] - self.assertEqual(args[0], event_name) - - for key, val in props.items(): - self.assertTrue( - key in args[1], - msg=f"Could not find value {key} in {args[1]} for index {index}", - ) - self.assertTrue(isinstance(args[1], dict)) - self.assertTrue(val == args[1][key]) - - def assert_telemetry_calls(self, telemetry_mock, calls) -> None: - index = 0 - for event_name, props in calls: - self.assert_telemetry_call(telemetry_mock, index, event_name, props) - index += 1 - if index != len(telemetry_mock.track_event.call_args_list): - self.assertTrue( # pylint: disable=redundant-unittest-assert - False, - f"Found {len(telemetry_mock.track_event.call_args_list)} calls, testing for {index + 1}", - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import patch, MagicMock +from typing import Dict +import aiounittest +from botbuilder.core.adapters import TestAdapter, TestFlow +from botbuilder.schema import Activity +from botbuilder.core import ( + ConversationState, + MemoryStorage, + TurnContext, + NullTelemetryClient, +) +from botbuilder.dialogs import ( + Dialog, + DialogSet, + WaterfallDialog, + DialogTurnResult, + DialogTurnStatus, +) + +BEGIN_MESSAGE = Activity() +BEGIN_MESSAGE.text = "begin" +BEGIN_MESSAGE.type = "message" + +MOCK_TELEMETRY = "botbuilder.applicationinsights.ApplicationInsightsTelemetryClient" + + +class TelemetryWaterfallTests(aiounittest.AsyncTestCase): + def test_none_telemetry_client(self): + # arrange + dialog = WaterfallDialog("myId") + # act + dialog.telemetry_client = None + # assert + self.assertEqual(type(dialog.telemetry_client), NullTelemetryClient) + + async def test_execute_sequence_waterfall_steps(self): + # arrange + + # Create new ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + telemetry = MagicMock(name=MOCK_TELEMETRY) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def step1(step) -> DialogTurnResult: + await step.context.send_activity("bot responding.") + return Dialog.end_of_turn + + async def step2(step) -> DialogTurnResult: + await step.context.send_activity("ending WaterfallDialog.") + return Dialog.end_of_turn + + # act + + my_dialog = WaterfallDialog("test", [step1, step2]) + my_dialog.telemetry_client = telemetry + dialogs.add(my_dialog) + + # Initialize TestAdapter + async def exec_test(turn_context: TurnContext) -> None: + + dialog_context = await dialogs.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog("test") + else: + if results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save_changes(turn_context) + + adapt = TestAdapter(exec_test) + + test_flow = TestFlow(None, adapt) + tf2 = await test_flow.send(BEGIN_MESSAGE) + tf3 = await tf2.assert_reply("bot responding.") + tf4 = await tf3.send("continue") + await tf4.assert_reply("ending WaterfallDialog.") + + # assert + + telemetry_calls = [ + ("WaterfallStart", {"DialogId": "test"}), + ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), + ("WaterfallStep", {"DialogId": "test", "StepName": "Step2of2"}), + ] + self.assert_telemetry_calls(telemetry, telemetry_calls) + + async def test_ensure_end_dialog_called(self): + # arrange + + # Create new ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + telemetry = MagicMock(name=MOCK_TELEMETRY) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + async def step1(step) -> DialogTurnResult: + await step.context.send_activity("step1 response") + return Dialog.end_of_turn + + async def step2(step) -> DialogTurnResult: + await step.context.send_activity("step2 response") + return Dialog.end_of_turn + + # act + + my_dialog = WaterfallDialog("test", [step1, step2]) + my_dialog.telemetry_client = telemetry + dialogs.add(my_dialog) + + # Initialize TestAdapter + async def exec_test(turn_context: TurnContext) -> None: + + dialog_context = await dialogs.create_context(turn_context) + await dialog_context.continue_dialog() + if not turn_context.responded: + await dialog_context.begin_dialog("test", None) + await convo_state.save_changes(turn_context) + + adapt = TestAdapter(exec_test) + + test_flow = TestFlow(None, adapt) + tf2 = await test_flow.send(BEGIN_MESSAGE) + tf3 = await tf2.assert_reply("step1 response") + tf4 = await tf3.send("continue") + tf5 = await tf4.assert_reply("step2 response") + await tf5.send( + "Should hit end of steps - this will restart the dialog and trigger COMPLETE event" + ) + # assert + telemetry_calls = [ + ("WaterfallStart", {"DialogId": "test"}), + ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), + ("WaterfallStep", {"DialogId": "test", "StepName": "Step2of2"}), + ("WaterfallComplete", {"DialogId": "test"}), + ("WaterfallStart", {"DialogId": "test"}), + ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), + ] + print(str(telemetry.track_event.call_args_list)) + self.assert_telemetry_calls(telemetry, telemetry_calls) + + def assert_telemetry_call( + self, telemetry_mock, index: int, event_name: str, props: Dict[str, str] + ) -> None: + args, kwargs = telemetry_mock.track_event.call_args_list[index] + self.assertEqual(args[0], event_name) + + for key, val in props.items(): + self.assertTrue( + key in args[1], + msg=f"Could not find value {key} in {args[1]} for index {index}", + ) + self.assertTrue(isinstance(args[1], dict)) + self.assertTrue(val == args[1][key]) + + def assert_telemetry_calls(self, telemetry_mock, calls) -> None: + index = 0 + for event_name, props in calls: + self.assert_telemetry_call(telemetry_mock, index, event_name, props) + index += 1 + if index != len(telemetry_mock.track_event.call_args_list): + self.assertTrue( # pylint: disable=redundant-unittest-assert + False, + f"Found {len(telemetry_mock.track_event.call_args_list)} calls, testing for {index + 1}", + ) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 0ff9f16b6..d8acd678c 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -1,463 +1,467 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# TODO: enable this in the future -# With python 3.7 the line below will allow to do Postponed Evaluation of Annotations. See PEP 563 -# from __future__ import annotations - -import asyncio -import inspect -from datetime import datetime -from typing import Awaitable, Coroutine, Dict, List, Callable, Union -from copy import copy -from threading import Lock -from botbuilder.schema import ( - ActivityTypes, - Activity, - ConversationAccount, - ConversationReference, - ChannelAccount, - ResourceResponse, - TokenResponse, -) -from botframework.connector.auth import ClaimsIdentity -from ..bot_adapter import BotAdapter -from ..turn_context import TurnContext -from ..user_token_provider import UserTokenProvider - - -class UserToken: - def __init__( - self, - connection_name: str = None, - user_id: str = None, - channel_id: str = None, - token: str = None, - ): - self.connection_name = connection_name - self.user_id = user_id - self.channel_id = channel_id - self.token = token - - def equals_key(self, rhs: "UserToken"): - return ( - rhs is not None - and self.connection_name == rhs.connection_name - and self.user_id == rhs.user_id - and self.channel_id == rhs.channel_id - ) - - -class TokenMagicCode: - def __init__(self, key: UserToken = None, magic_code: str = None): - self.key = key - self.magic_code = magic_code - - -class TestAdapter(BotAdapter, UserTokenProvider): - def __init__( - self, - logic: Coroutine = None, - template_or_conversation: Union[Activity, ConversationReference] = None, - send_trace_activities: bool = False, - ): # pylint: disable=unused-argument - """ - Creates a new TestAdapter instance. - :param logic: - :param conversation: A reference to the conversation to begin the adapter state with. - """ - super(TestAdapter, self).__init__() - self.logic = logic - self._next_id: int = 0 - self._user_tokens: List[UserToken] = [] - self._magic_codes: List[TokenMagicCode] = [] - self._conversation_lock = Lock() - self.activity_buffer: List[Activity] = [] - self.updated_activities: List[Activity] = [] - self.deleted_activities: List[ConversationReference] = [] - self.send_trace_activities = send_trace_activities - - self.template = ( - template_or_conversation - if isinstance(template_or_conversation, Activity) - else Activity( - channel_id="test", - service_url="https://test.com", - from_property=ChannelAccount(id="User1", name="user"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount(id="Convo1"), - ) - ) - - if isinstance(template_or_conversation, ConversationReference): - self.template.channel_id = template_or_conversation.channel_id - - async def process_activity( - self, activity: Activity, logic: Callable[[TurnContext], Awaitable] - ): - self._conversation_lock.acquire() - try: - # ready for next reply - if activity.type is None: - activity.type = ActivityTypes.message - - activity.channel_id = self.template.channel_id - activity.from_property = self.template.from_property - activity.recipient = self.template.recipient - activity.conversation = self.template.conversation - activity.service_url = self.template.service_url - - activity.id = str((self._next_id)) - self._next_id += 1 - finally: - self._conversation_lock.release() - - activity.timestamp = activity.timestamp or datetime.utcnow() - await self.run_pipeline(TurnContext(self, activity), logic) - - async def send_activities( - self, context, activities: List[Activity] - ) -> List[ResourceResponse]: - """ - INTERNAL: called by the logic under test to send a set of activities. These will be buffered - to the current `TestFlow` instance for comparison against the expected results. - :param context: - :param activities: - :return: - """ - - def id_mapper(activity): - self.activity_buffer.append(activity) - self._next_id += 1 - return ResourceResponse(id=str(self._next_id)) - - return [ - id_mapper(activity) - for activity in activities - if self.send_trace_activities or activity.type != "trace" - ] - - async def delete_activity(self, context, reference: ConversationReference): - """ - INTERNAL: called by the logic under test to delete an existing activity. These are simply - pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn - completes. - :param reference: - :return: - """ - self.deleted_activities.append(reference) - - async def update_activity(self, context, activity: Activity): - """ - INTERNAL: called by the logic under test to replace an existing activity. These are simply - pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn - completes. - :param activity: - :return: - """ - self.updated_activities.append(activity) - - async def continue_conversation( - self, - reference: ConversationReference, - callback: Callable, - bot_id: str = None, - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument - ): - """ - The `TestAdapter` just calls parent implementation. - :param reference: - :param callback: - :param bot_id: - :param claims_identity: - :return: - """ - await super().continue_conversation( - reference, callback, bot_id, claims_identity - ) - - async def receive_activity(self, activity): - """ - INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. - This will cause the adapters middleware pipe to be run and it's logic to be called. - :param activity: - :return: - """ - if isinstance(activity, str): - activity = Activity(type="message", text=activity) - # Initialize request. - request = copy(self.template) - - for key, value in vars(activity).items(): - if value is not None and key != "additional_properties": - setattr(request, key, value) - - request.type = request.type or ActivityTypes.message - if not request.id: - self._next_id += 1 - request.id = str(self._next_id) - - # Create context object and run middleware. - context = TurnContext(self, request) - return await self.run_pipeline(context, self.logic) - - def get_next_activity(self) -> Activity: - return self.activity_buffer.pop(0) - - async def send(self, user_says) -> object: - """ - Sends something to the bot. This returns a new `TestFlow` instance which can be used to add - additional steps for inspecting the bots reply and then sending additional activities. - :param user_says: - :return: A new instance of the TestFlow object - """ - return TestFlow(await self.receive_activity(user_says), self) - - async def test( - self, user_says, expected, description=None, timeout=None - ) -> "TestFlow": - """ - Send something to the bot and expects the bot to return with a given reply. This is simply a - wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a - helper is provided. - :param user_says: - :param expected: - :param description: - :param timeout: - :return: - """ - test_flow = await self.send(user_says) - test_flow = await test_flow.assert_reply(expected, description, timeout) - return test_flow - - async def tests(self, *args): - """ - Support multiple test cases without having to manually call `test()` repeatedly. This is a - convenience layer around the `test()`. Valid args are either lists or tuples of parameters - :param args: - :return: - """ - for arg in args: - description = None - timeout = None - if len(arg) >= 3: - description = arg[2] - if len(arg) == 4: - timeout = arg[3] - await self.test(arg[0], arg[1], description, timeout) - - def add_user_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - token: str, - magic_code: str = None, - ): - key = UserToken() - key.channel_id = channel_id - key.connection_name = connection_name - key.user_id = user_id - key.token = token - - if not magic_code: - self._user_tokens.append(key) - else: - code = TokenMagicCode() - code.key = key - code.magic_code = magic_code - self._magic_codes.append(code) - - async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None - ) -> TokenResponse: - key = UserToken() - key.channel_id = context.activity.channel_id - key.connection_name = connection_name - key.user_id = context.activity.from_property.id - - if magic_code: - magic_code_record = list( - filter(lambda x: key.equals_key(x.key), self._magic_codes) - ) - if magic_code_record and magic_code_record[0].magic_code == magic_code: - # Move the token to long term dictionary. - self.add_user_token( - connection_name, - key.channel_id, - key.user_id, - magic_code_record[0].key.token, - ) - - # Remove from the magic code list. - idx = self._magic_codes.index(magic_code_record[0]) - self._magic_codes = [self._magic_codes.pop(idx)] - - match = [token for token in self._user_tokens if key.equals_key(token)] - - if match: - return TokenResponse( - connection_name=match[0].connection_name, - token=match[0].token, - expiration=None, - ) - # Not found. - return None - - async def sign_out_user( - self, context: TurnContext, connection_name: str, user_id: str = None - ): - channel_id = context.activity.channel_id - user_id = context.activity.from_property.id - - new_records = [] - for token in self._user_tokens: - if ( - token.channel_id != channel_id - or token.user_id != user_id - or (connection_name and connection_name != token.connection_name) - ): - new_records.append(token) - self._user_tokens = new_records - - async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str - ) -> str: - return ( - f"https://fake.com/oauthsignin" - f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" - ) - - async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] - ) -> Dict[str, TokenResponse]: - return None - - -class TestFlow: - def __init__(self, previous: Callable, adapter: TestAdapter): - """ - INTERNAL: creates a new TestFlow instance. - :param previous: - :param adapter: - """ - self.previous = previous - self.adapter = adapter - - async def test( - self, user_says, expected, description=None, timeout=None - ) -> "TestFlow": - """ - Send something to the bot and expects the bot to return with a given reply. This is simply a - wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a - helper is provided. - :param user_says: - :param expected: - :param description: - :param timeout: - :return: - """ - test_flow = await self.send(user_says) - return await test_flow.assert_reply( - expected, description or f'test("{user_says}", "{expected}")', timeout - ) - - async def send(self, user_says) -> "TestFlow": - """ - Sends something to the bot. - :param user_says: - :return: - """ - - async def new_previous(): - nonlocal self, user_says - if callable(self.previous): - await self.previous() - await self.adapter.receive_activity(user_says) - - return TestFlow(await new_previous(), self.adapter) - - async def assert_reply( - self, - expected: Union[str, Activity, Callable[[Activity, str], None]], - description=None, - timeout=None, # pylint: disable=unused-argument - is_substring=False, - ) -> "TestFlow": - """ - Generates an assertion if the bots response doesn't match the expected text/activity. - :param expected: - :param description: - :param timeout: - :param is_substring: - :return: - """ - # TODO: refactor method so expected can take a Callable[[Activity], None] - def default_inspector(reply, description=None): - if isinstance(expected, Activity): - validate_activity(reply, expected) - else: - assert reply.type == "message", description + f" type == {reply.type}" - if is_substring: - assert expected in reply.text.strip(), ( - description + f" text == {reply.text}" - ) - else: - assert reply.text.strip() == expected.strip(), ( - description + f" text == {reply.text}" - ) - - if description is None: - description = "" - - inspector = expected if callable(expected) else default_inspector - - async def test_flow_previous(): - nonlocal timeout - if not timeout: - timeout = 3000 - start = datetime.now() - adapter = self.adapter - - async def wait_for_activity(): - nonlocal expected, timeout - current = datetime.now() - if (current - start).total_seconds() * 1000 > timeout: - if isinstance(expected, Activity): - expecting = expected.text - elif callable(expected): - expecting = inspect.getsourcefile(expected) - else: - expecting = str(expected) - raise RuntimeError( - f"TestAdapter.assert_reply({expecting}): {description} Timed out after " - f"{current - start}ms." - ) - if adapter.activity_buffer: - reply = adapter.activity_buffer.pop(0) - try: - await inspector(reply, description) - except Exception: - inspector(reply, description) - - else: - await asyncio.sleep(0.05) - await wait_for_activity() - - await wait_for_activity() - - return TestFlow(await test_flow_previous(), self.adapter) - - -def validate_activity(activity, expected) -> None: - """ - Helper method that compares activities - :param activity: - :param expected: - :return: - """ - iterable_expected = vars(expected).items() - - for attr, value in iterable_expected: - if value is not None and attr != "additional_properties": - assert value == getattr(activity, attr) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# TODO: enable this in the future +# With python 3.7 the line below will allow to do Postponed Evaluation of Annotations. See PEP 563 +# from __future__ import annotations + +import asyncio +import inspect +from datetime import datetime +from typing import Awaitable, Coroutine, Dict, List, Callable, Union +from copy import copy +from threading import Lock +from botbuilder.schema import ( + ActivityTypes, + Activity, + ConversationAccount, + ConversationReference, + ChannelAccount, + ResourceResponse, + TokenResponse, +) +from botframework.connector.auth import ClaimsIdentity +from ..bot_adapter import BotAdapter +from ..turn_context import TurnContext +from ..user_token_provider import UserTokenProvider + + +class UserToken: + def __init__( + self, + connection_name: str = None, + user_id: str = None, + channel_id: str = None, + token: str = None, + ): + self.connection_name = connection_name + self.user_id = user_id + self.channel_id = channel_id + self.token = token + + def equals_key(self, rhs: "UserToken"): + return ( + rhs is not None + and self.connection_name == rhs.connection_name + and self.user_id == rhs.user_id + and self.channel_id == rhs.channel_id + ) + + +class TokenMagicCode: + def __init__(self, key: UserToken = None, magic_code: str = None): + self.key = key + self.magic_code = magic_code + + +class TestAdapter(BotAdapter, UserTokenProvider): + __test__ = False + + def __init__( + self, + logic: Coroutine = None, + template_or_conversation: Union[Activity, ConversationReference] = None, + send_trace_activities: bool = False, + ): # pylint: disable=unused-argument + """ + Creates a new TestAdapter instance. + :param logic: + :param conversation: A reference to the conversation to begin the adapter state with. + """ + super(TestAdapter, self).__init__() + self.logic = logic + self._next_id: int = 0 + self._user_tokens: List[UserToken] = [] + self._magic_codes: List[TokenMagicCode] = [] + self._conversation_lock = Lock() + self.activity_buffer: List[Activity] = [] + self.updated_activities: List[Activity] = [] + self.deleted_activities: List[ConversationReference] = [] + self.send_trace_activities = send_trace_activities + + self.template = ( + template_or_conversation + if isinstance(template_or_conversation, Activity) + else Activity( + channel_id="test", + service_url="https://test.com", + from_property=ChannelAccount(id="User1", name="user"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount(id="Convo1"), + ) + ) + + if isinstance(template_or_conversation, ConversationReference): + self.template.channel_id = template_or_conversation.channel_id + + async def process_activity( + self, activity: Activity, logic: Callable[[TurnContext], Awaitable] + ): + self._conversation_lock.acquire() + try: + # ready for next reply + if activity.type is None: + activity.type = ActivityTypes.message + + activity.channel_id = self.template.channel_id + activity.from_property = self.template.from_property + activity.recipient = self.template.recipient + activity.conversation = self.template.conversation + activity.service_url = self.template.service_url + + activity.id = str((self._next_id)) + self._next_id += 1 + finally: + self._conversation_lock.release() + + activity.timestamp = activity.timestamp or datetime.utcnow() + await self.run_pipeline(TurnContext(self, activity), logic) + + async def send_activities( + self, context, activities: List[Activity] + ) -> List[ResourceResponse]: + """ + INTERNAL: called by the logic under test to send a set of activities. These will be buffered + to the current `TestFlow` instance for comparison against the expected results. + :param context: + :param activities: + :return: + """ + + def id_mapper(activity): + self.activity_buffer.append(activity) + self._next_id += 1 + return ResourceResponse(id=str(self._next_id)) + + return [ + id_mapper(activity) + for activity in activities + if self.send_trace_activities or activity.type != "trace" + ] + + async def delete_activity(self, context, reference: ConversationReference): + """ + INTERNAL: called by the logic under test to delete an existing activity. These are simply + pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn + completes. + :param reference: + :return: + """ + self.deleted_activities.append(reference) + + async def update_activity(self, context, activity: Activity): + """ + INTERNAL: called by the logic under test to replace an existing activity. These are simply + pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn + completes. + :param activity: + :return: + """ + self.updated_activities.append(activity) + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ): + """ + The `TestAdapter` just calls parent implementation. + :param reference: + :param callback: + :param bot_id: + :param claims_identity: + :return: + """ + await super().continue_conversation( + reference, callback, bot_id, claims_identity + ) + + async def receive_activity(self, activity): + """ + INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. + This will cause the adapters middleware pipe to be run and it's logic to be called. + :param activity: + :return: + """ + if isinstance(activity, str): + activity = Activity(type="message", text=activity) + # Initialize request. + request = copy(self.template) + + for key, value in vars(activity).items(): + if value is not None and key != "additional_properties": + setattr(request, key, value) + + request.type = request.type or ActivityTypes.message + if not request.id: + self._next_id += 1 + request.id = str(self._next_id) + + # Create context object and run middleware. + context = TurnContext(self, request) + return await self.run_pipeline(context, self.logic) + + def get_next_activity(self) -> Activity: + return self.activity_buffer.pop(0) + + async def send(self, user_says) -> object: + """ + Sends something to the bot. This returns a new `TestFlow` instance which can be used to add + additional steps for inspecting the bots reply and then sending additional activities. + :param user_says: + :return: A new instance of the TestFlow object + """ + return TestFlow(await self.receive_activity(user_says), self) + + async def test( + self, user_says, expected, description=None, timeout=None + ) -> "TestFlow": + """ + Send something to the bot and expects the bot to return with a given reply. This is simply a + wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a + helper is provided. + :param user_says: + :param expected: + :param description: + :param timeout: + :return: + """ + test_flow = await self.send(user_says) + test_flow = await test_flow.assert_reply(expected, description, timeout) + return test_flow + + async def tests(self, *args): + """ + Support multiple test cases without having to manually call `test()` repeatedly. This is a + convenience layer around the `test()`. Valid args are either lists or tuples of parameters + :param args: + :return: + """ + for arg in args: + description = None + timeout = None + if len(arg) >= 3: + description = arg[2] + if len(arg) == 4: + timeout = arg[3] + await self.test(arg[0], arg[1], description, timeout) + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str = None, + ): + key = UserToken() + key.channel_id = channel_id + key.connection_name = connection_name + key.user_id = user_id + key.token = token + + if not magic_code: + self._user_tokens.append(key) + else: + code = TokenMagicCode() + code.key = key + code.magic_code = magic_code + self._magic_codes.append(code) + + async def get_user_token( + self, context: TurnContext, connection_name: str, magic_code: str = None + ) -> TokenResponse: + key = UserToken() + key.channel_id = context.activity.channel_id + key.connection_name = connection_name + key.user_id = context.activity.from_property.id + + if magic_code: + magic_code_record = list( + filter(lambda x: key.equals_key(x.key), self._magic_codes) + ) + if magic_code_record and magic_code_record[0].magic_code == magic_code: + # Move the token to long term dictionary. + self.add_user_token( + connection_name, + key.channel_id, + key.user_id, + magic_code_record[0].key.token, + ) + + # Remove from the magic code list. + idx = self._magic_codes.index(magic_code_record[0]) + self._magic_codes = [self._magic_codes.pop(idx)] + + match = [token for token in self._user_tokens if key.equals_key(token)] + + if match: + return TokenResponse( + connection_name=match[0].connection_name, + token=match[0].token, + expiration=None, + ) + # Not found. + return None + + async def sign_out_user( + self, context: TurnContext, connection_name: str, user_id: str = None + ): + channel_id = context.activity.channel_id + user_id = context.activity.from_property.id + + new_records = [] + for token in self._user_tokens: + if ( + token.channel_id != channel_id + or token.user_id != user_id + or (connection_name and connection_name != token.connection_name) + ): + new_records.append(token) + self._user_tokens = new_records + + async def get_oauth_sign_in_link( + self, context: TurnContext, connection_name: str + ) -> str: + return ( + f"https://fake.com/oauthsignin" + f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" + ) + + async def get_aad_tokens( + self, context: TurnContext, connection_name: str, resource_urls: List[str] + ) -> Dict[str, TokenResponse]: + return None + + +class TestFlow: + __test__ = False + + def __init__(self, previous: Callable, adapter: TestAdapter): + """ + INTERNAL: creates a new TestFlow instance. + :param previous: + :param adapter: + """ + self.previous = previous + self.adapter = adapter + + async def test( + self, user_says, expected, description=None, timeout=None + ) -> "TestFlow": + """ + Send something to the bot and expects the bot to return with a given reply. This is simply a + wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a + helper is provided. + :param user_says: + :param expected: + :param description: + :param timeout: + :return: + """ + test_flow = await self.send(user_says) + return await test_flow.assert_reply( + expected, description or f'test("{user_says}", "{expected}")', timeout + ) + + async def send(self, user_says) -> "TestFlow": + """ + Sends something to the bot. + :param user_says: + :return: + """ + + async def new_previous(): + nonlocal self, user_says + if callable(self.previous): + await self.previous() + await self.adapter.receive_activity(user_says) + + return TestFlow(await new_previous(), self.adapter) + + async def assert_reply( + self, + expected: Union[str, Activity, Callable[[Activity, str], None]], + description=None, + timeout=None, # pylint: disable=unused-argument + is_substring=False, + ) -> "TestFlow": + """ + Generates an assertion if the bots response doesn't match the expected text/activity. + :param expected: + :param description: + :param timeout: + :param is_substring: + :return: + """ + # TODO: refactor method so expected can take a Callable[[Activity], None] + def default_inspector(reply, description=None): + if isinstance(expected, Activity): + validate_activity(reply, expected) + else: + assert reply.type == "message", description + f" type == {reply.type}" + if is_substring: + assert expected in reply.text.strip(), ( + description + f" text == {reply.text}" + ) + else: + assert reply.text.strip() == expected.strip(), ( + description + f" text == {reply.text}" + ) + + if description is None: + description = "" + + inspector = expected if callable(expected) else default_inspector + + async def test_flow_previous(): + nonlocal timeout + if not timeout: + timeout = 3000 + start = datetime.now() + adapter = self.adapter + + async def wait_for_activity(): + nonlocal expected, timeout + current = datetime.now() + if (current - start).total_seconds() * 1000 > timeout: + if isinstance(expected, Activity): + expecting = expected.text + elif callable(expected): + expecting = inspect.getsourcefile(expected) + else: + expecting = str(expected) + raise RuntimeError( + f"TestAdapter.assert_reply({expecting}): {description} Timed out after " + f"{current - start}ms." + ) + if adapter.activity_buffer: + reply = adapter.activity_buffer.pop(0) + try: + await inspector(reply, description) + except Exception: + inspector(reply, description) + + else: + await asyncio.sleep(0.05) + await wait_for_activity() + + await wait_for_activity() + + return TestFlow(await test_flow_previous(), self.adapter) + + +def validate_activity(activity, expected) -> None: + """ + Helper method that compares activities + :param activity: + :param expected: + :return: + """ + iterable_expected = vars(expected).items() + + for attr, value in iterable_expected: + if value is not None and attr != "additional_properties": + assert value == getattr(activity, attr) diff --git a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py index ea10e3fac..6002fbcc7 100644 --- a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py @@ -1,95 +1,95 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import time -from functools import wraps -from typing import Awaitable, Callable - -from botbuilder.schema import Activity, ActivityTypes - -from .middleware_set import Middleware -from .turn_context import TurnContext - - -def delay(span=0.0): - def wrap(func): - @wraps(func) - async def delayed(): - time.sleep(span) - await func() - - return delayed - - return wrap - - -class Timer: - clear_timer = False - - async def set_timeout(self, func, time): - is_invocation_cancelled = False - - @delay(time) - async def some_fn(): # pylint: disable=function-redefined - if not self.clear_timer: - await func() - - await some_fn() - return is_invocation_cancelled - - def set_clear_timer(self): - self.clear_timer = True - - -class ShowTypingMiddleware(Middleware): - def __init__(self, delay: float = 0.5, period: float = 2.0): - if delay < 0: - raise ValueError("Delay must be greater than or equal to zero") - - if period <= 0: - raise ValueError("Repeat period must be greater than zero") - - self._delay = delay - self._period = period - - async def on_turn( - self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] - ): - finished = False - timer = Timer() - - async def start_interval(context: TurnContext, delay: int, period: int): - async def aux(): - if not finished: - typing_activity = Activity( - type=ActivityTypes.typing, - relates_to=context.activity.relates_to, - ) - - conversation_reference = TurnContext.get_conversation_reference( - context.activity - ) - - typing_activity = TurnContext.apply_conversation_reference( - typing_activity, conversation_reference - ) - - await context.adapter.send_activities(context, [typing_activity]) - - start_interval(context, period, period) - - await timer.set_timeout(aux, delay) - - def stop_interval(): - nonlocal finished - finished = True - timer.set_clear_timer() - - if context.activity.type == ActivityTypes.message: - finished = False - await start_interval(context, self._delay, self._period) - - result = await logic() - stop_interval() - - return result +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import time +from functools import wraps +from typing import Awaitable, Callable + +from botbuilder.schema import Activity, ActivityTypes + +from .middleware_set import Middleware +from .turn_context import TurnContext + + +def delay(span=0.0): + def wrap(func): + @wraps(func) + async def delayed(): + time.sleep(span) + await func() + + return delayed + + return wrap + + +class Timer: + clear_timer = False + + async def set_timeout(self, func, time): + is_invocation_cancelled = False + + @delay(time) + async def some_fn(): # pylint: disable=function-redefined + if not self.clear_timer: + await func() + + await some_fn() + return is_invocation_cancelled + + def set_clear_timer(self): + self.clear_timer = True + + +class ShowTypingMiddleware(Middleware): + def __init__(self, delay: float = 0.5, period: float = 2.0): + if delay < 0: + raise ValueError("Delay must be greater than or equal to zero") + + if period <= 0: + raise ValueError("Repeat period must be greater than zero") + + self._delay = delay + self._period = period + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + finished = False + timer = Timer() + + async def start_interval(context: TurnContext, delay: int, period: int): + async def aux(): + if not finished: + typing_activity = Activity( + type=ActivityTypes.typing, + relates_to=context.activity.relates_to, + ) + + conversation_reference = TurnContext.get_conversation_reference( + context.activity + ) + + typing_activity = TurnContext.apply_conversation_reference( + typing_activity, conversation_reference + ) + + await context.adapter.send_activities(context, [typing_activity]) + + start_interval(context, period, period) + + await timer.set_timeout(aux, delay) + + def stop_interval(): + nonlocal finished + finished = True + timer.set_clear_timer() + + if context.activity.type == ActivityTypes.message: + finished = False + await start_interval(context, self._delay, self._period) + + result = await logic() + stop_interval() + + return result diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index a80fa29b3..1202ad7f1 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -1,60 +1,60 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import unittest -from typing import List -from botbuilder.core import BotAdapter, TurnContext -from botbuilder.schema import Activity, ConversationReference, ResourceResponse - - -class SimpleAdapter(BotAdapter): - # pylint: disable=unused-argument - - def __init__(self, call_on_send=None, call_on_update=None, call_on_delete=None): - super(SimpleAdapter, self).__init__() - self.test_aux = unittest.TestCase("__init__") - self._call_on_send = call_on_send - self._call_on_update = call_on_update - self._call_on_delete = call_on_delete - - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - self.test_aux.assertIsNotNone( - reference, "SimpleAdapter.delete_activity: missing reference" - ) - if self._call_on_delete is not None: - self._call_on_delete(reference) - - async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: - self.test_aux.assertIsNotNone( - activities, "SimpleAdapter.delete_activity: missing reference" - ) - self.test_aux.assertTrue( - len(activities) > 0, - "SimpleAdapter.send_activities: empty activities array.", - ) - - if self._call_on_send is not None: - self._call_on_send(activities) - responses = [] - - for activity in activities: - responses.append(ResourceResponse(id=activity.id)) - - return responses - - async def update_activity(self, context: TurnContext, activity: Activity): - self.test_aux.assertIsNotNone( - activity, "SimpleAdapter.update_activity: missing activity" - ) - if self._call_on_update is not None: - self._call_on_update(activity) - - return ResourceResponse(activity.id) - - async def process_request(self, activity, handler): - context = TurnContext(self, activity) - return self.run_pipeline(context, handler) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from typing import List +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import Activity, ConversationReference, ResourceResponse + + +class SimpleAdapter(BotAdapter): + # pylint: disable=unused-argument + + def __init__(self, call_on_send=None, call_on_update=None, call_on_delete=None): + super(SimpleAdapter, self).__init__() + self.test_aux = unittest.TestCase("__init__") + self._call_on_send = call_on_send + self._call_on_update = call_on_update + self._call_on_delete = call_on_delete + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + self.test_aux.assertIsNotNone( + reference, "SimpleAdapter.delete_activity: missing reference" + ) + if self._call_on_delete is not None: + self._call_on_delete(reference) + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + self.test_aux.assertIsNotNone( + activities, "SimpleAdapter.delete_activity: missing reference" + ) + self.test_aux.assertTrue( + len(activities) > 0, + "SimpleAdapter.send_activities: empty activities array.", + ) + + if self._call_on_send is not None: + self._call_on_send(activities) + responses = [] + + for activity in activities: + responses.append(ResourceResponse(id=activity.id)) + + return responses + + async def update_activity(self, context: TurnContext, activity: Activity): + self.test_aux.assertIsNotNone( + activity, "SimpleAdapter.update_activity: missing activity" + ) + if self._call_on_update is not None: + self._call_on_update(activity) + + return ResourceResponse(activity.id) + + async def process_request(self, activity, handler): + context = TurnContext(self, activity) + return await self.run_pipeline(context, handler) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 8de03909c..38c4e2c14 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -1,724 +1,726 @@ -from typing import List - -import aiounittest -from botbuilder.core import BotAdapter, TurnContext -from botbuilder.core.teams import TeamsActivityHandler -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationReference, - ResourceResponse, -) -from botbuilder.schema.teams import ( - AppBasedLinkQuery, - ChannelInfo, - FileConsentCardResponse, - MessageActionsPayload, - MessagingExtensionAction, - MessagingExtensionQuery, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleRequestContext, - TeamInfo, - TeamsChannelAccount, -) -from botframework.connector import Channels -from simple_adapter import SimpleAdapter - - -class TestingTeamsActivityHandler(TeamsActivityHandler): - def __init__(self): - self.record: List[str] = [] - - async def on_conversation_update_activity(self, turn_context: TurnContext): - self.record.append("on_conversation_update_activity") - return await super().on_conversation_update_activity(turn_context) - - async def on_teams_members_removed( - self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext - ): - self.record.append("on_teams_members_removed") - return await super().on_teams_members_removed( - teams_members_removed, turn_context - ) - - async def on_message_activity(self, turn_context: TurnContext): - self.record.append("on_message_activity") - return await super().on_message_activity(turn_context) - - async def on_token_response_event(self, turn_context: TurnContext): - self.record.append("on_token_response_event") - return await super().on_token_response_event(turn_context) - - async def on_event(self, turn_context: TurnContext): - self.record.append("on_event") - return await super().on_event(turn_context) - - async def on_unrecognized_activity_type(self, turn_context: TurnContext): - self.record.append("on_unrecognized_activity_type") - return await super().on_unrecognized_activity_type(turn_context) - - async def on_teams_channel_created( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ): - self.record.append("on_teams_channel_created") - return await super().on_teams_channel_created( - channel_info, team_info, turn_context - ) - - async def on_teams_channel_renamed( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ): - self.record.append("on_teams_channel_renamed") - return await super().on_teams_channel_renamed( - channel_info, team_info, turn_context - ) - - async def on_teams_channel_deleted( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ): - self.record.append("on_teams_channel_deleted") - return await super().on_teams_channel_renamed( - channel_info, team_info, turn_context - ) - - async def on_teams_team_renamed_activity( - self, team_info: TeamInfo, turn_context: TurnContext - ): - self.record.append("on_teams_team_renamed_activity") - return await super().on_teams_team_renamed_activity(team_info, turn_context) - - async def on_invoke_activity(self, turn_context: TurnContext): - self.record.append("on_invoke_activity") - return await super().on_invoke_activity(turn_context) - - async def on_teams_signin_verify_state(self, turn_context: TurnContext): - self.record.append("on_teams_signin_verify_state") - return await super().on_teams_signin_verify_state(turn_context) - - async def on_teams_file_consent( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ): - self.record.append("on_teams_file_consent") - return await super().on_teams_file_consent( - turn_context, file_consent_card_response - ) - - async def on_teams_file_consent_accept( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ): - self.record.append("on_teams_file_consent_accept") - return await super().on_teams_file_consent_accept( - turn_context, file_consent_card_response - ) - - async def on_teams_file_consent_decline( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ): - self.record.append("on_teams_file_consent_decline") - return await super().on_teams_file_consent_decline( - turn_context, file_consent_card_response - ) - - async def on_teams_o365_connector_card_action( - self, turn_context: TurnContext, query: O365ConnectorCardActionQuery - ): - self.record.append("on_teams_o365_connector_card_action") - return await super().on_teams_o365_connector_card_action(turn_context, query) - - async def on_teams_app_based_link_query( - self, turn_context: TurnContext, query: AppBasedLinkQuery - ): - self.record.append("on_teams_app_based_link_query") - return await super().on_teams_app_based_link_query(turn_context, query) - - async def on_teams_messaging_extension_query( - self, turn_context: TurnContext, query: MessagingExtensionQuery - ): - self.record.append("on_teams_messaging_extension_query") - return await super().on_teams_messaging_extension_query(turn_context, query) - - async def on_teams_messaging_extension_submit_action_dispatch( - self, turn_context: TurnContext, action: MessagingExtensionAction - ): - self.record.append("on_teams_messaging_extension_submit_action_dispatch") - return await super().on_teams_messaging_extension_submit_action_dispatch( - turn_context, action - ) - - async def on_teams_messaging_extension_submit_action( - self, turn_context: TurnContext, action: MessagingExtensionAction - ): - self.record.append("on_teams_messaging_extension_submit_action") - return await super().on_teams_messaging_extension_submit_action( - turn_context, action - ) - - async def on_teams_messaging_extension_bot_message_preview_edit( - self, turn_context: TurnContext, action: MessagingExtensionAction - ): - self.record.append("on_teams_messaging_extension_bot_message_preview_edit") - return await super().on_teams_messaging_extension_bot_message_preview_edit( - turn_context, action - ) - - async def on_teams_messaging_extension_bot_message_preview_send( - self, turn_context: TurnContext, action: MessagingExtensionAction - ): - self.record.append("on_teams_messaging_extension_bot_message_preview_send") - return await super().on_teams_messaging_extension_bot_message_preview_send( - turn_context, action - ) - - async def on_teams_messaging_extension_fetch_task( - self, turn_context: TurnContext, action: MessagingExtensionAction - ): - self.record.append("on_teams_messaging_extension_fetch_task") - return await super().on_teams_messaging_extension_fetch_task( - turn_context, action - ) - - async def on_teams_messaging_extension_configuration_query_settings_url( - self, turn_context: TurnContext, query: MessagingExtensionQuery - ): - self.record.append( - "on_teams_messaging_extension_configuration_query_settings_url" - ) - return await super().on_teams_messaging_extension_configuration_query_settings_url( - turn_context, query - ) - - async def on_teams_messaging_extension_configuration_setting( - self, turn_context: TurnContext, settings - ): - self.record.append("on_teams_messaging_extension_configuration_setting") - return await super().on_teams_messaging_extension_configuration_setting( - turn_context, settings - ) - - async def on_teams_messaging_extension_card_button_clicked( - self, turn_context: TurnContext, card_data - ): - self.record.append("on_teams_messaging_extension_card_button_clicked") - return await super().on_teams_messaging_extension_card_button_clicked( - turn_context, card_data - ) - - async def on_teams_task_module_fetch( - self, turn_context: TurnContext, task_module_request - ): - self.record.append("on_teams_task_module_fetch") - return await super().on_teams_task_module_fetch( - turn_context, task_module_request - ) - - async def on_teams_task_module_submit( # pylint: disable=unused-argument - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ): - self.record.append("on_teams_task_module_submit") - return await super().on_teams_task_module_submit( - turn_context, task_module_request - ) - - -class NotImplementedAdapter(BotAdapter): - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - raise NotImplementedError() - - async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: - raise NotImplementedError() - - async def update_activity(self, context: TurnContext, activity: Activity): - raise NotImplementedError() - - -class TestTeamsActivityHandler(aiounittest.AsyncTestCase): - async def test_on_teams_channel_created_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.conversation_update, - channel_data={ - "eventType": "channelCreated", - "channel": {"id": "asdfqwerty", "name": "new_channel"}, - }, - channel_id=Channels.ms_teams, - ) - - turn_context = TurnContext(NotImplementedAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_channel_created" - - async def test_on_teams_channel_renamed_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.conversation_update, - channel_data={ - "eventType": "channelRenamed", - "channel": {"id": "asdfqwerty", "name": "new_channel"}, - }, - channel_id=Channels.ms_teams, - ) - - turn_context = TurnContext(NotImplementedAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_channel_renamed" - - async def test_on_teams_channel_deleted_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.conversation_update, - channel_data={ - "eventType": "channelDeleted", - "channel": {"id": "asdfqwerty", "name": "new_channel"}, - }, - channel_id=Channels.ms_teams, - ) - - turn_context = TurnContext(NotImplementedAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_channel_deleted" - - async def test_on_teams_team_renamed_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.conversation_update, - channel_data={ - "eventType": "teamRenamed", - "team": {"id": "team_id_1", "name": "new_team_name"}, - }, - channel_id=Channels.ms_teams, - ) - - turn_context = TurnContext(NotImplementedAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_team_renamed_activity" - - async def test_on_teams_members_removed_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.conversation_update, - channel_data={"eventType": "teamMemberRemoved"}, - members_removed=[ - ChannelAccount( - id="123", - name="test_user", - aad_object_id="asdfqwerty", - role="tester", - ) - ], - channel_id=Channels.ms_teams, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_conversation_update_activity" - assert bot.record[1] == "on_teams_members_removed" - - async def test_on_signin_verify_state(self): - # arrange - activity = Activity(type=ActivityTypes.invoke, name="signin/verifyState") - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_signin_verify_state" - - async def test_on_file_consent_accept_activity(self): - # arrange - activity = Activity( - type=ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_file_consent" - assert bot.record[2] == "on_teams_file_consent_accept" - - async def test_on_file_consent_decline_activity(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_file_consent" - assert bot.record[2] == "on_teams_file_consent_decline" - - async def test_on_file_consent_bad_action_activity(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "bad_action"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_file_consent" - - async def test_on_teams_o365_connector_card_action(self): - # arrange - activity = Activity( - type=ActivityTypes.invoke, - name="actionableMessage/executeAction", - value={"body": "body_here", "actionId": "action_id_here"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_o365_connector_card_action" - - async def test_on_app_based_link_query(self): - # arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/query", - value={"url": "http://www.test.com"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_query" - - async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(self): - # Arrange - - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/submitAction", - value={ - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "commandId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "edit", - "botActivityPreview": [{"id": "activity123"}], - "messagePayload": {"id": "payloadid"}, - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_edit" - - async def test_on_teams_messaging_extension_bot_message_send_activity(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/submitAction", - value={ - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "commandId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "send", - "botActivityPreview": [{"id": "123"}], - "messagePayload": {"id": "abc"}, - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_send" - - async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( - self, - ): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/submitAction", - value={ - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "commandId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": None, - "botActivityPreview": [{"id": "test123"}], - "messagePayload": {"id": "payloadid123"}, - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_submit_action" - - async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty_string( - self, - ): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/submitAction", - value={ - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "commandId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "", - "botActivityPreview": [Activity().serialize()], - "messagePayload": MessageActionsPayload().serialize(), - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" - assert bot.record[2] == "on_teams_messaging_extension_submit_action" - - async def test_on_teams_messaging_extension_fetch_task(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={ - "data": {"key": "value"}, - "context": {"theme": "dark"}, - "commandId": "test_command", - "commandContext": "command_context_test", - "botMessagePreviewAction": "message_action", - "botActivityPreview": [{"id": "123"}], - "messagePayload": {"id": "abc123"}, - }, - ) - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_fetch_task" - - async def test_on_teams_messaging_extension_configuration_query_settings_url(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/querySettingUrl", - value={ - "commandId": "test_command", - "parameters": [], - "messagingExtensionQueryOptions": {"skip": 1, "count": 1}, - "state": "state_string", - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert ( - bot.record[1] - == "on_teams_messaging_extension_configuration_query_settings_url" - ) - - async def test_on_teams_messaging_extension_configuration_setting(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/setting", - value={"key": "value"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_configuration_setting" - - async def test_on_teams_messaging_extension_card_button_clicked(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="composeExtension/onCardButtonClicked", - value={"key": "value"}, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_messaging_extension_card_button_clicked" - - async def test_on_teams_task_module_fetch(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="task/fetch", - value={ - "data": {"key": "value"}, - "context": TaskModuleRequestContext().serialize(), - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_task_module_fetch" - - async def test_on_teams_task_module_submit(self): - # Arrange - activity = Activity( - type=ActivityTypes.invoke, - name="task/submit", - value={ - "data": {"key": "value"}, - "context": TaskModuleRequestContext().serialize(), - }, - ) - - turn_context = TurnContext(SimpleAdapter(), activity) - - # Act - bot = TestingTeamsActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 2 - assert bot.record[0] == "on_invoke_activity" - assert bot.record[1] == "on_teams_task_module_submit" +from typing import List + +import aiounittest +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.core.teams import TeamsActivityHandler +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationReference, + ResourceResponse, +) +from botbuilder.schema.teams import ( + AppBasedLinkQuery, + ChannelInfo, + FileConsentCardResponse, + MessageActionsPayload, + MessagingExtensionAction, + MessagingExtensionQuery, + O365ConnectorCardActionQuery, + TaskModuleRequest, + TaskModuleRequestContext, + TeamInfo, + TeamsChannelAccount, +) +from botframework.connector import Channels +from simple_adapter import SimpleAdapter + + +class TestingTeamsActivityHandler(TeamsActivityHandler): + __test__ = False + + def __init__(self): + self.record: List[str] = [] + + async def on_conversation_update_activity(self, turn_context: TurnContext): + self.record.append("on_conversation_update_activity") + return await super().on_conversation_update_activity(turn_context) + + async def on_teams_members_removed( + self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + ): + self.record.append("on_teams_members_removed") + return await super().on_teams_members_removed( + teams_members_removed, turn_context + ) + + async def on_message_activity(self, turn_context: TurnContext): + self.record.append("on_message_activity") + return await super().on_message_activity(turn_context) + + async def on_token_response_event(self, turn_context: TurnContext): + self.record.append("on_token_response_event") + return await super().on_token_response_event(turn_context) + + async def on_event(self, turn_context: TurnContext): + self.record.append("on_event") + return await super().on_event(turn_context) + + async def on_unrecognized_activity_type(self, turn_context: TurnContext): + self.record.append("on_unrecognized_activity_type") + return await super().on_unrecognized_activity_type(turn_context) + + async def on_teams_channel_created( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_created") + return await super().on_teams_channel_created( + channel_info, team_info, turn_context + ) + + async def on_teams_channel_renamed( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_renamed") + return await super().on_teams_channel_renamed( + channel_info, team_info, turn_context + ) + + async def on_teams_channel_deleted( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_deleted") + return await super().on_teams_channel_renamed( + channel_info, team_info, turn_context + ) + + async def on_teams_team_renamed_activity( + self, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_team_renamed_activity") + return await super().on_teams_team_renamed_activity(team_info, turn_context) + + async def on_invoke_activity(self, turn_context: TurnContext): + self.record.append("on_invoke_activity") + return await super().on_invoke_activity(turn_context) + + async def on_teams_signin_verify_state(self, turn_context: TurnContext): + self.record.append("on_teams_signin_verify_state") + return await super().on_teams_signin_verify_state(turn_context) + + async def on_teams_file_consent( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ): + self.record.append("on_teams_file_consent") + return await super().on_teams_file_consent( + turn_context, file_consent_card_response + ) + + async def on_teams_file_consent_accept( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ): + self.record.append("on_teams_file_consent_accept") + return await super().on_teams_file_consent_accept( + turn_context, file_consent_card_response + ) + + async def on_teams_file_consent_decline( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ): + self.record.append("on_teams_file_consent_decline") + return await super().on_teams_file_consent_decline( + turn_context, file_consent_card_response + ) + + async def on_teams_o365_connector_card_action( + self, turn_context: TurnContext, query: O365ConnectorCardActionQuery + ): + self.record.append("on_teams_o365_connector_card_action") + return await super().on_teams_o365_connector_card_action(turn_context, query) + + async def on_teams_app_based_link_query( + self, turn_context: TurnContext, query: AppBasedLinkQuery + ): + self.record.append("on_teams_app_based_link_query") + return await super().on_teams_app_based_link_query(turn_context, query) + + async def on_teams_messaging_extension_query( + self, turn_context: TurnContext, query: MessagingExtensionQuery + ): + self.record.append("on_teams_messaging_extension_query") + return await super().on_teams_messaging_extension_query(turn_context, query) + + async def on_teams_messaging_extension_submit_action_dispatch( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_submit_action_dispatch") + return await super().on_teams_messaging_extension_submit_action_dispatch( + turn_context, action + ) + + async def on_teams_messaging_extension_submit_action( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_submit_action") + return await super().on_teams_messaging_extension_submit_action( + turn_context, action + ) + + async def on_teams_messaging_extension_bot_message_preview_edit( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_bot_message_preview_edit") + return await super().on_teams_messaging_extension_bot_message_preview_edit( + turn_context, action + ) + + async def on_teams_messaging_extension_bot_message_preview_send( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_bot_message_preview_send") + return await super().on_teams_messaging_extension_bot_message_preview_send( + turn_context, action + ) + + async def on_teams_messaging_extension_fetch_task( + self, turn_context: TurnContext, action: MessagingExtensionAction + ): + self.record.append("on_teams_messaging_extension_fetch_task") + return await super().on_teams_messaging_extension_fetch_task( + turn_context, action + ) + + async def on_teams_messaging_extension_configuration_query_settings_url( + self, turn_context: TurnContext, query: MessagingExtensionQuery + ): + self.record.append( + "on_teams_messaging_extension_configuration_query_settings_url" + ) + return await super().on_teams_messaging_extension_configuration_query_settings_url( + turn_context, query + ) + + async def on_teams_messaging_extension_configuration_setting( + self, turn_context: TurnContext, settings + ): + self.record.append("on_teams_messaging_extension_configuration_setting") + return await super().on_teams_messaging_extension_configuration_setting( + turn_context, settings + ) + + async def on_teams_messaging_extension_card_button_clicked( + self, turn_context: TurnContext, card_data + ): + self.record.append("on_teams_messaging_extension_card_button_clicked") + return await super().on_teams_messaging_extension_card_button_clicked( + turn_context, card_data + ) + + async def on_teams_task_module_fetch( + self, turn_context: TurnContext, task_module_request + ): + self.record.append("on_teams_task_module_fetch") + return await super().on_teams_task_module_fetch( + turn_context, task_module_request + ) + + async def on_teams_task_module_submit( # pylint: disable=unused-argument + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ): + self.record.append("on_teams_task_module_submit") + return await super().on_teams_task_module_submit( + turn_context, task_module_request + ) + + +class NotImplementedAdapter(BotAdapter): + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + raise NotImplementedError() + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + raise NotImplementedError() + + async def update_activity(self, context: TurnContext, activity: Activity): + raise NotImplementedError() + + +class TestTeamsActivityHandler(aiounittest.AsyncTestCase): + async def test_on_teams_channel_created_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelCreated", + "channel": {"id": "asdfqwerty", "name": "new_channel"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_created" + + async def test_on_teams_channel_renamed_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelRenamed", + "channel": {"id": "asdfqwerty", "name": "new_channel"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_renamed" + + async def test_on_teams_channel_deleted_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelDeleted", + "channel": {"id": "asdfqwerty", "name": "new_channel"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_deleted" + + async def test_on_teams_team_renamed_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamRenamed", + "team": {"id": "team_id_1", "name": "new_team_name"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_renamed_activity" + + async def test_on_teams_members_removed_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={"eventType": "teamMemberRemoved"}, + members_removed=[ + ChannelAccount( + id="123", + name="test_user", + aad_object_id="asdfqwerty", + role="tester", + ) + ], + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_removed" + + async def test_on_signin_verify_state(self): + # arrange + activity = Activity(type=ActivityTypes.invoke, name="signin/verifyState") + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_signin_verify_state" + + async def test_on_file_consent_accept_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_file_consent" + assert bot.record[2] == "on_teams_file_consent_accept" + + async def test_on_file_consent_decline_activity(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_file_consent" + assert bot.record[2] == "on_teams_file_consent_decline" + + async def test_on_file_consent_bad_action_activity(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "bad_action"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_file_consent" + + async def test_on_teams_o365_connector_card_action(self): + # arrange + activity = Activity( + type=ActivityTypes.invoke, + name="actionableMessage/executeAction", + value={"body": "body_here", "actionId": "action_id_here"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_o365_connector_card_action" + + async def test_on_app_based_link_query(self): + # arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/query", + value={"url": "http://www.test.com"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_query" + + async def test_on_teams_messaging_extension_bot_message_preview_edit_activity(self): + # Arrange + + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "commandId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "edit", + "botActivityPreview": [{"id": "activity123"}], + "messagePayload": {"id": "payloadid"}, + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_edit" + + async def test_on_teams_messaging_extension_bot_message_send_activity(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "commandId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "send", + "botActivityPreview": [{"id": "123"}], + "messagePayload": {"id": "abc"}, + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_bot_message_preview_send" + + async def test_on_teams_messaging_extension_bot_message_send_activity_with_none( + self, + ): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "commandId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": None, + "botActivityPreview": [{"id": "test123"}], + "messagePayload": {"id": "payloadid123"}, + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_submit_action" + + async def test_on_teams_messaging_extension_bot_message_send_activity_with_empty_string( + self, + ): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "commandId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "", + "botActivityPreview": [Activity().serialize()], + "messagePayload": MessageActionsPayload().serialize(), + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_submit_action_dispatch" + assert bot.record[2] == "on_teams_messaging_extension_submit_action" + + async def test_on_teams_messaging_extension_fetch_task(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={ + "data": {"key": "value"}, + "context": {"theme": "dark"}, + "commandId": "test_command", + "commandContext": "command_context_test", + "botMessagePreviewAction": "message_action", + "botActivityPreview": [{"id": "123"}], + "messagePayload": {"id": "abc123"}, + }, + ) + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_fetch_task" + + async def test_on_teams_messaging_extension_configuration_query_settings_url(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/querySettingUrl", + value={ + "commandId": "test_command", + "parameters": [], + "messagingExtensionQueryOptions": {"skip": 1, "count": 1}, + "state": "state_string", + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert ( + bot.record[1] + == "on_teams_messaging_extension_configuration_query_settings_url" + ) + + async def test_on_teams_messaging_extension_configuration_setting(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/setting", + value={"key": "value"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_configuration_setting" + + async def test_on_teams_messaging_extension_card_button_clicked(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="composeExtension/onCardButtonClicked", + value={"key": "value"}, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_messaging_extension_card_button_clicked" + + async def test_on_teams_task_module_fetch(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="task/fetch", + value={ + "data": {"key": "value"}, + "context": TaskModuleRequestContext().serialize(), + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_task_module_fetch" + + async def test_on_teams_task_module_submit(self): + # Arrange + activity = Activity( + type=ActivityTypes.invoke, + name="task/submit", + value={ + "data": {"key": "value"}, + "context": TaskModuleRequestContext().serialize(), + }, + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_invoke_activity" + assert bot.record[1] == "on_teams_task_module_submit" diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 90a49019b..5e6916dd0 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -1,101 +1,103 @@ -from typing import List - -import aiounittest -from botbuilder.core import ActivityHandler, BotAdapter, TurnContext -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationReference, - MessageReaction, - ResourceResponse, -) - - -class TestingActivityHandler(ActivityHandler): - def __init__(self): - self.record: List[str] = [] - - async def on_message_activity(self, turn_context: TurnContext): - self.record.append("on_message_activity") - return await super().on_message_activity(turn_context) - - async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_added_activity") - return await super().on_members_added_activity(members_added, turn_context) - - async def on_members_removed_activity( - self, members_removed: ChannelAccount, turn_context: TurnContext - ): - self.record.append("on_members_removed_activity") - return await super().on_members_removed_activity(members_removed, turn_context) - - async def on_message_reaction_activity(self, turn_context: TurnContext): - self.record.append("on_message_reaction_activity") - return await super().on_message_reaction_activity(turn_context) - - async def on_reactions_added( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_added") - return await super().on_reactions_added(message_reactions, turn_context) - - async def on_reactions_removed( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - self.record.append("on_reactions_removed") - return await super().on_reactions_removed(message_reactions, turn_context) - - async def on_token_response_event(self, turn_context: TurnContext): - self.record.append("on_token_response_event") - return await super().on_token_response_event(turn_context) - - async def on_event(self, turn_context: TurnContext): - self.record.append("on_event") - return await super().on_event(turn_context) - - async def on_unrecognized_activity_type(self, turn_context: TurnContext): - self.record.append("on_unrecognized_activity_type") - return await super().on_unrecognized_activity_type(turn_context) - - -class NotImplementedAdapter(BotAdapter): - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - raise NotImplementedError() - - async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: - raise NotImplementedError() - - async def update_activity(self, context: TurnContext, activity: Activity): - raise NotImplementedError() - - -class TestActivityHandler(aiounittest.AsyncTestCase): - async def test_message_reaction(self): - # Note the code supports multiple adds and removes in the same activity though - # a channel may decide to send separate activities for each. For example, Teams - # sends separate activities each with a single add and a single remove. - - # Arrange - activity = Activity( - type=ActivityTypes.message_reaction, - reactions_added=[MessageReaction(type="sad")], - reactions_removed=[MessageReaction(type="angry")], - ) - turn_context = TurnContext(NotImplementedAdapter(), activity) - - # Act - bot = TestingActivityHandler() - await bot.on_turn(turn_context) - - # Assert - assert len(bot.record) == 3 - assert bot.record[0] == "on_message_reaction_activity" - assert bot.record[1] == "on_reactions_added" - assert bot.record[2] == "on_reactions_removed" +from typing import List + +import aiounittest +from botbuilder.core import ActivityHandler, BotAdapter, TurnContext +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationReference, + MessageReaction, + ResourceResponse, +) + + +class TestingActivityHandler(ActivityHandler): + __test__ = False + + def __init__(self): + self.record: List[str] = [] + + async def on_message_activity(self, turn_context: TurnContext): + self.record.append("on_message_activity") + return await super().on_message_activity(turn_context) + + async def on_members_added_activity( + self, members_added: ChannelAccount, turn_context: TurnContext + ): + self.record.append("on_members_added_activity") + return await super().on_members_added_activity(members_added, turn_context) + + async def on_members_removed_activity( + self, members_removed: ChannelAccount, turn_context: TurnContext + ): + self.record.append("on_members_removed_activity") + return await super().on_members_removed_activity(members_removed, turn_context) + + async def on_message_reaction_activity(self, turn_context: TurnContext): + self.record.append("on_message_reaction_activity") + return await super().on_message_reaction_activity(turn_context) + + async def on_reactions_added( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + self.record.append("on_reactions_added") + return await super().on_reactions_added(message_reactions, turn_context) + + async def on_reactions_removed( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + self.record.append("on_reactions_removed") + return await super().on_reactions_removed(message_reactions, turn_context) + + async def on_token_response_event(self, turn_context: TurnContext): + self.record.append("on_token_response_event") + return await super().on_token_response_event(turn_context) + + async def on_event(self, turn_context: TurnContext): + self.record.append("on_event") + return await super().on_event(turn_context) + + async def on_unrecognized_activity_type(self, turn_context: TurnContext): + self.record.append("on_unrecognized_activity_type") + return await super().on_unrecognized_activity_type(turn_context) + + +class NotImplementedAdapter(BotAdapter): + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + raise NotImplementedError() + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + raise NotImplementedError() + + async def update_activity(self, context: TurnContext, activity: Activity): + raise NotImplementedError() + + +class TestActivityHandler(aiounittest.AsyncTestCase): + async def test_message_reaction(self): + # Note the code supports multiple adds and removes in the same activity though + # a channel may decide to send separate activities for each. For example, Teams + # sends separate activities each with a single add and a single remove. + + # Arrange + activity = Activity( + type=ActivityTypes.message_reaction, + reactions_added=[MessageReaction(type="sad")], + reactions_removed=[MessageReaction(type="angry")], + ) + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 3 + assert bot.record[0] == "on_message_reaction_activity" + assert bot.record[1] == "on_reactions_added" + assert bot.record[2] == "on_reactions_removed" diff --git a/libraries/botbuilder-core/tests/test_bot_adapter.py b/libraries/botbuilder-core/tests/test_bot_adapter.py index 9edd36c50..5f524dca2 100644 --- a/libraries/botbuilder-core/tests/test_bot_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_adapter.py @@ -1,86 +1,86 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import uuid -from typing import List -import aiounittest - -from botbuilder.core import TurnContext -from botbuilder.core.adapters import TestAdapter -from botbuilder.schema import ( - Activity, - ConversationAccount, - ConversationReference, - ChannelAccount, -) - -from simple_adapter import SimpleAdapter -from call_counting_middleware import CallCountingMiddleware -from test_message import TestMessage - - -class TestBotAdapter(aiounittest.AsyncTestCase): - def test_adapter_single_use(self): - adapter = SimpleAdapter() - adapter.use(CallCountingMiddleware()) - - def test_adapter_use_chaining(self): - adapter = SimpleAdapter() - adapter.use(CallCountingMiddleware()).use(CallCountingMiddleware()) - - async def test_pass_resource_responses_through(self): - def validate_responses( # pylint: disable=unused-argument - activities: List[Activity], - ): - pass # no need to do anything. - - adapter = SimpleAdapter(call_on_send=validate_responses) - context = TurnContext(adapter, Activity()) - - activity_id = str(uuid.uuid1()) - activity = TestMessage.message(activity_id) - - resource_response = await context.send_activity(activity) - self.assertTrue( - resource_response.id != activity_id, "Incorrect response Id returned" - ) - - async def test_continue_conversation_direct_msg(self): - callback_invoked = False - adapter = TestAdapter() - reference = ConversationReference( - activity_id="activityId", - bot=ChannelAccount(id="channelId", name="testChannelAccount", role="bot"), - channel_id="testChannel", - service_url="testUrl", - conversation=ConversationAccount( - conversation_type="", - id="testConversationId", - is_group=False, - name="testConversationName", - role="user", - ), - user=ChannelAccount(id="channelId", name="testChannelAccount", role="bot"), - ) - - async def continue_callback(turn_context): # pylint: disable=unused-argument - nonlocal callback_invoked - callback_invoked = True - - await adapter.continue_conversation(reference, continue_callback, "MyBot") - self.assertTrue(callback_invoked) - - async def test_turn_error(self): - async def on_error(turn_context: TurnContext, err: Exception): - nonlocal self - self.assertIsNotNone(turn_context, "turn_context not found.") - self.assertIsNotNone(err, "error not found.") - self.assertEqual(err.__class__, Exception, "unexpected error thrown.") - - adapter = SimpleAdapter() - adapter.on_turn_error = on_error - - def handler(context: TurnContext): # pylint: disable=unused-argument - raise Exception - - await adapter.process_request(TestMessage.message(), handler) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import uuid +from typing import List +import aiounittest + +from botbuilder.core import TurnContext +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import ( + Activity, + ConversationAccount, + ConversationReference, + ChannelAccount, +) + +from simple_adapter import SimpleAdapter +from call_counting_middleware import CallCountingMiddleware +from test_message import TestMessage + + +class TestBotAdapter(aiounittest.AsyncTestCase): + def test_adapter_single_use(self): + adapter = SimpleAdapter() + adapter.use(CallCountingMiddleware()) + + def test_adapter_use_chaining(self): + adapter = SimpleAdapter() + adapter.use(CallCountingMiddleware()).use(CallCountingMiddleware()) + + async def test_pass_resource_responses_through(self): + def validate_responses( # pylint: disable=unused-argument + activities: List[Activity], + ): + pass # no need to do anything. + + adapter = SimpleAdapter(call_on_send=validate_responses) + context = TurnContext(adapter, Activity()) + + activity_id = str(uuid.uuid1()) + activity = TestMessage.message(activity_id) + + resource_response = await context.send_activity(activity) + self.assertTrue( + resource_response.id != activity_id, "Incorrect response Id returned" + ) + + async def test_continue_conversation_direct_msg(self): + callback_invoked = False + adapter = TestAdapter() + reference = ConversationReference( + activity_id="activityId", + bot=ChannelAccount(id="channelId", name="testChannelAccount", role="bot"), + channel_id="testChannel", + service_url="testUrl", + conversation=ConversationAccount( + conversation_type="", + id="testConversationId", + is_group=False, + name="testConversationName", + role="user", + ), + user=ChannelAccount(id="channelId", name="testChannelAccount", role="bot"), + ) + + async def continue_callback(turn_context): # pylint: disable=unused-argument + nonlocal callback_invoked + callback_invoked = True + + await adapter.continue_conversation(reference, continue_callback, "MyBot") + self.assertTrue(callback_invoked) + + async def test_turn_error(self): + async def on_error(turn_context: TurnContext, err: Exception): + nonlocal self + self.assertIsNotNone(turn_context, "turn_context not found.") + self.assertIsNotNone(err, "error not found.") + self.assertEqual(err.__class__, Exception, "unexpected error thrown.") + + adapter = SimpleAdapter() + adapter.on_turn_error = on_error + + def handler(context: TurnContext): # pylint: disable=unused-argument + raise Exception + + await adapter.process_request(TestMessage.message(), handler) diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index 13dec6d53..2c0eb815e 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -1,483 +1,485 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from unittest.mock import MagicMock -import aiounittest - -from botbuilder.core import ( - BotState, - ConversationState, - MemoryStorage, - Storage, - StoreItem, - TurnContext, - UserState, -) -from botbuilder.core.adapters import TestAdapter -from botbuilder.schema import Activity, ConversationAccount - -from test_utilities import TestUtilities - -RECEIVED_MESSAGE = Activity(type="message", text="received") -STORAGE_KEY = "stateKey" - - -def cached_state(context, state_key): - cached = context.services.get(state_key) - return cached["state"] if cached is not None else None - - -def key_factory(context): - assert context is not None - return STORAGE_KEY - - -class BotStateForTest(BotState): - def __init__(self, storage: Storage): - super().__init__(storage, f"BotState:BotState") - - def get_storage_key(self, turn_context: TurnContext) -> str: - return f"botstate/{turn_context.activity.channel_id}/{turn_context.activity.conversation.id}/BotState" - - -class CustomState(StoreItem): - def __init__(self, custom_string: str = None, e_tag: str = "*"): - super().__init__(custom_string=custom_string, e_tag=e_tag) - - -class TestPocoState: - def __init__(self, value=None): - self.value = value - - -class TestBotState(aiounittest.AsyncTestCase): - storage = MemoryStorage() - adapter = TestAdapter() - context = TurnContext(adapter, RECEIVED_MESSAGE) - middleware = BotState(storage, key_factory) - - def test_state_empty_name(self): - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - - # Act - with self.assertRaises(TypeError) as _: - user_state.create_property("") - - def test_state_none_name(self): - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - - # Act - with self.assertRaises(TypeError) as _: - user_state.create_property(None) - - async def test_storage_not_called_no_changes(self): - """Verify storage not called when no changes are made""" - # Mock a storage provider, which counts read/writes - dictionary = {} - - async def mock_write_result(self): # pylint: disable=unused-argument - return - - async def mock_read_result(self): # pylint: disable=unused-argument - return {} - - mock_storage = MemoryStorage(dictionary) - mock_storage.write = MagicMock(side_effect=mock_write_result) - mock_storage.read = MagicMock(side_effect=mock_read_result) - - # Arrange - user_state = UserState(mock_storage) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property_a") - self.assertEqual(mock_storage.write.call_count, 0) - await user_state.save_changes(context) - await property_a.set(context, "hello") - self.assertEqual(mock_storage.read.call_count, 1) # Initial save bumps count - self.assertEqual(mock_storage.write.call_count, 0) # Initial save bumps count - await property_a.set(context, "there") - self.assertEqual( - mock_storage.write.call_count, 0 - ) # Set on property should not bump - await user_state.save_changes(context) - self.assertEqual(mock_storage.write.call_count, 1) # Explicit save should bump - value_a = await property_a.get(context) - self.assertEqual("there", value_a) - self.assertEqual(mock_storage.write.call_count, 1) # Gets should not bump - await user_state.save_changes(context) - self.assertEqual(mock_storage.write.call_count, 1) - await property_a.delete(context) # Delete alone no bump - self.assertEqual(mock_storage.write.call_count, 1) - await user_state.save_changes(context) # Save when dirty should bump - self.assertEqual(mock_storage.write.call_count, 2) - self.assertEqual(mock_storage.read.call_count, 1) - await user_state.save_changes(context) # Save not dirty should not bump - self.assertEqual(mock_storage.write.call_count, 2) - self.assertEqual(mock_storage.read.call_count, 1) - - async def test_state_set_no_load(self): - """Should be able to set a property with no Load""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property_a") - await property_a.set(context, "hello") - - async def test_state_multiple_loads(self): - """Should be able to load multiple times""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - user_state.create_property("property_a") - await user_state.load(context) - await user_state.load(context) - - async def test_state_get_no_load_with_default(self): - """Should be able to get a property with no Load and default""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property_a") - value_a = await property_a.get(context, lambda: "Default!") - self.assertEqual("Default!", value_a) - - async def test_state_get_no_load_no_default(self): - """Cannot get a string with no default set""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property_a") - value_a = await property_a.get(context) - - # Assert - self.assertIsNone(value_a) - - async def test_state_poco_no_default(self): - """Cannot get a POCO with no default set""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - test_property = user_state.create_property("test") - value = await test_property.get(context) - - # Assert - self.assertIsNone(value) - - async def test_state_bool_no_default(self): - """Cannot get a bool with no default set""" - # Arange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - test_property = user_state.create_property("test") - value = await test_property.get(context) - - # Assert - self.assertFalse(value) - - async def test_state_set_after_save(self): - """Verify setting property after save""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property-a") - property_b = user_state.create_property("property-b") - - await user_state.load(context) - await property_a.set(context, "hello") - await property_b.set(context, "world") - await user_state.save_changes(context) - - await property_a.set(context, "hello2") - - async def test_state_multiple_save(self): - """Verify multiple saves""" - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property-a") - property_b = user_state.create_property("property-b") - - await user_state.load(context) - await property_a.set(context, "hello") - await property_b.set(context, "world") - await user_state.save_changes(context) - - await property_a.set(context, "hello2") - await user_state.save_changes(context) - value_a = await property_a.get(context) - self.assertEqual("hello2", value_a) - - async def test_load_set_save(self): - # Arrange - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - - # Act - property_a = user_state.create_property("property-a") - property_b = user_state.create_property("property-b") - - await user_state.load(context) - await property_a.set(context, "hello") - await property_b.set(context, "world") - await user_state.save_changes(context) - - # Assert - obj = dictionary["EmptyContext/users/empty@empty.context.org"] - self.assertEqual("hello", obj["property-a"]) - self.assertEqual("world", obj["property-b"]) - - async def test_load_set_save_twice(self): - # Arrange - dictionary = {} - context = TestUtilities.create_empty_context() - - # Act - user_state = UserState(MemoryStorage(dictionary)) - - property_a = user_state.create_property("property-a") - property_b = user_state.create_property("property-b") - property_c = user_state.create_property("property-c") - - await user_state.load(context) - await property_a.set(context, "hello") - await property_b.set(context, "world") - await property_c.set(context, "test") - await user_state.save_changes(context) - - # Assert - obj = dictionary["EmptyContext/users/empty@empty.context.org"] - self.assertEqual("hello", obj["property-a"]) - self.assertEqual("world", obj["property-b"]) - - # Act 2 - user_state2 = UserState(MemoryStorage(dictionary)) - - property_a2 = user_state2.create_property("property-a") - property_b2 = user_state2.create_property("property-b") - - await user_state2.load(context) - await property_a2.set(context, "hello-2") - await property_b2.set(context, "world-2") - await user_state2.save_changes(context) - - # Assert 2 - obj2 = dictionary["EmptyContext/users/empty@empty.context.org"] - self.assertEqual("hello-2", obj2["property-a"]) - self.assertEqual("world-2", obj2["property-b"]) - self.assertEqual("test", obj2["property-c"]) - - async def test_load_save_delete(self): - # Arrange - dictionary = {} - context = TestUtilities.create_empty_context() - - # Act - user_state = UserState(MemoryStorage(dictionary)) - - property_a = user_state.create_property("property-a") - property_b = user_state.create_property("property-b") - - await user_state.load(context) - await property_a.set(context, "hello") - await property_b.set(context, "world") - await user_state.save_changes(context) - - # Assert - obj = dictionary["EmptyContext/users/empty@empty.context.org"] - self.assertEqual("hello", obj["property-a"]) - self.assertEqual("world", obj["property-b"]) - - # Act 2 - user_state2 = UserState(MemoryStorage(dictionary)) - - property_a2 = user_state2.create_property("property-a") - property_b2 = user_state2.create_property("property-b") - - await user_state2.load(context) - await property_a2.set(context, "hello-2") - await property_b2.delete(context) - await user_state2.save_changes(context) - - # Assert 2 - obj2 = dictionary["EmptyContext/users/empty@empty.context.org"] - self.assertEqual("hello-2", obj2["property-a"]) - with self.assertRaises(KeyError) as _: - obj2["property-b"] # pylint: disable=pointless-statement - - async def test_state_use_bot_state_directly(self): - async def exec_test(context: TurnContext): - # pylint: disable=unnecessary-lambda - bot_state_manager = BotStateForTest(MemoryStorage()) - test_property = bot_state_manager.create_property("test") - - # read initial state object - await bot_state_manager.load(context) - - custom_state = await test_property.get(context, lambda: CustomState()) - - # this should be a 'CustomState' as nothing is currently stored in storage - assert isinstance(custom_state, CustomState) - - # amend property and write to storage - custom_state.custom_string = "test" - await bot_state_manager.save_changes(context) - - custom_state.custom_string = "asdfsadf" - - # read into context again - await bot_state_manager.load(context, True) - - custom_state = await test_property.get(context) - - # check object read from value has the correct value for custom_string - assert custom_state.custom_string == "test" - - adapter = TestAdapter(exec_test) - await adapter.send("start") - - async def test_user_state_bad_from_throws(self): - dictionary = {} - user_state = UserState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - context.activity.from_property = None - test_property = user_state.create_property("test") - with self.assertRaises(AttributeError): - await test_property.get(context) - - async def test_conversation_state_bad_conversation_throws(self): - dictionary = {} - user_state = ConversationState(MemoryStorage(dictionary)) - context = TestUtilities.create_empty_context() - context.activity.conversation = None - test_property = user_state.create_property("test") - with self.assertRaises(AttributeError): - await test_property.get(context) - - async def test_clear_and_save(self): - # pylint: disable=unnecessary-lambda - turn_context = TestUtilities.create_empty_context() - turn_context.activity.conversation = ConversationAccount(id="1234") - - storage = MemoryStorage({}) - - # Turn 0 - bot_state1 = ConversationState(storage) - ( - await bot_state1.create_property("test-name").get( - turn_context, lambda: TestPocoState() - ) - ).value = "test-value" - await bot_state1.save_changes(turn_context) - - # Turn 1 - bot_state2 = ConversationState(storage) - value1 = ( - await bot_state2.create_property("test-name").get( - turn_context, lambda: TestPocoState(value="default-value") - ) - ).value - - assert value1 == "test-value" - - # Turn 2 - bot_state3 = ConversationState(storage) - await bot_state3.clear_state(turn_context) - await bot_state3.save_changes(turn_context) - - # Turn 3 - bot_state4 = ConversationState(storage) - value2 = ( - await bot_state4.create_property("test-name").get( - turn_context, lambda: TestPocoState(value="default-value") - ) - ).value - - assert value2, "default-value" - - async def test_bot_state_delete(self): - # pylint: disable=unnecessary-lambda - turn_context = TestUtilities.create_empty_context() - turn_context.activity.conversation = ConversationAccount(id="1234") - - storage = MemoryStorage({}) - - # Turn 0 - bot_state1 = ConversationState(storage) - ( - await bot_state1.create_property("test-name").get( - turn_context, lambda: TestPocoState() - ) - ).value = "test-value" - await bot_state1.save_changes(turn_context) - - # Turn 1 - bot_state2 = ConversationState(storage) - value1 = ( - await bot_state2.create_property("test-name").get( - turn_context, lambda: TestPocoState(value="default-value") - ) - ).value - - assert value1 == "test-value" - - # Turn 2 - bot_state3 = ConversationState(storage) - await bot_state3.delete(turn_context) - - # Turn 3 - bot_state4 = ConversationState(storage) - value2 = ( - await bot_state4.create_property("test-name").get( - turn_context, lambda: TestPocoState(value="default-value") - ) - ).value - - assert value2 == "default-value" - - async def test_bot_state_get(self): - # pylint: disable=unnecessary-lambda - turn_context = TestUtilities.create_empty_context() - turn_context.activity.conversation = ConversationAccount(id="1234") - - storage = MemoryStorage({}) - - conversation_state = ConversationState(storage) - ( - await conversation_state.create_property("test-name").get( - turn_context, lambda: TestPocoState() - ) - ).value = "test-value" - - result = conversation_state.get(turn_context) - - assert result["test-name"].value == "test-value" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import MagicMock +import aiounittest + +from botbuilder.core import ( + BotState, + ConversationState, + MemoryStorage, + Storage, + StoreItem, + TurnContext, + UserState, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import Activity, ConversationAccount + +from test_utilities import TestUtilities + +RECEIVED_MESSAGE = Activity(type="message", text="received") +STORAGE_KEY = "stateKey" + + +def cached_state(context, state_key): + cached = context.services.get(state_key) + return cached["state"] if cached is not None else None + + +def key_factory(context): + assert context is not None + return STORAGE_KEY + + +class BotStateForTest(BotState): + def __init__(self, storage: Storage): + super().__init__(storage, f"BotState:BotState") + + def get_storage_key(self, turn_context: TurnContext) -> str: + return f"botstate/{turn_context.activity.channel_id}/{turn_context.activity.conversation.id}/BotState" + + +class CustomState(StoreItem): + def __init__(self, custom_string: str = None, e_tag: str = "*"): + super().__init__(custom_string=custom_string, e_tag=e_tag) + + +class TestPocoState: + __test__ = False + + def __init__(self, value=None): + self.value = value + + +class TestBotState(aiounittest.AsyncTestCase): + storage = MemoryStorage() + adapter = TestAdapter() + context = TurnContext(adapter, RECEIVED_MESSAGE) + middleware = BotState(storage, key_factory) + + def test_state_empty_name(self): + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + + # Act + with self.assertRaises(TypeError) as _: + user_state.create_property("") + + def test_state_none_name(self): + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + + # Act + with self.assertRaises(TypeError) as _: + user_state.create_property(None) + + async def test_storage_not_called_no_changes(self): + """Verify storage not called when no changes are made""" + # Mock a storage provider, which counts read/writes + dictionary = {} + + async def mock_write_result(self): # pylint: disable=unused-argument + return + + async def mock_read_result(self): # pylint: disable=unused-argument + return {} + + mock_storage = MemoryStorage(dictionary) + mock_storage.write = MagicMock(side_effect=mock_write_result) + mock_storage.read = MagicMock(side_effect=mock_read_result) + + # Arrange + user_state = UserState(mock_storage) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + self.assertEqual(mock_storage.write.call_count, 0) + await user_state.save_changes(context) + await property_a.set(context, "hello") + self.assertEqual(mock_storage.read.call_count, 1) # Initial save bumps count + self.assertEqual(mock_storage.write.call_count, 0) # Initial save bumps count + await property_a.set(context, "there") + self.assertEqual( + mock_storage.write.call_count, 0 + ) # Set on property should not bump + await user_state.save_changes(context) + self.assertEqual(mock_storage.write.call_count, 1) # Explicit save should bump + value_a = await property_a.get(context) + self.assertEqual("there", value_a) + self.assertEqual(mock_storage.write.call_count, 1) # Gets should not bump + await user_state.save_changes(context) + self.assertEqual(mock_storage.write.call_count, 1) + await property_a.delete(context) # Delete alone no bump + self.assertEqual(mock_storage.write.call_count, 1) + await user_state.save_changes(context) # Save when dirty should bump + self.assertEqual(mock_storage.write.call_count, 2) + self.assertEqual(mock_storage.read.call_count, 1) + await user_state.save_changes(context) # Save not dirty should not bump + self.assertEqual(mock_storage.write.call_count, 2) + self.assertEqual(mock_storage.read.call_count, 1) + + async def test_state_set_no_load(self): + """Should be able to set a property with no Load""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + await property_a.set(context, "hello") + + async def test_state_multiple_loads(self): + """Should be able to load multiple times""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + user_state.create_property("property_a") + await user_state.load(context) + await user_state.load(context) + + async def test_state_get_no_load_with_default(self): + """Should be able to get a property with no Load and default""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + value_a = await property_a.get(context, lambda: "Default!") + self.assertEqual("Default!", value_a) + + async def test_state_get_no_load_no_default(self): + """Cannot get a string with no default set""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + value_a = await property_a.get(context) + + # Assert + self.assertIsNone(value_a) + + async def test_state_poco_no_default(self): + """Cannot get a POCO with no default set""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + test_property = user_state.create_property("test") + value = await test_property.get(context) + + # Assert + self.assertIsNone(value) + + async def test_state_bool_no_default(self): + """Cannot get a bool with no default set""" + # Arange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + test_property = user_state.create_property("test") + value = await test_property.get(context) + + # Assert + self.assertFalse(value) + + async def test_state_set_after_save(self): + """Verify setting property after save""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + await property_a.set(context, "hello2") + + async def test_state_multiple_save(self): + """Verify multiple saves""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + await property_a.set(context, "hello2") + await user_state.save_changes(context) + value_a = await property_a.get(context) + self.assertEqual("hello2", value_a) + + async def test_load_set_save(self): + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + # Assert + obj = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello", obj["property-a"]) + self.assertEqual("world", obj["property-b"]) + + async def test_load_set_save_twice(self): + # Arrange + dictionary = {} + context = TestUtilities.create_empty_context() + + # Act + user_state = UserState(MemoryStorage(dictionary)) + + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + property_c = user_state.create_property("property-c") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await property_c.set(context, "test") + await user_state.save_changes(context) + + # Assert + obj = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello", obj["property-a"]) + self.assertEqual("world", obj["property-b"]) + + # Act 2 + user_state2 = UserState(MemoryStorage(dictionary)) + + property_a2 = user_state2.create_property("property-a") + property_b2 = user_state2.create_property("property-b") + + await user_state2.load(context) + await property_a2.set(context, "hello-2") + await property_b2.set(context, "world-2") + await user_state2.save_changes(context) + + # Assert 2 + obj2 = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello-2", obj2["property-a"]) + self.assertEqual("world-2", obj2["property-b"]) + self.assertEqual("test", obj2["property-c"]) + + async def test_load_save_delete(self): + # Arrange + dictionary = {} + context = TestUtilities.create_empty_context() + + # Act + user_state = UserState(MemoryStorage(dictionary)) + + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + # Assert + obj = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello", obj["property-a"]) + self.assertEqual("world", obj["property-b"]) + + # Act 2 + user_state2 = UserState(MemoryStorage(dictionary)) + + property_a2 = user_state2.create_property("property-a") + property_b2 = user_state2.create_property("property-b") + + await user_state2.load(context) + await property_a2.set(context, "hello-2") + await property_b2.delete(context) + await user_state2.save_changes(context) + + # Assert 2 + obj2 = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello-2", obj2["property-a"]) + with self.assertRaises(KeyError) as _: + obj2["property-b"] # pylint: disable=pointless-statement + + async def test_state_use_bot_state_directly(self): + async def exec_test(context: TurnContext): + # pylint: disable=unnecessary-lambda + bot_state_manager = BotStateForTest(MemoryStorage()) + test_property = bot_state_manager.create_property("test") + + # read initial state object + await bot_state_manager.load(context) + + custom_state = await test_property.get(context, lambda: CustomState()) + + # this should be a 'CustomState' as nothing is currently stored in storage + assert isinstance(custom_state, CustomState) + + # amend property and write to storage + custom_state.custom_string = "test" + await bot_state_manager.save_changes(context) + + custom_state.custom_string = "asdfsadf" + + # read into context again + await bot_state_manager.load(context, True) + + custom_state = await test_property.get(context) + + # check object read from value has the correct value for custom_string + assert custom_state.custom_string == "test" + + adapter = TestAdapter(exec_test) + await adapter.send("start") + + async def test_user_state_bad_from_throws(self): + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + context.activity.from_property = None + test_property = user_state.create_property("test") + with self.assertRaises(AttributeError): + await test_property.get(context) + + async def test_conversation_state_bad_conversation_throws(self): + dictionary = {} + user_state = ConversationState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + context.activity.conversation = None + test_property = user_state.create_property("test") + with self.assertRaises(AttributeError): + await test_property.get(context) + + async def test_clear_and_save(self): + # pylint: disable=unnecessary-lambda + turn_context = TestUtilities.create_empty_context() + turn_context.activity.conversation = ConversationAccount(id="1234") + + storage = MemoryStorage({}) + + # Turn 0 + bot_state1 = ConversationState(storage) + ( + await bot_state1.create_property("test-name").get( + turn_context, lambda: TestPocoState() + ) + ).value = "test-value" + await bot_state1.save_changes(turn_context) + + # Turn 1 + bot_state2 = ConversationState(storage) + value1 = ( + await bot_state2.create_property("test-name").get( + turn_context, lambda: TestPocoState(value="default-value") + ) + ).value + + assert value1 == "test-value" + + # Turn 2 + bot_state3 = ConversationState(storage) + await bot_state3.clear_state(turn_context) + await bot_state3.save_changes(turn_context) + + # Turn 3 + bot_state4 = ConversationState(storage) + value2 = ( + await bot_state4.create_property("test-name").get( + turn_context, lambda: TestPocoState(value="default-value") + ) + ).value + + assert value2, "default-value" + + async def test_bot_state_delete(self): + # pylint: disable=unnecessary-lambda + turn_context = TestUtilities.create_empty_context() + turn_context.activity.conversation = ConversationAccount(id="1234") + + storage = MemoryStorage({}) + + # Turn 0 + bot_state1 = ConversationState(storage) + ( + await bot_state1.create_property("test-name").get( + turn_context, lambda: TestPocoState() + ) + ).value = "test-value" + await bot_state1.save_changes(turn_context) + + # Turn 1 + bot_state2 = ConversationState(storage) + value1 = ( + await bot_state2.create_property("test-name").get( + turn_context, lambda: TestPocoState(value="default-value") + ) + ).value + + assert value1 == "test-value" + + # Turn 2 + bot_state3 = ConversationState(storage) + await bot_state3.delete(turn_context) + + # Turn 3 + bot_state4 = ConversationState(storage) + value2 = ( + await bot_state4.create_property("test-name").get( + turn_context, lambda: TestPocoState(value="default-value") + ) + ).value + + assert value2 == "default-value" + + async def test_bot_state_get(self): + # pylint: disable=unnecessary-lambda + turn_context = TestUtilities.create_empty_context() + turn_context.activity.conversation = ConversationAccount(id="1234") + + storage = MemoryStorage({}) + + conversation_state = ConversationState(storage) + ( + await conversation_state.create_property("test-name").get( + turn_context, lambda: TestPocoState() + ) + ).value = "test-value" + + result = conversation_state.get(turn_context) + + assert result["test-name"].value == "test-value" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 678f341f8..bced214fb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -59,7 +59,7 @@ async def begin_dialog( properties = {} properties["DialogId"] = self.id properties["InstanceId"] = instance_id - self.telemetry_client.track_event("WaterfallStart", properties=properties) + self.telemetry_client.track_event("WaterfallStart", properties) # Run first stepkinds return await self.run_step(dialog_context, 0, DialogReason.BeginCalled, None) From 2abe23e0ff18b3c6490d0889db3909ab1ef0f97d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 14 Feb 2020 15:31:20 -0600 Subject: [PATCH 292/616] pylint and black corrections --- .../tests/test_telemetry_waterfall.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index 16261065f..5312fe506 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock from typing import Dict import aiounittest from botbuilder.core.adapters import TestAdapter, TestFlow @@ -150,7 +150,9 @@ async def exec_test(turn_context: TurnContext) -> None: def assert_telemetry_call( self, telemetry_mock, index: int, event_name: str, props: Dict[str, str] ) -> None: - args, kwargs = telemetry_mock.track_event.call_args_list[index] + args, kwargs = telemetry_mock.track_event.call_args_list[ + index + ] # pylint: disable=unused-variable self.assertEqual(args[0], event_name) for key, val in props.items(): From 1f1622d47ee70056da7b7200551501c83cf0d038 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 14 Feb 2020 15:37:47 -0600 Subject: [PATCH 293/616] Fixed pylint issue again (thanks black) --- .../tests/test_telemetry_waterfall.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index 5312fe506..10d2a0ebd 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -150,9 +150,9 @@ async def exec_test(turn_context: TurnContext) -> None: def assert_telemetry_call( self, telemetry_mock, index: int, event_name: str, props: Dict[str, str] ) -> None: - args, kwargs = telemetry_mock.track_event.call_args_list[ + args, kwargs = telemetry_mock.track_event.call_args_list[ # pylint: disable=unused-variable index - ] # pylint: disable=unused-variable + ] self.assertEqual(args[0], event_name) for key, val in props.items(): From bdce9489895894bdedfc93cd727c96a44fea9a18 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 14 Feb 2020 15:51:30 -0600 Subject: [PATCH 294/616] pylint/black #fail --- .../tests/test_telemetry_waterfall.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index 10d2a0ebd..c1ab6e261 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -150,9 +150,8 @@ async def exec_test(turn_context: TurnContext) -> None: def assert_telemetry_call( self, telemetry_mock, index: int, event_name: str, props: Dict[str, str] ) -> None: - args, kwargs = telemetry_mock.track_event.call_args_list[ # pylint: disable=unused-variable - index - ] + # pylint: disable=unused-variable + args, kwargs = telemetry_mock.track_event.call_args_list[index] self.assertEqual(args[0], event_name) for key, val in props.items(): From 5000dc8d0a2c79b51c8a4eb731d20a1558996da3 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 18 Feb 2020 09:59:28 -0600 Subject: [PATCH 295/616] Fixes #743: 3.6 tests failing due to remove_mention_text --- .../botbuilder/core/re_escape.py | 25 +++++++++++++++++++ .../botbuilder/core/turn_context.py | 3 ++- .../tests/test_turn_context.py | 8 +++--- 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/re_escape.py diff --git a/libraries/botbuilder-core/botbuilder/core/re_escape.py b/libraries/botbuilder-core/botbuilder/core/re_escape.py new file mode 100644 index 000000000..b50472bb6 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/re_escape.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# SPECIAL_CHARS +# closing ')', '}' and ']' +# '-' (a range in character set) +# '&', '~', (extended character set operations) +# '#' (comment) and WHITESPACE (ignored) in verbose mode +SPECIAL_CHARS_MAP = {i: "\\" + chr(i) for i in b"()[]{}?*+-|^$\\.&~# \t\n\r\v\f"} + + +def escape(pattern): + """ + Escape special characters in a string. + + This is a copy of the re.escape function in Python 3.8. This was done + because the 3.6.x version didn't escape in the same way and handling + bot names with regex characters in it would fail in TurnContext.remove_mention_text + without escaping the text. + """ + if isinstance(pattern, str): + return pattern.translate(SPECIAL_CHARS_MAP) + + pattern = str(pattern, "latin1") + return pattern.translate(SPECIAL_CHARS_MAP).encode("latin1") diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index f316584a9..614a43ce1 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -13,6 +13,7 @@ Mention, ResourceResponse, ) +from .re_escape import escape class TurnContext: @@ -362,7 +363,7 @@ def remove_mention_text(activity: Activity, identifier: str) -> str: if mention.additional_properties["mentioned"]["id"] == identifier: mention_name_match = re.match( r"(.*?)<\/at>", - re.escape(mention.additional_properties["text"]), + escape(mention.additional_properties["text"]), re.IGNORECASE, ) if mention_name_match: diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index be48fdc04..5f3668844 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -322,8 +322,8 @@ def test_should_remove_at_mention_from_activity(self): text = TurnContext.remove_recipient_mention(activity) - assert text, " test activity" - assert activity.text, " test activity" + assert text == " test activity" + assert activity.text == " test activity" def test_should_remove_at_mention_with_regex_characters(self): activity = Activity( @@ -343,8 +343,8 @@ def test_should_remove_at_mention_with_regex_characters(self): text = TurnContext.remove_recipient_mention(activity) - assert text, " test activity" - assert activity.text, " test activity" + assert text == " test activity" + assert activity.text == " test activity" async def test_should_send_a_trace_activity(self): context = TurnContext(SimpleAdapter(), ACTIVITY) From 7c9cb7ede751f388e41189877c1e01b16e8863f1 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 18 Feb 2020 09:17:22 -0800 Subject: [PATCH 296/616] removing comment text --- libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py index f9b6a30ac..766cd6291 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_helper.py @@ -10,8 +10,6 @@ import botbuilder.schema as schema import botbuilder.schema.teams as teams_schema -# Optimization: The dependencies dictionary could be cached here, -# and shared between the two methods. DEPENDICIES = [ schema_cls for key, schema_cls in getmembers(schema) From f3392d6081de1db77092c69dc9cc716e61204c90 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 20 Feb 2020 10:23:04 -0600 Subject: [PATCH 297/616] Fixes #525: Additional auth flow --- .../botbuilder/core/adapters/test_adapter.py | 37 +- .../botbuilder/core/bot_framework_adapter.py | 131 +++--- .../botbuilder/core/user_token_provider.py | 175 +++++--- .../tests/test_test_adapter.py | 384 +++++++++++------- .../dialogs/prompts/oauth_prompt.py | 34 +- .../dialogs/prompts/oauth_prompt_settings.py | 51 ++- 6 files changed, 533 insertions(+), 279 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 0ff9f16b6..77e8cd54c 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -20,7 +20,7 @@ ResourceResponse, TokenResponse, ) -from botframework.connector.auth import ClaimsIdentity +from botframework.connector.auth import ClaimsIdentity, AppCredentials from ..bot_adapter import BotAdapter from ..turn_context import TurnContext from ..user_token_provider import UserTokenProvider @@ -269,7 +269,11 @@ def add_user_token( self._magic_codes.append(code) async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, ) -> TokenResponse: key = UserToken() key.channel_id = context.activity.channel_id @@ -305,7 +309,11 @@ async def get_user_token( return None async def sign_out_user( - self, context: TurnContext, connection_name: str, user_id: str = None + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, ): channel_id = context.activity.channel_id user_id = context.activity.from_property.id @@ -321,15 +329,34 @@ async def sign_out_user( self._user_tokens = new_records async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, ) -> str: return ( f"https://fake.com/oauthsignin" f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" ) + async def get_token_status( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + return None + async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, ) -> Dict[str, TokenResponse]: return None diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 3f248147f..37b22baaf 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -25,9 +25,9 @@ SimpleCredentialProvider, SkillValidation, CertificateAppCredentials, + AppCredentials, ) from botframework.connector.token_api import TokenApiClient -from botframework.connector.token_api.models import TokenStatus from botbuilder.schema import ( Activity, ActivityTypes, @@ -730,7 +730,11 @@ async def get_conversations(self, service_url: str, continuation_token: str = No return await client.conversations.get_conversations(continuation_token) async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, ) -> TokenResponse: """ @@ -742,6 +746,8 @@ async def get_user_token( :type connection_name: str :param magic_code" (Optional) user entered code to validate :str magic_code" str + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` :raises: An exception error @@ -762,24 +768,27 @@ async def get_user_token( "get_user_token() requires a connection_name but none was provided." ) - self.check_emulating_oauth_cards(context) - user_id = context.activity.from_property.id - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) + client = self._create_token_api_client(context, oauth_app_credentials) result = client.user_token.get_token( - user_id, connection_name, context.activity.channel_id, magic_code + context.activity.from_property.id, + connection_name, + context.activity.channel_id, + magic_code, ) - # TODO check form of response if result is None or result.token is None: return None return result async def sign_out_user( - self, context: TurnContext, connection_name: str = None, user_id: str = None - ) -> str: + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): """ Signs the user out with the token server. @@ -789,8 +798,8 @@ async def sign_out_user( :type connection_name: str :param user_id: User id of user to sign out :type user_id: str - - :returns: A task that represents the work queued to execute + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` """ if not context.activity.from_property or not context.activity.from_property.id: raise Exception( @@ -799,15 +808,17 @@ async def sign_out_user( if not user_id: user_id = context.activity.from_property.id - self.check_emulating_oauth_cards(context) - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) + client = self._create_token_api_client(context, oauth_app_credentials) client.user_token.sign_out( user_id, connection_name, context.activity.channel_id ) async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, ) -> str: """ Gets the raw sign-in link to be sent to the user for sign-in for a connection name. @@ -816,17 +827,16 @@ async def get_oauth_sign_in_link( :type context: :class:`botbuilder.core.TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str + :param final_redirect: The final URL that the OAuth flow will redirect to. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` - :returns: A task that represents the work queued to execute + :return: If the task completes successfully, the result contains the raw sign-in link + """ - .. note:: + client = self._create_token_api_client(context, oauth_app_credentials) - If the task completes successfully, the result contains the raw sign-in link - """ - self.check_emulating_oauth_cards(context) conversation = TurnContext.get_conversation_reference(context.activity) - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) state = TokenExchangeState( connection_name=connection_name, conversation=conversation, @@ -840,19 +850,27 @@ async def get_oauth_sign_in_link( return client.bot_sign_in.get_sign_in_url(final_state) async def get_token_status( - self, context: TurnContext, user_id: str = None, include_filter: str = None - ) -> List[TokenStatus]: - + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: """ Retrieves the token status for each configured connection for the given user. :param context: Context for the current turn of conversation with the user :type context: :class:`botbuilder.core.TurnContext` - :param user_id: The user Id for which token status is retrieved + :param connection_name: Name of the auth connection to use + :type connection_name: str + :param user_id: The user Id for which tokens are retrieved. If passing in None the userId is taken :type user_id: str :param include_filter: (Optional) Comma separated list of connection's to include. Blank will return token status for all configured connections. :type include_filter: str + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` :returns: Array of :class:`botframework.connector.token_api.modelsTokenStatus` """ @@ -864,18 +882,20 @@ async def get_token_status( "BotFrameworkAdapter.get_token_status(): missing from_property or from_property.id" ) - self.check_emulating_oauth_cards(context) - user_id = user_id or context.activity.from_property.id - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) + client = self._create_token_api_client(context, oauth_app_credentials) - # TODO check form of response + user_id = user_id or context.activity.from_property.id return client.user_token.get_token_status( user_id, context.activity.channel_id, include_filter ) async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, ) -> Dict[str, TokenResponse]: """ Retrieves Azure Active Directory tokens for particular resources on a configured connection. @@ -886,6 +906,12 @@ async def get_aad_tokens( :type connection_name: str :param resource_urls: The list of resource URLs to retrieve tokens for :type resource_urls: :class:`typing.List` + :param user_id: The user Id for which tokens are retrieved. If passing in null the userId is taken + from the Activity in the TurnContext. + :type user_id: str + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` + :returns: Dictionary of resource Urls to the corresponding :class:'botbuilder.schema.TokenResponse` :rtype: :class:`typing.Dict` """ @@ -894,14 +920,12 @@ async def get_aad_tokens( "BotFrameworkAdapter.get_aad_tokens(): missing from_property or from_property.id" ) - self.check_emulating_oauth_cards(context) - user_id = context.activity.from_property.id - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) - - # TODO check form of response + client = self._create_token_api_client(context, oauth_app_credentials) return client.user_token.get_aad_tokens( - user_id, connection_name, context.activity.channel_id, resource_urls + context.activity.from_property.id, + connection_name, + context.activity.channel_id, + resource_urls, ) async def create_connector_client( @@ -959,20 +983,31 @@ async def create_connector_client( return client - def create_token_api_client(self, service_url: str) -> TokenApiClient: - client = TokenApiClient(self._credentials, service_url) - client.config.add_user_agent(USER_AGENT) + def _create_token_api_client( + self, + url_or_context: Union[TurnContext, str], + oauth_app_credentials: AppCredentials = None, + ) -> TokenApiClient: + if isinstance(url_or_context, str): + app_credentials = ( + oauth_app_credentials if oauth_app_credentials else self._credentials + ) + client = TokenApiClient(app_credentials, url_or_context) + client.config.add_user_agent(USER_AGENT) + return client - return client + self.__check_emulating_oauth_cards(url_or_context) + url = self.__oauth_api_url(url_or_context) + return self._create_token_api_client(url) - async def emulate_oauth_cards( + async def __emulate_oauth_cards( self, context_or_service_url: Union[TurnContext, str], emulate: bool ): self._is_emulating_oauth_cards = emulate - url = self.oauth_api_url(context_or_service_url) + url = self.__oauth_api_url(context_or_service_url) await EmulatorApiClient.emulate_oauth_cards(self._credentials, url, emulate) - def oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: + def __oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: url = None if self._is_emulating_oauth_cards: url = ( @@ -992,7 +1027,7 @@ def oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: return url - def check_emulating_oauth_cards(self, context: TurnContext): + def __check_emulating_oauth_cards(self, context: TurnContext): if ( not self._is_emulating_oauth_cards and context.activity.channel_id == "emulator" diff --git a/libraries/botbuilder-core/botbuilder/core/user_token_provider.py b/libraries/botbuilder-core/botbuilder/core/user_token_provider.py index 4316a2f88..735af1e7a 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_token_provider.py +++ b/libraries/botbuilder-core/botbuilder/core/user_token_provider.py @@ -1,62 +1,113 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC, abstractmethod -from typing import Dict, List - -from botbuilder.schema import TokenResponse - -from .turn_context import TurnContext - - -class UserTokenProvider(ABC): - @abstractmethod - async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None - ) -> TokenResponse: - """ - Retrieves the OAuth token for a user that is in a sign-in flow. - :param context: - :param connection_name: - :param magic_code: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def sign_out_user( - self, context: TurnContext, connection_name: str, user_id: str = None - ): - """ - Signs the user out with the token server. - :param context: - :param connection_name: - :param user_id: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str - ) -> str: - """ - Get the raw signin link to be sent to the user for signin for a connection name. - :param context: - :param connection_name: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] - ) -> Dict[str, TokenResponse]: - """ - Retrieves Azure Active Directory tokens for particular resources on a configured connection. - :param context: - :param connection_name: - :param resource_urls: - :return: - """ - raise NotImplementedError() +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from typing import Dict, List + +from botbuilder.schema import TokenResponse +from botframework.connector.auth import AppCredentials + +from .turn_context import TurnContext + + +class UserTokenProvider(ABC): + @abstractmethod + async def get_user_token( + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> TokenResponse: + """ + Retrieves the OAuth token for a user that is in a sign-in flow. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param magic_code: (Optional) Optional user entered code to validate. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def sign_out_user( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): + """ + Signs the user out with the token server. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: User id of user to sign out. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_oauth_sign_in_link( + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> str: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param final_redirect: The final URL that the OAuth flow will redirect to. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_token_status( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: The user Id for which token status is retrieved. + :param include_filter: Optional comma separated list of connection's to include. Blank will return token status + for all configured connections. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_aad_tokens( + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param resource_urls: The list of resource URLs to retrieve tokens for. + :param user_id: The user Id for which tokens are retrieved. If passing in None the userId is taken + from the Activity in the TurnContext. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-core/tests/test_test_adapter.py b/libraries/botbuilder-core/tests/test_test_adapter.py index 1d095c222..4312ca352 100644 --- a/libraries/botbuilder-core/tests/test_test_adapter.py +++ b/libraries/botbuilder-core/tests/test_test_adapter.py @@ -1,137 +1,247 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import aiounittest -from botbuilder.schema import Activity, ConversationReference -from botbuilder.core import TurnContext -from botbuilder.core.adapters import TestAdapter - -RECEIVED_MESSAGE = Activity(type="message", text="received") -UPDATED_ACTIVITY = Activity(type="message", text="update") -DELETED_ACTIVITY_REFERENCE = ConversationReference(activity_id="1234") - - -class TestTestAdapter(aiounittest.AsyncTestCase): - async def test_should_call_bog_logic_when_receive_activity_is_called(self): - async def logic(context: TurnContext): - assert context - assert context.activity - assert context.activity.type == "message" - assert context.activity.text == "test" - assert context.activity.id - assert context.activity.from_property - assert context.activity.recipient - assert context.activity.conversation - assert context.activity.channel_id - assert context.activity.service_url - - adapter = TestAdapter(logic) - await adapter.receive_activity("test") - - async def test_should_support_receive_activity_with_activity(self): - async def logic(context: TurnContext): - assert context.activity.type == "message" - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.receive_activity(Activity(type="message", text="test")) - - async def test_should_set_activity_type_when_receive_activity_receives_activity_without_type( - self, - ): - async def logic(context: TurnContext): - assert context.activity.type == "message" - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.receive_activity(Activity(text="test")) - - async def test_should_support_custom_activity_id_in_receive_activity(self): - async def logic(context: TurnContext): - assert context.activity.id == "myId" - assert context.activity.type == "message" - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.receive_activity(Activity(type="message", text="test", id="myId")) - - async def test_should_call_bot_logic_when_send_is_called(self): - async def logic(context: TurnContext): - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.send("test") - - async def test_should_send_and_receive_when_test_is_called(self): - async def logic(context: TurnContext): - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - await adapter.test("test", "received") - - async def test_should_send_and_throw_assertion_error_when_test_is_called(self): - async def logic(context: TurnContext): - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - try: - await adapter.test("test", "foobar") - except AssertionError: - pass - else: - raise AssertionError("Assertion error should have been raised") - - async def test_tests_should_call_test_for_each_tuple(self): - counter = 0 - - async def logic(context: TurnContext): - nonlocal counter - counter += 1 - await context.send_activity(Activity(type="message", text=str(counter))) - - adapter = TestAdapter(logic) - await adapter.tests(("test", "1"), ("test", "2"), ("test", "3")) - assert counter == 3 - - async def test_tests_should_call_test_for_each_list(self): - counter = 0 - - async def logic(context: TurnContext): - nonlocal counter - counter += 1 - await context.send_activity(Activity(type="message", text=str(counter))) - - adapter = TestAdapter(logic) - await adapter.tests(["test", "1"], ["test", "2"], ["test", "3"]) - assert counter == 3 - - async def test_should_assert_reply_after_send(self): - async def logic(context: TurnContext): - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - test_flow = await adapter.send("test") - await test_flow.assert_reply("received") - - async def test_should_support_context_update_activity_call(self): - async def logic(context: TurnContext): - await context.update_activity(UPDATED_ACTIVITY) - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - await adapter.test("test", "received") - assert len(adapter.updated_activities) == 1 - assert adapter.updated_activities[0].text == UPDATED_ACTIVITY.text - - async def test_should_support_context_delete_activity_call(self): - async def logic(context: TurnContext): - await context.delete_activity(DELETED_ACTIVITY_REFERENCE) - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - await adapter.test("test", "received") - assert len(adapter.deleted_activities) == 1 - assert ( - adapter.deleted_activities[0].activity_id - == DELETED_ACTIVITY_REFERENCE.activity_id - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest + +from botbuilder.core import TurnContext +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import Activity, ConversationReference, ChannelAccount +from botframework.connector.auth import MicrosoftAppCredentials + +RECEIVED_MESSAGE = Activity(type="message", text="received") +UPDATED_ACTIVITY = Activity(type="message", text="update") +DELETED_ACTIVITY_REFERENCE = ConversationReference(activity_id="1234") + + +class TestTestAdapter(aiounittest.AsyncTestCase): + async def test_should_call_bog_logic_when_receive_activity_is_called(self): + async def logic(context: TurnContext): + assert context + assert context.activity + assert context.activity.type == "message" + assert context.activity.text == "test" + assert context.activity.id + assert context.activity.from_property + assert context.activity.recipient + assert context.activity.conversation + assert context.activity.channel_id + assert context.activity.service_url + + adapter = TestAdapter(logic) + await adapter.receive_activity("test") + + async def test_should_support_receive_activity_with_activity(self): + async def logic(context: TurnContext): + assert context.activity.type == "message" + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.receive_activity(Activity(type="message", text="test")) + + async def test_should_set_activity_type_when_receive_activity_receives_activity_without_type( + self, + ): + async def logic(context: TurnContext): + assert context.activity.type == "message" + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.receive_activity(Activity(text="test")) + + async def test_should_support_custom_activity_id_in_receive_activity(self): + async def logic(context: TurnContext): + assert context.activity.id == "myId" + assert context.activity.type == "message" + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.receive_activity(Activity(type="message", text="test", id="myId")) + + async def test_should_call_bot_logic_when_send_is_called(self): + async def logic(context: TurnContext): + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.send("test") + + async def test_should_send_and_receive_when_test_is_called(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + await adapter.test("test", "received") + + async def test_should_send_and_throw_assertion_error_when_test_is_called(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + try: + await adapter.test("test", "foobar") + except AssertionError: + pass + else: + raise AssertionError("Assertion error should have been raised") + + async def test_tests_should_call_test_for_each_tuple(self): + counter = 0 + + async def logic(context: TurnContext): + nonlocal counter + counter += 1 + await context.send_activity(Activity(type="message", text=str(counter))) + + adapter = TestAdapter(logic) + await adapter.tests(("test", "1"), ("test", "2"), ("test", "3")) + assert counter == 3 + + async def test_tests_should_call_test_for_each_list(self): + counter = 0 + + async def logic(context: TurnContext): + nonlocal counter + counter += 1 + await context.send_activity(Activity(type="message", text=str(counter))) + + adapter = TestAdapter(logic) + await adapter.tests(["test", "1"], ["test", "2"], ["test", "3"]) + assert counter == 3 + + async def test_should_assert_reply_after_send(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + test_flow = await adapter.send("test") + await test_flow.assert_reply("received") + + async def test_should_support_context_update_activity_call(self): + async def logic(context: TurnContext): + await context.update_activity(UPDATED_ACTIVITY) + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + await adapter.test("test", "received") + assert len(adapter.updated_activities) == 1 + assert adapter.updated_activities[0].text == UPDATED_ACTIVITY.text + + async def test_should_support_context_delete_activity_call(self): + async def logic(context: TurnContext): + await context.delete_activity(DELETED_ACTIVITY_REFERENCE) + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + await adapter.test("test", "received") + assert len(adapter.deleted_activities) == 1 + assert ( + adapter.deleted_activities[0].activity_id + == DELETED_ACTIVITY_REFERENCE.activity_id + ) + + async def test_get_user_token_returns_null(self): + adapter = TestAdapter() + activity = Activity( + channel_id="directline", from_property=ChannelAccount(id="testuser") + ) + + turn_context = TurnContext(adapter, activity) + + token_response = await adapter.get_user_token(turn_context, "myConnection") + assert not token_response + + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, "myConnection", oauth_app_credentials=oauth_app_credentials + ) + assert not token_response + + async def test_get_user_token_returns_null_with_code(self): + adapter = TestAdapter() + activity = Activity( + channel_id="directline", from_property=ChannelAccount(id="testuser") + ) + + turn_context = TurnContext(adapter, activity) + + token_response = await adapter.get_user_token( + turn_context, "myConnection", "abc123" + ) + assert not token_response + + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, + "myConnection", + "abc123", + oauth_app_credentials=oauth_app_credentials, + ) + assert not token_response + + async def test_get_user_token_returns_token(self): + adapter = TestAdapter() + connection_name = "myConnection" + channel_id = "directline" + user_id = "testUser" + token = "abc123" + activity = Activity( + channel_id=channel_id, from_property=ChannelAccount(id=user_id) + ) + + turn_context = TurnContext(adapter, activity) + + adapter.add_user_token(connection_name, channel_id, user_id, token) + + token_response = await adapter.get_user_token(turn_context, connection_name) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, connection_name, oauth_app_credentials=oauth_app_credentials + ) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + async def test_get_user_token_returns_token_with_magice_code(self): + adapter = TestAdapter() + connection_name = "myConnection" + channel_id = "directline" + user_id = "testUser" + token = "abc123" + magic_code = "888999" + activity = Activity( + channel_id=channel_id, from_property=ChannelAccount(id=user_id) + ) + + turn_context = TurnContext(adapter, activity) + + adapter.add_user_token(connection_name, channel_id, user_id, token, magic_code) + + # First no magic_code + token_response = await adapter.get_user_token(turn_context, connection_name) + assert not token_response + + # Can be retrieved with magic code + token_response = await adapter.get_user_token( + turn_context, connection_name, magic_code + ) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + # Then can be retrieved without magic code + token_response = await adapter.get_user_token(turn_context, connection_name) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + # Then can be retrieved using customized AppCredentials + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, connection_name, oauth_app_credentials=oauth_app_credentials + ) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index a8cd05048..0f9a82453 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -143,7 +143,10 @@ async def begin_dialog( ) output = await dialog_context.context.adapter.get_user_token( - dialog_context.context, self._settings.connection_name, None + dialog_context.context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) if output is not None: @@ -220,6 +223,8 @@ async def get_user_token( :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` + :param code: (Optional) Optional user entered code to validate. + :type code: str :return: A response that includes the user's token :rtype: :class:`TokenResponse` @@ -237,7 +242,10 @@ async def get_user_token( ) return await adapter.get_user_token( - context, self._settings.connection_name, code + context, + self._settings.connection_name, + code, + self._settings.oath_app_credentials, ) async def sign_out_user(self, context: TurnContext): @@ -260,7 +268,12 @@ async def sign_out_user(self, context: TurnContext): "OAuthPrompt.sign_out_user(): not supported for the current adapter." ) - return await adapter.sign_out_user(context, self._settings.connection_name) + return await adapter.sign_out_user( + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, + ) async def _send_oauth_card( self, context: TurnContext, prompt: Union[Activity, str] = None @@ -288,13 +301,19 @@ async def _send_oauth_card( "OAuthPrompt: get_oauth_sign_in_link() not supported by the current adapter" ) link = await context.adapter.get_oauth_sign_in_link( - context, self._settings.connection_name + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) elif bot_identity and SkillValidation.is_skill_claim( bot_identity.claims ): link = await context.adapter.get_oauth_sign_in_link( - context, self._settings.connection_name + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) card_action_type = ActionTypes.open_url @@ -325,7 +344,10 @@ async def _send_oauth_card( ) link = await context.adapter.get_oauth_sign_in_link( - context, self._settings.connection_name + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) prompt.attachments.append( CardFactory.signin_card( diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py index 4eec4881a..1d8f04eca 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py @@ -1,21 +1,30 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class OAuthPromptSettings: - def __init__( - self, connection_name: str, title: str, text: str = None, timeout: int = None - ): - """ - Settings used to configure an `OAuthPrompt` instance. - Parameters: - connection_name (str): Name of the OAuth connection being used. - title (str): The title of the cards signin button. - text (str): (Optional) additional text included on the signin card. - timeout (int): (Optional) number of milliseconds the prompt will wait for the user to authenticate. - `OAuthPrompt` defaults value to `900,000` ms (15 minutes). - """ - self.connection_name = connection_name - self.title = title - self.text = text - self.timeout = timeout +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botframework.connector.auth import AppCredentials + + +class OAuthPromptSettings: + def __init__( + self, + connection_name: str, + title: str, + text: str = None, + timeout: int = None, + oauth_app_credentials: AppCredentials = None, + ): + """ + Settings used to configure an `OAuthPrompt` instance. + Parameters: + connection_name (str): Name of the OAuth connection being used. + title (str): The title of the cards signin button. + text (str): (Optional) additional text included on the signin card. + timeout (int): (Optional) number of milliseconds the prompt will wait for the user to authenticate. + `OAuthPrompt` defaults value to `900,000` ms (15 minutes). + oauth_app_credentials (AppCredentials): (Optional) AppCredentials to use for OAuth. If None, + the Bots credentials are used. + """ + self.connection_name = connection_name + self.title = title + self.text = text + self.timeout = timeout + self.oath_app_credentials = oauth_app_credentials From fc05153fb11b6e5755eb12dfe3f59dc4f079d82d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 08:55:33 -0600 Subject: [PATCH 298/616] Update ci-pr-pipeline.yml for Azure Pipelines Run pytest using Python 3.6, 3.7, and 3.8 --- ci-pr-pipeline.yml | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 13d622d1c..0e358e9bf 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -6,7 +6,10 @@ variables: COVERALLS_GIT_COMMIT: $(Build.SourceVersion) COVERALLS_SERVICE_JOB_ID: $(Build.BuildId) COVERALLS_SERVICE_NAME: python-ci - python.version: 3.7.6 + python.36: 3.6.9 + python.37: 3.7.6 + pythin.38: 3.8.1 + python.version: $(python.37) jobs: @@ -53,8 +56,39 @@ jobs: pip install pytest pip install pytest-cov pip install coveralls - pytest --junitxml=junit/test-results.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html - displayName: Pytest + displayName: PyTest + + - task: UsePythonVersion@0 + displayName: 'Use Python $(python.36)' + inputs: + versionSpec: '$(python.36)' + + - script: + pytest --junitxml=junit/test-results.36.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + displayName: Pytest.36 + + - task: UsePythonVersion@0 + displayName: 'Use Python $(python.37)' + inputs: + versionSpec: '$(python.37)' + + - script: + pytest --junitxml=junit/test-results.37.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + displayName: Pytest.37 + + - task: UsePythonVersion@0 + displayName: 'Use Python $(python.38)' + inputs: + versionSpec: '$(python.38)' + + - script: + pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + displayName: Pytest.38 + + - task: UsePythonVersion@0 + displayName: 'Use Python $(python.version)' + inputs: + versionSpec: '$(python.version)' - script: 'black --check libraries' displayName: 'Check Black compliant' @@ -65,7 +99,7 @@ jobs: - task: PublishTestResults@2 displayName: 'Publish Test Results **/test-results.xml' inputs: - testResultsFiles: '**/test-results.xml' + testResultsFiles: '**/test-results*.xml' testRunTitle: 'Python $(python.version)' - script: 'COVERALLS_REPO_TOKEN=$(COVERALLS_TOKEN) coveralls' From e4795ec9f9ac9fc02167752568f5d15e77dad0c6 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 09:03:58 -0600 Subject: [PATCH 299/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 0e358e9bf..da36a7a9c 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -6,7 +6,7 @@ variables: COVERALLS_GIT_COMMIT: $(Build.SourceVersion) COVERALLS_SERVICE_JOB_ID: $(Build.BuildId) COVERALLS_SERVICE_NAME: python-ci - python.36: 3.6.9 + python.36: 3.6.10 python.37: 3.7.6 pythin.38: 3.8.1 python.version: $(python.37) From dc3ecab5c1031c3a8cc57f74fa8da8d5af8ba7e0 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 09:26:41 -0600 Subject: [PATCH 300/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index da36a7a9c..923cfba50 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -56,7 +56,7 @@ jobs: pip install pytest pip install pytest-cov pip install coveralls - displayName: PyTest + displayName: PyTestSetup - task: UsePythonVersion@0 displayName: 'Use Python $(python.36)' @@ -67,6 +67,12 @@ jobs: pytest --junitxml=junit/test-results.36.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.36 + - task: PublishTestResults@2 + displayName: 'Publish Test Results **/test-results.36.xml' + inputs: + testResultsFiles: '**/test-results.36.xml' + testRunTitle: 'Python $(python.36)' + - task: UsePythonVersion@0 displayName: 'Use Python $(python.37)' inputs: @@ -76,6 +82,12 @@ jobs: pytest --junitxml=junit/test-results.37.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.37 + - task: PublishTestResults@2 + displayName: 'Publish Test Results **/test-results.37.xml' + inputs: + testResultsFiles: '**/test-results.37.xml' + testRunTitle: 'Python $(python.37)' + - task: UsePythonVersion@0 displayName: 'Use Python $(python.38)' inputs: @@ -85,6 +97,12 @@ jobs: pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.38 + - task: PublishTestResults@2 + displayName: 'Publish Test Results **/test-results.38.xml' + inputs: + testResultsFiles: '**/test-results.38.xml' + testRunTitle: 'Python $(python.38)' + - task: UsePythonVersion@0 displayName: 'Use Python $(python.version)' inputs: @@ -96,12 +114,6 @@ jobs: - script: 'pylint --rcfile=.pylintrc libraries' displayName: Pylint - - task: PublishTestResults@2 - displayName: 'Publish Test Results **/test-results.xml' - inputs: - testResultsFiles: '**/test-results*.xml' - testRunTitle: 'Python $(python.version)' - - script: 'COVERALLS_REPO_TOKEN=$(COVERALLS_TOKEN) coveralls' displayName: 'Push test results to coveralls https://coveralls.io/github/microsoft/botbuilder-python' continueOnError: true From 77a37ba2438a01c3ef5df42234b092e60d265eae Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 09:41:26 -0600 Subject: [PATCH 301/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 923cfba50..c2350bc3e 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -91,7 +91,7 @@ jobs: - task: UsePythonVersion@0 displayName: 'Use Python $(python.38)' inputs: - versionSpec: '$(python.38)' + versionSpec: '3.8.1' - script: pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html From 97b18d18da6d478c42df90c504bf894434decd22 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 09:56:17 -0600 Subject: [PATCH 302/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index c2350bc3e..f68888837 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -8,7 +8,7 @@ variables: COVERALLS_SERVICE_NAME: python-ci python.36: 3.6.10 python.37: 3.7.6 - pythin.38: 3.8.1 + python.38: 3.8.1 python.version: $(python.37) @@ -64,7 +64,7 @@ jobs: versionSpec: '$(python.36)' - script: - pytest --junitxml=junit/test-results.36.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + python -m pytest --junitxml=junit/test-results.36.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.36 - task: PublishTestResults@2 @@ -79,7 +79,7 @@ jobs: versionSpec: '$(python.37)' - script: - pytest --junitxml=junit/test-results.37.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + python -m pytest --junitxml=junit/test-results.37.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.37 - task: PublishTestResults@2 @@ -91,10 +91,10 @@ jobs: - task: UsePythonVersion@0 displayName: 'Use Python $(python.38)' inputs: - versionSpec: '3.8.1' + versionSpec: '$(python.38)' - script: - pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + python -m pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.38 - task: PublishTestResults@2 From a7d516901936c15f152f0845522f01e321e7cc5d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 10:02:50 -0600 Subject: [PATCH 303/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index f68888837..dcd84e4ba 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -52,18 +52,15 @@ jobs: pip install black displayName: 'Install dependencies' - - script: | - pip install pytest - pip install pytest-cov - pip install coveralls - displayName: PyTestSetup - - task: UsePythonVersion@0 displayName: 'Use Python $(python.36)' inputs: versionSpec: '$(python.36)' - script: + pip install pytest + pip install pytest-cov + pip install coveralls python -m pytest --junitxml=junit/test-results.36.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.36 @@ -79,6 +76,9 @@ jobs: versionSpec: '$(python.37)' - script: + pip install pytest + pip install pytest-cov + pip install coveralls python -m pytest --junitxml=junit/test-results.37.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.37 @@ -94,6 +94,9 @@ jobs: versionSpec: '$(python.38)' - script: + pip install pytest + pip install pytest-cov + pip install coveralls python -m pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest.38 From 463bdc7893dc683f43e362c6bb6fc596d5510d41 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 10:12:32 -0600 Subject: [PATCH 304/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 73 +++++++++++----------------------------------- 1 file changed, 17 insertions(+), 56 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index dcd84e4ba..540176901 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -9,7 +9,6 @@ variables: python.36: 3.6.10 python.37: 3.7.6 python.38: 3.8.1 - python.version: $(python.37) jobs: @@ -18,18 +17,26 @@ jobs: #Multi-configuration and multi-agent job options are not exported to YAML. Configure these options using documentation guidance: https://docs.microsoft.com/vsts/pipelines/process/phases pool: name: Hosted Ubuntu 1604 - #Your build pipeline references the ‘python.version’ variable, which you’ve selected to be settable at queue time. Create or edit the build pipeline for this YAML file, define the variable on the Variables tab, and then select the option to make it settable at queue time. See https://go.microsoft.com/fwlink/?linkid=865971 - #Your build pipeline references the ‘python.version’ variable, which you’ve selected to be settable at queue time. Create or edit the build pipeline for this YAML file, define the variable on the Variables tab, and then select the option to make it settable at queue time. See https://go.microsoft.com/fwlink/?linkid=865971 steps: - powershell: | Get-ChildItem env:* | sort-object name | Format-Table -Autosize -Wrap | Out-String -Width 120 displayName: 'Get environment vars' + strategy: + matrix: + Python35: + PYTHON_VERSION: '$(python.36)' + Python36: + PYTHON_VERSION: '$(python.37)' + Python37: + PYTHON_VERSION: '$(python.38)' + maxParallel: 2 + - task: UsePythonVersion@0 - displayName: 'Use Python $(python.version)' + displayName: 'Use Python $(PYTHON_VERSION)' inputs: - versionSpec: '$(python.version)' + versionSpec: '$(PYTHON_VERSION)' - script: 'sudo ln -s /opt/hostedtoolcache/Python/3.6.9/x64/lib/libpython3.6m.so.1.0 /usr/lib/libpython3.6m.so' displayName: libpython3.6m @@ -52,64 +59,18 @@ jobs: pip install black displayName: 'Install dependencies' - - task: UsePythonVersion@0 - displayName: 'Use Python $(python.36)' - inputs: - versionSpec: '$(python.36)' - - - script: - pip install pytest - pip install pytest-cov - pip install coveralls - python -m pytest --junitxml=junit/test-results.36.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html - displayName: Pytest.36 - - - task: PublishTestResults@2 - displayName: 'Publish Test Results **/test-results.36.xml' - inputs: - testResultsFiles: '**/test-results.36.xml' - testRunTitle: 'Python $(python.36)' - - - task: UsePythonVersion@0 - displayName: 'Use Python $(python.37)' - inputs: - versionSpec: '$(python.37)' - - script: pip install pytest pip install pytest-cov pip install coveralls - python -m pytest --junitxml=junit/test-results.37.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html - displayName: Pytest.37 + python -m pytest --junitxml=junit/test-results.$(PYTHON_VERSION).xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + displayName: Pytest - task: PublishTestResults@2 - displayName: 'Publish Test Results **/test-results.37.xml' - inputs: - testResultsFiles: '**/test-results.37.xml' - testRunTitle: 'Python $(python.37)' - - - task: UsePythonVersion@0 - displayName: 'Use Python $(python.38)' - inputs: - versionSpec: '$(python.38)' - - - script: - pip install pytest - pip install pytest-cov - pip install coveralls - python -m pytest --junitxml=junit/test-results.38.xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html - displayName: Pytest.38 - - - task: PublishTestResults@2 - displayName: 'Publish Test Results **/test-results.38.xml' - inputs: - testResultsFiles: '**/test-results.38.xml' - testRunTitle: 'Python $(python.38)' - - - task: UsePythonVersion@0 - displayName: 'Use Python $(python.version)' + displayName: 'Publish Test Results **/test-results.$(PYTHON_VERSION).xml' inputs: - versionSpec: '$(python.version)' + testResultsFiles: '**/test-results.$(PYTHON_VERSION).xml' + testRunTitle: 'Python $(PYTHON_VERSION)' - script: 'black --check libraries' displayName: 'Check Black compliant' From 330388cdf3d0caa26b1002f374818ab0905882b8 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 10:14:05 -0600 Subject: [PATCH 305/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 540176901..95e1d1444 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -18,11 +18,6 @@ jobs: pool: name: Hosted Ubuntu 1604 - steps: - - powershell: | - Get-ChildItem env:* | sort-object name | Format-Table -Autosize -Wrap | Out-String -Width 120 - displayName: 'Get environment vars' - strategy: matrix: Python35: @@ -33,6 +28,11 @@ jobs: PYTHON_VERSION: '$(python.38)' maxParallel: 2 + steps: + - powershell: | + Get-ChildItem env:* | sort-object name | Format-Table -Autosize -Wrap | Out-String -Width 120 + displayName: 'Get environment vars' + - task: UsePythonVersion@0 displayName: 'Use Python $(PYTHON_VERSION)' inputs: From 8de379c5a663bef5b93fec57f4b0a89d61217d05 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 10:16:55 -0600 Subject: [PATCH 306/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 95e1d1444..6f1fd0789 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -20,11 +20,11 @@ jobs: strategy: matrix: - Python35: - PYTHON_VERSION: '$(python.36)' Python36: - PYTHON_VERSION: '$(python.37)' + PYTHON_VERSION: '$(python.36)' Python37: + PYTHON_VERSION: '$(python.37)' + Python38: PYTHON_VERSION: '$(python.38)' maxParallel: 2 @@ -63,7 +63,7 @@ jobs: pip install pytest pip install pytest-cov pip install coveralls - python -m pytest --junitxml=junit/test-results.$(PYTHON_VERSION).xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + pytest --junitxml=junit/test-results.$(PYTHON_VERSION).xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest - task: PublishTestResults@2 From 4b737f6f55915cc08b97ecc8d5fdfa3e8723bf8c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 24 Feb 2020 10:20:49 -0600 Subject: [PATCH 307/616] Update ci-pr-pipeline.yml for Azure Pipelines --- ci-pr-pipeline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 6f1fd0789..28a803d69 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -26,7 +26,7 @@ jobs: PYTHON_VERSION: '$(python.37)' Python38: PYTHON_VERSION: '$(python.38)' - maxParallel: 2 + maxParallel: 3 steps: - powershell: | @@ -59,7 +59,7 @@ jobs: pip install black displayName: 'Install dependencies' - - script: + - script: | pip install pytest pip install pytest-cov pip install coveralls From 17803055688f68f25db4c158bd84912646bf048f Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Mon, 24 Feb 2020 14:21:35 -0800 Subject: [PATCH 308/616] Adding helper for starting thread in Teams (#653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding helper for starting thread in Teams * updating for black * adding helper for starting thread in Teams * updating for black * fixing imports and PR feedback * saving changes * removing merge conflict leftover * black Co-authored-by: Axel Suárez Co-authored-by: tracyboehrer --- .../botbuilder/adapters/slack/slack_client.py | 5 ++- .../botbuilder/adapters/slack/slack_helper.py | 3 +- .../adapters/slack/slack_payload.py | 4 +- .../botbuilder/core/bot_framework_adapter.py | 7 ++-- .../botbuilder/core/teams/teams_info.py | 38 ++++++++++++++++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py index d5e645f3f..42fb96e81 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py @@ -9,13 +9,14 @@ import aiohttp from aiohttp.web_request import Request -from slack.web.client import WebClient -from slack.web.slack_response import SlackResponse from botbuilder.schema import Activity from botbuilder.adapters.slack import SlackAdapterOptions from botbuilder.adapters.slack.slack_message import SlackMessage +from slack.web.client import WebClient +from slack.web.slack_response import SlackResponse + POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage" POST_EPHEMERAL_MESSAGE_URL = "https://slack.com/api/chat.postEphemeral" diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py index bc5e471a3..e15604442 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -6,7 +6,6 @@ from aiohttp.web_request import Request from aiohttp.web_response import Response -from slack.web.classes.attachments import Attachment from botbuilder.schema import ( Activity, @@ -15,6 +14,8 @@ ActivityTypes, ) +from slack.web.classes.attachments import Attachment + from .slack_message import SlackMessage from .slack_client import SlackClient from .slack_event import SlackEvent diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py index 5a8fd90eb..0be8e3666 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py @@ -3,10 +3,10 @@ from typing import Optional, List -from slack.web.classes.actions import Action - from botbuilder.adapters.slack.slack_message import SlackMessage +from slack.web.classes.actions import Action + class SlackPayload: def __init__(self, **kwargs): diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 3f248147f..f04be1c97 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -305,7 +305,9 @@ async def create_conversation( ) ) client = await self.create_connector_client(reference.service_url) - + resource_response = await client.conversations.create_conversation( + parameters + ) # Mix in the tenant ID if specified. This is required for MS Teams. if reference.conversation is not None and reference.conversation.tenant_id: # Putting tenant_id in channel_data is a temporary while we wait for the Teams API to be updated @@ -316,9 +318,6 @@ async def create_conversation( # Permanent solution is to put tenant_id in parameters.tenant_id parameters.tenant_id = reference.conversation.tenant_id - resource_response = await client.conversations.create_conversation( - parameters - ) request = TurnContext.apply_conversation_reference( Activity(type=ActivityTypes.event, name="CreateConversation"), reference, diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 0b9cb9471..0395b4945 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List -from botbuilder.core.turn_context import TurnContext +from typing import List, Tuple +from botbuilder.schema import ConversationParameters, ConversationReference +from botbuilder.core.turn_context import Activity, TurnContext from botbuilder.schema.teams import ( ChannelInfo, TeamDetails, @@ -14,6 +15,39 @@ class TeamsInfo: + @staticmethod + async def send_message_to_teams_channel( + turn_context: TurnContext, activity: Activity, teams_channel_id: str + ) -> Tuple[ConversationReference, str]: + if not turn_context: + raise ValueError("The turn_context cannot be None") + if not turn_context.activity: + raise ValueError("The turn_context.activity cannot be None") + if not teams_channel_id: + raise ValueError("The teams_channel_id cannot be None or empty") + + old_ref = TurnContext.get_conversation_reference(turn_context.activity) + conversation_parameters = ConversationParameters( + is_group=True, + channel_data={"channel": {"id": teams_channel_id}}, + activity=activity, + ) + + result = await turn_context.adapter.create_conversation( + old_ref, TeamsInfo._create_conversation_callback, conversation_parameters + ) + return (result[0], result[1]) + + @staticmethod + async def _create_conversation_callback( + new_turn_context, + ) -> Tuple[ConversationReference, str]: + new_activity_id = new_turn_context.activity.id + conversation_reference = TurnContext.get_conversation_reference( + new_turn_context.activity + ) + return (conversation_reference, new_activity_id) + @staticmethod async def get_team_details( turn_context: TurnContext, team_id: str = "" From cf76bd88e4cf6fddc1bc71ceeece662ab78dc956 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 25 Feb 2020 13:31:54 -0800 Subject: [PATCH 309/616] adding unit test for helper: --- .../botbuilder-core/tests/simple_adapter.py | 22 ++++++- .../tests/teams/simple_adapter.py | 60 ------------------- .../tests/teams/test_teams_info.py | 49 +++++++++++++++ 3 files changed, 68 insertions(+), 63 deletions(-) delete mode 100644 libraries/botbuilder-core/tests/teams/simple_adapter.py create mode 100644 libraries/botbuilder-core/tests/teams/test_teams_info.py diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index 1202ad7f1..f70d3c384 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -2,20 +2,27 @@ # Licensed under the MIT License. import unittest -from typing import List +from typing import List, Tuple, Awaitable, Callable from botbuilder.core import BotAdapter, TurnContext -from botbuilder.schema import Activity, ConversationReference, ResourceResponse +from botbuilder.schema import Activity, ConversationReference, ResourceResponse, ConversationParameters class SimpleAdapter(BotAdapter): # pylint: disable=unused-argument - def __init__(self, call_on_send=None, call_on_update=None, call_on_delete=None): + def __init__( + self, + call_on_send=None, + call_on_update=None, + call_on_delete=None, + call_create_conversation=None, + ): super(SimpleAdapter, self).__init__() self.test_aux = unittest.TestCase("__init__") self._call_on_send = call_on_send self._call_on_update = call_on_update self._call_on_delete = call_on_delete + self._call_create_conversation = call_create_conversation async def delete_activity( self, context: TurnContext, reference: ConversationReference @@ -46,6 +53,15 @@ async def send_activities( return responses + async def create_conversation( + self, + reference: ConversationReference, + logic: Callable[[TurnContext], Awaitable] = None, + conversation_parameters: ConversationParameters = None, + ) -> Tuple[ConversationReference, str]: + if self._call_create_conversation is not None: + self._call_create_conversation() + async def update_activity(self, context: TurnContext, activity: Activity): self.test_aux.assertIsNotNone( activity, "SimpleAdapter.update_activity: missing activity" diff --git a/libraries/botbuilder-core/tests/teams/simple_adapter.py b/libraries/botbuilder-core/tests/teams/simple_adapter.py deleted file mode 100644 index a80fa29b3..000000000 --- a/libraries/botbuilder-core/tests/teams/simple_adapter.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import unittest -from typing import List -from botbuilder.core import BotAdapter, TurnContext -from botbuilder.schema import Activity, ConversationReference, ResourceResponse - - -class SimpleAdapter(BotAdapter): - # pylint: disable=unused-argument - - def __init__(self, call_on_send=None, call_on_update=None, call_on_delete=None): - super(SimpleAdapter, self).__init__() - self.test_aux = unittest.TestCase("__init__") - self._call_on_send = call_on_send - self._call_on_update = call_on_update - self._call_on_delete = call_on_delete - - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - self.test_aux.assertIsNotNone( - reference, "SimpleAdapter.delete_activity: missing reference" - ) - if self._call_on_delete is not None: - self._call_on_delete(reference) - - async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: - self.test_aux.assertIsNotNone( - activities, "SimpleAdapter.delete_activity: missing reference" - ) - self.test_aux.assertTrue( - len(activities) > 0, - "SimpleAdapter.send_activities: empty activities array.", - ) - - if self._call_on_send is not None: - self._call_on_send(activities) - responses = [] - - for activity in activities: - responses.append(ResourceResponse(id=activity.id)) - - return responses - - async def update_activity(self, context: TurnContext, activity: Activity): - self.test_aux.assertIsNotNone( - activity, "SimpleAdapter.update_activity: missing activity" - ) - if self._call_on_update is not None: - self._call_on_update(activity) - - return ResourceResponse(activity.id) - - async def process_request(self, activity, handler): - context = TurnContext(self, activity) - return self.run_pipeline(context, handler) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py new file mode 100644 index 000000000..9d71e40cb --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest + + +from botbuilder.core import TurnContext, MessageFactory +from botbuilder.core.teams import TeamsInfo, TeamsActivityHandler +from botbuilder.schema import Activity +from botbuilder.schema.teams import TeamsChannelData, TeamInfo +from botframework.connector import Channels +from simple_adapter import SimpleAdapter + + +class TestTeamsInfo(aiounittest.AsyncTestCase): + async def test_send_message_to_teams(self): + def create_conversation(): + pass + + adapter = SimpleAdapter(call_create_conversation=create_conversation) + + activity = Activity( + type="message", + text="test_send_message_to_teams_channel", + channel_id=Channels.ms_teams, + service_url="https://example.org", + channel_data=TeamsChannelData(team=TeamInfo(id="team-id")), + ) + turn_context = TurnContext(adapter, activity) + handler = TestTeamsActivityHandler() + await handler.on_turn(turn_context) + + +class TestTeamsActivityHandler(TeamsActivityHandler): + async def on_turn(self, turn_context: TurnContext): + super().on_turn(turn_context) + + if turn_context.activity.text == "test_send_message_to_teams_channel": + await self.call_send_message_to_teams(turn_context) + + async def call_send_message_to_teams(self, turn_context: TurnContext): + msg = MessageFactory.text("call_send_message_to_teams") + channel_id = "teams_channel_123" + reference = await TeamsInfo.send_message_to_teams_channel( + turn_context, msg, channel_id + ) + + assert reference[0].activity_id == "new_conversation_id" + assert reference[1] == "reference123" From d66de1b6ac5bfc44500888fbfe7a04a6e1b440a6 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 25 Feb 2020 13:50:00 -0800 Subject: [PATCH 310/616] updating callback function --- ...simple_adapter_with_create_conversation.py | 78 +++++++++++++++++++ .../tests/teams/test_teams_info.py | 4 +- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py diff --git a/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py b/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py new file mode 100644 index 000000000..18ac35c82 --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from typing import List, Tuple, Awaitable, Callable +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import Activity, ConversationReference, ResourceResponse, ConversationParameters + + +class SimpleAdapterWithCreateConversation(BotAdapter): + # pylint: disable=unused-argument + + def __init__( + self, + call_on_send=None, + call_on_update=None, + call_on_delete=None, + call_create_conversation=None, + ): + super(SimpleAdapterWithCreateConversation, self).__init__() + self.test_aux = unittest.TestCase("__init__") + self._call_on_send = call_on_send + self._call_on_update = call_on_update + self._call_on_delete = call_on_delete + self._call_create_conversation = call_create_conversation + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + self.test_aux.assertIsNotNone( + reference, "SimpleAdapter.delete_activity: missing reference" + ) + if self._call_on_delete is not None: + self._call_on_delete(reference) + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + self.test_aux.assertIsNotNone( + activities, "SimpleAdapter.delete_activity: missing reference" + ) + self.test_aux.assertTrue( + len(activities) > 0, + "SimpleAdapter.send_activities: empty activities array.", + ) + + if self._call_on_send is not None: + self._call_on_send(activities) + responses = [] + + for activity in activities: + responses.append(ResourceResponse(id=activity.id)) + + return responses + + async def create_conversation( + self, + reference: ConversationReference, + logic: Callable[[TurnContext], Awaitable] = None, + conversation_parameters: ConversationParameters = None, + ) -> Tuple[ConversationReference, str]: + if self._call_create_conversation is not None: + self._call_create_conversation() + ref = ConversationReference(activity_id="new_conversation_id") + return (ref, "reference123") + + async def update_activity(self, context: TurnContext, activity: Activity): + self.test_aux.assertIsNotNone( + activity, "SimpleAdapter.update_activity: missing activity" + ) + if self._call_on_update is not None: + self._call_on_update(activity) + + return ResourceResponse(activity.id) + + async def process_request(self, activity, handler): + context = TurnContext(self, activity) + return await self.run_pipeline(context, handler) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 9d71e40cb..70f129d1d 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -9,7 +9,7 @@ from botbuilder.schema import Activity from botbuilder.schema.teams import TeamsChannelData, TeamInfo from botframework.connector import Channels -from simple_adapter import SimpleAdapter +from simple_adapter_with_create_conversation import SimpleAdapterWithCreateConversation class TestTeamsInfo(aiounittest.AsyncTestCase): @@ -17,7 +17,7 @@ async def test_send_message_to_teams(self): def create_conversation(): pass - adapter = SimpleAdapter(call_create_conversation=create_conversation) + adapter = SimpleAdapterWithCreateConversation(call_create_conversation=create_conversation) activity = Activity( type="message", From 2d10a59c58448414bcd3dfb8a00b0ca7b20343af Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 25 Feb 2020 14:00:06 -0800 Subject: [PATCH 311/616] black --- libraries/botbuilder-core/tests/simple_adapter.py | 7 ++++++- .../tests/teams/simple_adapter_with_create_conversation.py | 7 ++++++- libraries/botbuilder-core/tests/teams/test_teams_info.py | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index f70d3c384..0ded46f45 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -4,7 +4,12 @@ import unittest from typing import List, Tuple, Awaitable, Callable from botbuilder.core import BotAdapter, TurnContext -from botbuilder.schema import Activity, ConversationReference, ResourceResponse, ConversationParameters +from botbuilder.schema import ( + Activity, + ConversationReference, + ResourceResponse, + ConversationParameters, +) class SimpleAdapter(BotAdapter): diff --git a/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py b/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py index 18ac35c82..477aa3b28 100644 --- a/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py +++ b/libraries/botbuilder-core/tests/teams/simple_adapter_with_create_conversation.py @@ -4,7 +4,12 @@ import unittest from typing import List, Tuple, Awaitable, Callable from botbuilder.core import BotAdapter, TurnContext -from botbuilder.schema import Activity, ConversationReference, ResourceResponse, ConversationParameters +from botbuilder.schema import ( + Activity, + ConversationReference, + ResourceResponse, + ConversationParameters, +) class SimpleAdapterWithCreateConversation(BotAdapter): diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 70f129d1d..27a68c1f2 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -17,7 +17,9 @@ async def test_send_message_to_teams(self): def create_conversation(): pass - adapter = SimpleAdapterWithCreateConversation(call_create_conversation=create_conversation) + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) activity = Activity( type="message", From d578948059a1895cd5e953ecfd8d85255c563189 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 28 Feb 2020 10:55:54 -0600 Subject: [PATCH 312/616] #780: Refactor BFAdapter Auth, Add support for proactively messaging non-channel recipients --- .../botbuilder/core/bot_adapter.py | 228 ++--- .../botbuilder/core/bot_framework_adapter.py | 528 +++++++---- .../tests/teams/test_teams_info.py | 2 +- .../tests/test_bot_framework_adapter.py | 867 ++++++++++++------ .../botframework/connector/auth/__init__.py | 1 + .../connector/auth/jwt_token_validation.py | 2 +- .../auth/microsoft_app_credentials.py | 4 + .../microsoft_government_app_credentials.py | 24 + .../connector/emulator_api_client.py | 4 +- 9 files changed, 1052 insertions(+), 608 deletions(-) create mode 100644 libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index f97030879..ac18666a1 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -1,113 +1,115 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC, abstractmethod -from typing import List, Callable, Awaitable -from botbuilder.schema import Activity, ConversationReference, ResourceResponse -from botframework.connector.auth import ClaimsIdentity - -from . import conversation_reference_extension -from .bot_assert import BotAssert -from .turn_context import TurnContext -from .middleware_set import MiddlewareSet - - -class BotAdapter(ABC): - def __init__( - self, on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None - ): - self._middleware = MiddlewareSet() - self.on_turn_error = on_turn_error - - @abstractmethod - async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: - """ - Sends a set of activities to the user. An array of responses from the server will be returned. - :param context: - :param activities: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def update_activity(self, context: TurnContext, activity: Activity): - """ - Replaces an existing activity. - :param context: - :param activity: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - """ - Deletes an existing activity. - :param context: - :param reference: - :return: - """ - raise NotImplementedError() - - def use(self, middleware): - """ - Registers a middleware handler with the adapter. - :param middleware: - :return: - """ - self._middleware.use(middleware) - return self - - async def continue_conversation( - self, - reference: ConversationReference, - callback: Callable, - bot_id: str = None, # pylint: disable=unused-argument - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument - ): - """ - Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. - Most _channels require a user to initiate a conversation with a bot before the bot can send activities - to the user. - :param bot_id: The application ID of the bot. This parameter is ignored in - single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter - which is multi-tenant aware. - :param reference: A reference to the conversation to continue. - :param callback: The method to call for the resulting bot turn. - :param claims_identity: - """ - context = TurnContext( - self, conversation_reference_extension.get_continuation_activity(reference) - ) - return await self.run_pipeline(context, callback) - - async def run_pipeline( - self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None - ): - """ - Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at - the end of the chain. - :param context: - :param callback: - :return: - """ - BotAssert.context_not_none(context) - - if context.activity is not None: - try: - return await self._middleware.receive_activity_with_status( - context, callback - ) - except Exception as error: - if self.on_turn_error is not None: - await self.on_turn_error(context, error) - else: - raise error - else: - # callback to caller on proactive case - if callback is not None: - await callback(context) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from typing import List, Callable, Awaitable +from botbuilder.schema import Activity, ConversationReference, ResourceResponse +from botframework.connector.auth import ClaimsIdentity + +from . import conversation_reference_extension +from .bot_assert import BotAssert +from .turn_context import TurnContext +from .middleware_set import MiddlewareSet + + +class BotAdapter(ABC): + def __init__( + self, on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None + ): + self._middleware = MiddlewareSet() + self.on_turn_error = on_turn_error + + @abstractmethod + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + """ + Sends a set of activities to the user. An array of responses from the server will be returned. + :param context: + :param activities: + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def update_activity(self, context: TurnContext, activity: Activity): + """ + Replaces an existing activity. + :param context: + :param activity: + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + """ + Deletes an existing activity. + :param context: + :param reference: + :return: + """ + raise NotImplementedError() + + def use(self, middleware): + """ + Registers a middleware handler with the adapter. + :param middleware: + :return: + """ + self._middleware.use(middleware) + return self + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + audience: str = None, # pylint: disable=unused-argument + ): + """ + Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. + Most _channels require a user to initiate a conversation with a bot before the bot can send activities + to the user. + :param bot_id: The application ID of the bot. This parameter is ignored in + single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. + :param claims_identity: + :param audience: + """ + context = TurnContext( + self, conversation_reference_extension.get_continuation_activity(reference) + ) + return await self.run_pipeline(context, callback) + + async def run_pipeline( + self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None + ): + """ + Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at + the end of the chain. + :param context: + :param callback: + :return: + """ + BotAssert.context_not_none(context) + + if context.activity is not None: + try: + return await self._middleware.receive_activity_with_status( + context, callback + ) + except Exception as error: + if self.on_turn_error is not None: + await self.on_turn_error(context, error) + else: + raise error + else: + # callback to caller on proactive case + if callback is not None: + await callback(context) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index c2a9aa869..62819c09e 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -7,6 +7,7 @@ import base64 import json import os +import uuid from typing import List, Callable, Awaitable, Union, Dict from msrest.serialization import Model @@ -22,10 +23,12 @@ GovernmentConstants, MicrosoftAppCredentials, JwtTokenValidation, + CredentialProvider, SimpleCredentialProvider, SkillValidation, - CertificateAppCredentials, AppCredentials, + SimpleChannelProvider, + MicrosoftGovernmentAppCredentials, ) from botframework.connector.token_api import TokenApiClient from botbuilder.schema import ( @@ -48,7 +51,6 @@ USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" OAUTH_ENDPOINT = "https://api.botframework.com" US_GOV_OAUTH_ENDPOINT = "https://api.botframework.azure.us" -BOT_IDENTITY_KEY = "BotIdentity" class TokenExchangeState(Model): @@ -83,11 +85,12 @@ def __init__( channel_auth_tenant: str = None, oauth_endpoint: str = None, open_id_metadata: str = None, - channel_service: str = None, channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, certificate_thumbprint: str = None, certificate_private_key: str = None, + app_credentials: AppCredentials = None, + credential_provider: CredentialProvider = None, ): """ Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. @@ -103,8 +106,6 @@ def __init__( :type oauth_endpoint: str :param open_id_metadata: :type open_id_metadata: str - :param channel_service: - :type channel_service: str :param channel_provider: The channel provider :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` :param auth_configuration: @@ -121,15 +122,29 @@ def __init__( """ self.app_id = app_id self.app_password = app_password + self.app_credentials = app_credentials self.channel_auth_tenant = channel_auth_tenant self.oauth_endpoint = oauth_endpoint - self.open_id_metadata = open_id_metadata - self.channel_service = channel_service - self.channel_provider = channel_provider + self.channel_provider = ( + channel_provider if channel_provider else SimpleChannelProvider() + ) + self.credential_provider = ( + credential_provider + if credential_provider + else SimpleCredentialProvider(self.app_id, self.app_password) + ) self.auth_configuration = auth_configuration or AuthenticationConfiguration() self.certificate_thumbprint = certificate_thumbprint self.certificate_private_key = certificate_private_key + # If no open_id_metadata values were passed in the settings, check the + # process' Environment Variable. + self.open_id_metadata = ( + open_id_metadata + if open_id_metadata + else os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) + ) + class BotFrameworkAdapter(BotAdapter, UserTokenProvider): """ @@ -147,6 +162,10 @@ class BotFrameworkAdapter(BotAdapter, UserTokenProvider): """ _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" + BOT_IDENTITY_KEY = "BotIdentity" + BOT_OAUTH_SCOPE_KEY = "OAuthScope" + BOT_CALLBACK_HANDLER_KEY = "BotCallbackHandler" + BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" def __init__(self, settings: BotFrameworkAdapterSettings): """ @@ -158,41 +177,14 @@ def __init__(self, settings: BotFrameworkAdapterSettings): super(BotFrameworkAdapter, self).__init__() self.settings = settings or BotFrameworkAdapterSettings("", "") - # If settings.certificate_thumbprint & settings.certificate_private_key are provided, - # use CertificateAppCredentials. - if self.settings.certificate_thumbprint and settings.certificate_private_key: - self._credentials = CertificateAppCredentials( - self.settings.app_id, - self.settings.certificate_thumbprint, - self.settings.certificate_private_key, - self.settings.channel_auth_tenant, - ) - self._credential_provider = SimpleCredentialProvider( - self.settings.app_id, "" - ) - else: - self._credentials = MicrosoftAppCredentials( - self.settings.app_id, - self.settings.app_password, - self.settings.channel_auth_tenant, - ) - self._credential_provider = SimpleCredentialProvider( - self.settings.app_id, self.settings.app_password - ) + self._credentials = self.settings.app_credentials + self._credential_provider = SimpleCredentialProvider( + self.settings.app_id, self.settings.app_password + ) - self._is_emulating_oauth_cards = False + self._channel_provider = self.settings.channel_provider - # If no channel_service or open_id_metadata values were passed in the settings, check the - # process' Environment Variables for values. - # These values may be set when a bot is provisioned on Azure and if so are required for - # the bot to properly work in Public Azure or a National Cloud. - self.settings.channel_service = self.settings.channel_service or os.environ.get( - AuthenticationConstants.CHANNEL_SERVICE - ) - self.settings.open_id_metadata = ( - self.settings.open_id_metadata - or os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) - ) + self._is_emulating_oauth_cards = False if self.settings.open_id_metadata: ChannelValidation.open_id_metadata_endpoint = self.settings.open_id_metadata @@ -200,22 +192,19 @@ def __init__(self, settings: BotFrameworkAdapterSettings): self.settings.open_id_metadata ) - if JwtTokenValidation.is_government(self.settings.channel_service): - self._credentials.oauth_endpoint = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL - ) - self._credentials.oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - ) - + # There is a significant boost in throughput if we reuse a ConnectorClient self._connector_client_cache: Dict[str, ConnectorClient] = {} + # Cache for appCredentials to speed up token acquisition (a token is not requested unless is expired) + self._app_credential_map: Dict[str, AppCredentials] = {} + async def continue_conversation( self, reference: ConversationReference, callback: Callable, bot_id: str = None, - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, + audience: str = None, ): """ Continues a conversation with a user. @@ -229,6 +218,8 @@ async def continue_conversation( :type bot_id: :class:`typing.str` :param claims_identity: The bot claims identity :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param audience: + :type audience: :class:`typing.str` :raises: It raises an argument null exception. @@ -239,11 +230,20 @@ async def continue_conversation( send messages to a conversation or user that are already in a communication. Scenarios such as sending notifications or coupons to a user are enabled by this function. """ - # TODO: proactive messages - if not claims_identity: - if not bot_id: - raise TypeError("Expected bot_id: str but got None instead") + if not reference: + raise TypeError( + "Expected reference: ConversationReference but got None instead" + ) + if not callback: + raise TypeError("Expected callback: Callable but got None instead") + + # This has to have either a bot_id, in which case a ClaimsIdentity will be created, or + # a ClaimsIdentity. In either case, if an audience isn't supplied one will be created. + if not (bot_id or claims_identity): + raise TypeError("Expected bot_id or claims_identity") + + if bot_id and not claims_identity: claims_identity = ClaimsIdentity( claims={ AuthenticationConstants.AUDIENCE_CLAIM: bot_id, @@ -252,12 +252,24 @@ async def continue_conversation( is_authenticated=True, ) + if not audience: + audience = self.__get_botframework_oauth_scope() + context = TurnContext(self, get_continuation_activity(reference)) - context.turn_state[BOT_IDENTITY_KEY] = claims_identity - context.turn_state["BotCallbackHandler"] = callback - await self._ensure_channel_connector_client_is_created( - reference.service_url, claims_identity + context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] = claims_identity + context.turn_state[BotFrameworkAdapter.BOT_CALLBACK_HANDLER_KEY] = callback + context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] = audience + + # Add the channel service URL to the trusted services list so we can send messages back. + # the service URL for skills is trusted because it is applied by the SkillHandler based + # on the original request received by the root bot + AppCredentials.trust_service_url(reference.service_url) + + client = await self.create_connector_client( + reference.service_url, claims_identity, audience ) + context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + return await self.run_pipeline(context, callback) async def create_conversation( @@ -265,6 +277,9 @@ async def create_conversation( reference: ConversationReference, logic: Callable[[TurnContext], Awaitable] = None, conversation_parameters: ConversationParameters = None, + channel_id: str = None, + service_url: str = None, + credentials: AppCredentials = None, ): """ Starts a new conversation with a user. Used to direct message to a member of a group. @@ -275,6 +290,12 @@ async def create_conversation( :type logic: :class:`typing.Callable` :param conversation_parameters: The information to use to create the conversation :type conversation_parameters: + :param channel_id: The ID for the channel. + :type channel_id: :class:`typing.str` + :param service_url: The channel's service URL endpoint. + :type service_url: :class:`typing.str` + :param credentials: The application credentials for the bot. + :type credentials: :class:`botframework.connector.auth.AppCredentials` :raises: It raises a generic exception error. @@ -288,15 +309,23 @@ async def create_conversation( then sends a conversation update activity through its middleware pipeline to the the callback method. If the conversation is established with the specified users, the ID of the activity - will contain the ID of the new conversation. + will contain the ID of the new conversation. """ try: - if reference.service_url is None: - raise TypeError( - "BotFrameworkAdapter.create_conversation(): reference.service_url cannot be None." - ) + if not service_url: + service_url = reference.service_url + if not service_url: + raise TypeError( + "BotFrameworkAdapter.create_conversation(): service_url or reference.service_url is required." + ) + + if not channel_id: + channel_id = reference.channel_id + if not channel_id: + raise TypeError( + "BotFrameworkAdapter.create_conversation(): channel_id or reference.channel_id is required." + ) - # Create conversation parameters = ( conversation_parameters if conversation_parameters @@ -304,12 +333,9 @@ async def create_conversation( bot=reference.bot, members=[reference.user], is_group=False ) ) - client = await self.create_connector_client(reference.service_url) - resource_response = await client.conversations.create_conversation( - parameters - ) + # Mix in the tenant ID if specified. This is required for MS Teams. - if reference.conversation is not None and reference.conversation.tenant_id: + if reference.conversation and reference.conversation.tenant_id: # Putting tenant_id in channel_data is a temporary while we wait for the Teams API to be updated parameters.channel_data = { "tenant": {"id": reference.conversation.tenant_id} @@ -318,19 +344,51 @@ async def create_conversation( # Permanent solution is to put tenant_id in parameters.tenant_id parameters.tenant_id = reference.conversation.tenant_id - request = TurnContext.apply_conversation_reference( - Activity(type=ActivityTypes.event, name="CreateConversation"), - reference, - is_incoming=True, + # This is different from C# where credentials are required in the method call. + # Doing this for compatibility. + app_credentials = ( + credentials + if credentials + else await self.__get_app_credentials( + self.settings.app_id, self.__get_botframework_oauth_scope() + ) + ) + + # Create conversation + client = self._get_or_create_connector_client(service_url, app_credentials) + + resource_response = await client.conversations.create_conversation( + parameters + ) + + event_activity = Activity( + type=ActivityTypes.event, + name="CreateConversation", + channel_id=channel_id, + service_url=service_url, + id=resource_response.activity_id + if resource_response.activity_id + else str(uuid.uuid4()), + conversation=ConversationAccount( + id=resource_response.id, tenant_id=parameters.tenant_id, + ), + channel_data=parameters.channel_data, + recipient=parameters.bot, ) - request.conversation = ConversationAccount( - id=resource_response.id, tenant_id=parameters.tenant_id + + context = self._create_context(event_activity) + context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + + claims_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: app_credentials.microsoft_app_id, + AuthenticationConstants.APP_ID_CLAIM: app_credentials.microsoft_app_id, + AuthenticationConstants.SERVICE_URL_CLAIM: service_url, + }, + is_authenticated=True, ) - request.channel_data = parameters.channel_data - if resource_response.service_url: - request.service_url = resource_response.service_url + context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] = claims_identity - context = self.create_context(request) return await self.run_pipeline(context, logic) except Exception as error: @@ -359,10 +417,30 @@ async def process_activity(self, req, auth_header: str, logic: Callable): """ activity = await self.parse_request(req) auth_header = auth_header or "" + identity = await self._authenticate_request(activity, auth_header) + return await self.process_activity_with_identity(activity, identity, logic) - identity = await self.authenticate_request(activity, auth_header) - context = self.create_context(activity) - context.turn_state[BOT_IDENTITY_KEY] = identity + async def process_activity_with_identity( + self, activity: Activity, identity: ClaimsIdentity, logic: Callable + ): + context = self._create_context(activity) + context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] = identity + context.turn_state[BotFrameworkAdapter.BOT_CALLBACK_HANDLER_KEY] = logic + + # To create the correct cache key, provide the OAuthScope when calling CreateConnectorClientAsync. + # The OAuthScope is also stored on the TurnState to get the correct AppCredentials if fetching a token + # is required. + scope = ( + self.__get_botframework_oauth_scope() + if not SkillValidation.is_skill_claim(identity.claims) + else JwtTokenValidation.get_app_id_from_claims(identity.claims) + ) + context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] = scope + + client = await self.create_connector_client( + activity.service_url, identity, scope + ) + context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] = client # Fix to assign tenant_id from channelData to Conversation.tenant_id. # MS Teams currently sends the tenant ID in channelData and the correct behavior is to expose @@ -393,7 +471,7 @@ async def process_activity(self, req, auth_header: str, logic: Callable): return None - async def authenticate_request( + async def _authenticate_request( self, request: Activity, auth_header: str ) -> ClaimsIdentity: """ @@ -412,7 +490,7 @@ async def authenticate_request( request, auth_header, self._credential_provider, - self.settings.channel_service, + await self.settings.channel_provider.get_channel_service(), self.settings.auth_configuration, ) @@ -421,7 +499,7 @@ async def authenticate_request( return claims - def create_context(self, activity): + def _create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. :param activity: @@ -495,8 +573,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): of the activity to replace. """ try: - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(activity.service_url, identity) + client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.update_activity( activity.conversation.id, activity.id, activity ) @@ -524,8 +601,7 @@ async def delete_activity( The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(reference.service_url, identity) + client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] await client.conversations.delete_activity( reference.conversation.id, reference.activity_id ) @@ -566,17 +642,19 @@ async def send_activities( "BotFrameworkAdapter.send_activity(): conversation.id can not be None." ) - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client( - activity.service_url, identity - ) if activity.type == "trace" and activity.channel_id != "emulator": pass elif activity.reply_to_id: + client = context.turn_state[ + BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + ] response = await client.conversations.reply_to_activity( activity.conversation.id, activity.reply_to_id, activity ) else: + client = context.turn_state[ + BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + ] response = await client.conversations.send_to_conversation( activity.conversation.id, activity ) @@ -617,12 +695,10 @@ async def delete_conversation_member( "BotFrameworkAdapter.delete_conversation_member(): missing conversation or " "conversation.id" ) - service_url = context.activity.service_url - conversation_id = context.activity.conversation.id - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(service_url, identity) + + client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.delete_conversation_member( - conversation_id, member_id + context.activity.conversation.id, member_id ) except AttributeError as attr_e: raise attr_e @@ -661,12 +737,10 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): "BotFrameworkAdapter.get_activity_member(): missing both activity_id and " "context.activity.id" ) - service_url = context.activity.service_url - conversation_id = context.activity.conversation.id - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(service_url, identity) + + client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.get_activity_members( - conversation_id, activity_id + context.activity.conversation.id, activity_id ) except Exception as error: raise error @@ -695,15 +769,20 @@ async def get_conversation_members(self, context: TurnContext): "BotFrameworkAdapter.get_conversation_members(): missing conversation or " "conversation.id" ) - service_url = context.activity.service_url - conversation_id = context.activity.conversation.id - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(service_url, identity) - return await client.conversations.get_conversation_members(conversation_id) + + client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] + return await client.conversations.get_conversation_members( + context.activity.conversation.id + ) except Exception as error: raise error - async def get_conversations(self, service_url: str, continuation_token: str = None): + async def get_conversations( + self, + service_url: str, + credentials: AppCredentials, + continuation_token: str = None, + ): """ Lists the Conversations in which this bot has participated for a given channel server. @@ -725,7 +804,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. """ - client = await self.create_connector_client(service_url) + client = self._get_or_create_connector_client(service_url, credentials) return await client.conversations.get_conversations(continuation_token) async def get_user_token( @@ -928,83 +1007,107 @@ async def get_aad_tokens( ) async def create_connector_client( - self, service_url: str, identity: ClaimsIdentity = None + self, service_url: str, identity: ClaimsIdentity = None, audience: str = None ) -> ConnectorClient: - """Allows for mocking of the connector client in unit tests + """ + Creates the connector client :param service_url: The service URL :param identity: The claims identity + :param audience: :return: An instance of the :class:`ConnectorClient` class """ + if not identity: + # This is different from C# where an exception is raised. In this case + # we are creating a ClaimsIdentity to retain compatibility with this + # method. + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: self.settings.app_id, + AuthenticationConstants.APP_ID_CLAIM: self.settings.app_id, + }, + is_authenticated=True, + ) + + # For requests from channel App Id is in Audience claim of JWT token. For emulator it is in AppId claim. + # For unauthenticated requests we have anonymous claimsIdentity provided auth is disabled. + # For Activities coming from Emulator AppId claim contains the Bot's AAD AppId. + bot_app_id = identity.claims.get( + AuthenticationConstants.AUDIENCE_CLAIM + ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) + # Anonymous claims and non-skill claims should fall through without modifying the scope. - credentials = self._credentials - - if identity: - bot_app_id_claim = identity.claims.get( - AuthenticationConstants.AUDIENCE_CLAIM - ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) - - if bot_app_id_claim and SkillValidation.is_skill_claim(identity.claims): - scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) - - # Do nothing, if the current credentials and its scope are valid for the skill. - # i.e. the adapter instance is pre-configured to talk with one skill. - # Otherwise we will create a new instance of the AppCredentials - # so self._credentials.oauth_scope isn't overridden. - if self._credentials.oauth_scope != scope: - password = await self._credential_provider.get_app_password( - bot_app_id_claim - ) - credentials = MicrosoftAppCredentials( - bot_app_id_claim, password, oauth_scope=scope - ) - if ( - self.settings.channel_provider - and self.settings.channel_provider.is_government() - ): - credentials.oauth_endpoint = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL - ) - credentials.oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - ) + credentials = None + if bot_app_id: + scope = audience + if not scope: + scope = ( + JwtTokenValidation.get_app_id_from_claims(identity.claims) + if SkillValidation.is_skill_claim(identity.claims) + else self.__get_botframework_oauth_scope() + ) + + credentials = await self.__get_app_credentials(bot_app_id, scope) - client_key = ( - f"{service_url}{credentials.microsoft_app_id if credentials else ''}" + return self._get_or_create_connector_client(service_url, credentials) + + def _get_or_create_connector_client( + self, service_url: str, credentials: AppCredentials + ) -> ConnectorClient: + # Get ConnectorClient from cache or create. + client_key = BotFrameworkAdapter.key_for_connector_client( + service_url, credentials.microsoft_app_id, credentials.oauth_scope ) client = self._connector_client_cache.get(client_key) - if not client: + if not credentials: + credentials = MicrosoftAppCredentials.empty() + client = ConnectorClient(credentials, base_url=service_url) client.config.add_user_agent(USER_AGENT) self._connector_client_cache[client_key] = client return client - def _create_token_api_client( - self, - url_or_context: Union[TurnContext, str], - oauth_app_credentials: AppCredentials = None, + @staticmethod + def key_for_connector_client(service_url: str, app_id: str, scope: str): + return f"{service_url}:{app_id}:{scope}" + + async def _create_token_api_client( + self, context: TurnContext, oauth_app_credentials: AppCredentials = None, ) -> TokenApiClient: - if isinstance(url_or_context, str): - app_credentials = ( - oauth_app_credentials if oauth_app_credentials else self._credentials - ) - client = TokenApiClient(app_credentials, url_or_context) - client.config.add_user_agent(USER_AGENT) - return client + if ( + not self._is_emulating_oauth_cards + and context.activity.channel_id == "emulator" + and await self._credential_provider.is_authentication_disabled() + ): + self._is_emulating_oauth_cards = True - self.__check_emulating_oauth_cards(url_or_context) - url = self.__oauth_api_url(url_or_context) - return self._create_token_api_client(url) + app_id = self.__get_app_id(context) + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + app_credentials = oauth_app_credentials or await self.__get_app_credentials( + app_id, scope + ) - async def __emulate_oauth_cards( - self, context_or_service_url: Union[TurnContext, str], emulate: bool - ): - self._is_emulating_oauth_cards = emulate - url = self.__oauth_api_url(context_or_service_url) - await EmulatorApiClient.emulate_oauth_cards(self._credentials, url, emulate) + if ( + not self._is_emulating_oauth_cards + and context.activity.channel_id == "emulator" + and await self._credential_provider.is_authentication_disabled() + ): + self._is_emulating_oauth_cards = True + + # TODO: token_api_client cache + + url = self.__oauth_api_url(context) + client = TokenApiClient(app_credentials, url) + client.config.add_user_agent(USER_AGENT) + + if self._is_emulating_oauth_cards: + # intentionally not awaiting this call + EmulatorApiClient.emulate_oauth_cards(app_credentials, url, True) + + return client def __oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: url = None @@ -1020,47 +1123,76 @@ def __oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> st else: url = ( US_GOV_OAUTH_ENDPOINT - if JwtTokenValidation.is_government(self.settings.channel_service) + if self.settings.channel_provider.is_government() else OAUTH_ENDPOINT ) return url - def __check_emulating_oauth_cards(self, context: TurnContext): - if ( - not self._is_emulating_oauth_cards - and context.activity.channel_id == "emulator" - and ( - not self._credentials.microsoft_app_id - or not self._credentials.microsoft_app_password + @staticmethod + def key_for_app_credentials(app_id: str, scope: str): + return f"{app_id}:{scope}" + + async def __get_app_credentials( + self, app_id: str, oauth_scope: str + ) -> AppCredentials: + if not app_id: + return MicrosoftAppCredentials.empty() + + # get from the cache if it's there + cache_key = BotFrameworkAdapter.key_for_app_credentials(app_id, oauth_scope) + app_credentials = self._app_credential_map.get(cache_key) + if app_credentials: + return app_credentials + + # If app credentials were provided, use them as they are the preferred choice moving forward + if self._credentials: + self._app_credential_map[cache_key] = self._credentials + return self._credentials + + # Credentials not found in cache, build them + app_credentials = await self.__build_credentials(app_id, oauth_scope) + + # Cache the credentials for later use + self._app_credential_map[cache_key] = app_credentials + + return app_credentials + + async def __build_credentials( + self, app_id: str, oauth_scope: str = None + ) -> AppCredentials: + app_password = await self._credential_provider.get_app_password(app_id) + + if self._channel_provider.is_government(): + return MicrosoftGovernmentAppCredentials( + app_id, + app_password, + self.settings.channel_auth_tenant, + scope=oauth_scope, ) - ): - self._is_emulating_oauth_cards = True - async def _ensure_channel_connector_client_is_created( - self, service_url: str, claims_identity: ClaimsIdentity - ): - # Ensure we have a default ConnectorClient and MSAppCredentials instance for the audience. - audience = claims_identity.claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + return MicrosoftAppCredentials( + app_id, + app_password, + self.settings.channel_auth_tenant, + oauth_scope=oauth_scope, + ) + def __get_botframework_oauth_scope(self) -> str: if ( - not audience - or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER != audience + self.settings.channel_provider + and self.settings.channel_provider.is_government() ): - # We create a default connector for audiences that are not coming from - # the default https://api.botframework.com audience. - # We create a default claim that contains only the desired audience. - default_connector_claims = { - AuthenticationConstants.AUDIENCE_CLAIM: audience - } - connector_claims_identity = ClaimsIdentity( - claims=default_connector_claims, is_authenticated=True - ) + return GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + return AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + + def __get_app_id(self, context: TurnContext) -> str: + identity = context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] + if not identity: + raise Exception("An IIdentity is required in TurnState for this operation.") - await self.create_connector_client(service_url, connector_claims_identity) + app_id = identity.claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + if not app_id: + raise Exception("Unable to get the bot AppId from the audience claim.") - if SkillValidation.is_skill_claim(claims_identity.claims): - # Add the channel service URL to the trusted services list so we can send messages back. - # the service URL for skills is trusted because it is applied by the - # SkillHandler based on the original request received by the root bot - MicrosoftAppCredentials.trust_service_url(service_url) + return app_id diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 27a68c1f2..41c3e5439 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -35,7 +35,7 @@ def create_conversation(): class TestTeamsActivityHandler(TeamsActivityHandler): async def on_turn(self, turn_context: TurnContext): - super().on_turn(turn_context) + await super().on_turn(turn_context) if turn_context.activity.text == "test_send_message_to_teams_channel": await self.call_send_message_to_teams(turn_context) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 528bbf719..e53148d94 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -1,293 +1,574 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from copy import copy, deepcopy -from unittest.mock import Mock -import unittest -import uuid -import aiounittest - -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - TurnContext, -) -from botbuilder.schema import ( - Activity, - ActivityTypes, - ConversationAccount, - ConversationReference, - ConversationResourceResponse, - ChannelAccount, -) -from botframework.connector.aio import ConnectorClient -from botframework.connector.auth import ClaimsIdentity - -REFERENCE = ConversationReference( - activity_id="1234", - channel_id="test", - service_url="https://example.org/channel", - user=ChannelAccount(id="user", name="User Name"), - bot=ChannelAccount(id="bot", name="Bot Name"), - conversation=ConversationAccount(id="convo1"), -) - -TEST_ACTIVITY = Activity(text="test", type=ActivityTypes.message) - -INCOMING_MESSAGE = TurnContext.apply_conversation_reference( - copy(TEST_ACTIVITY), REFERENCE, True -) -OUTGOING_MESSAGE = TurnContext.apply_conversation_reference( - copy(TEST_ACTIVITY), REFERENCE -) -INCOMING_INVOKE = TurnContext.apply_conversation_reference( - Activity(type=ActivityTypes.invoke), REFERENCE, True -) - - -class AdapterUnderTest(BotFrameworkAdapter): - def __init__(self, settings=None): - super().__init__(settings) - self.tester = aiounittest.AsyncTestCase() - self.fail_auth = False - self.fail_operation = False - self.expect_auth_header = "" - self.new_service_url = None - - def aux_test_authenticate_request(self, request: Activity, auth_header: str): - return super().authenticate_request(request, auth_header) - - async def aux_test_create_connector_client(self, service_url: str): - return await super().create_connector_client(service_url) - - async def authenticate_request(self, request: Activity, auth_header: str): - self.tester.assertIsNotNone( - request, "authenticate_request() not passed request." - ) - self.tester.assertEqual( - auth_header, - self.expect_auth_header, - "authenticateRequest() not passed expected authHeader.", - ) - return not self.fail_auth - - async def create_connector_client( - self, - service_url: str, - identity: ClaimsIdentity = None, # pylint: disable=unused-argument - ) -> ConnectorClient: - self.tester.assertIsNotNone( - service_url, "create_connector_client() not passed service_url." - ) - connector_client_mock = Mock() - - async def mock_reply_to_activity(conversation_id, activity_id, activity): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "reply_to_activity not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity_id, "reply_to_activity not passed activity_id" - ) - self.tester.assertIsNotNone( - activity, "reply_to_activity not passed activity" - ) - return not self.fail_auth - - async def mock_send_to_conversation(conversation_id, activity): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "send_to_conversation not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity, "send_to_conversation not passed activity" - ) - return not self.fail_auth - - async def mock_update_activity(conversation_id, activity_id, activity): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "update_activity not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity_id, "update_activity not passed activity_id" - ) - self.tester.assertIsNotNone(activity, "update_activity not passed activity") - return not self.fail_auth - - async def mock_delete_activity(conversation_id, activity_id): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "delete_activity not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity_id, "delete_activity not passed activity_id" - ) - return not self.fail_auth - - async def mock_create_conversation(parameters): - nonlocal self - self.tester.assertIsNotNone( - parameters, "create_conversation not passed parameters" - ) - response = ConversationResourceResponse( - activity_id=REFERENCE.activity_id, - service_url=REFERENCE.service_url, - id=uuid.uuid4(), - ) - return response - - connector_client_mock.conversations.reply_to_activity.side_effect = ( - mock_reply_to_activity - ) - connector_client_mock.conversations.send_to_conversation.side_effect = ( - mock_send_to_conversation - ) - connector_client_mock.conversations.update_activity.side_effect = ( - mock_update_activity - ) - connector_client_mock.conversations.delete_activity.side_effect = ( - mock_delete_activity - ) - connector_client_mock.conversations.create_conversation.side_effect = ( - mock_create_conversation - ) - - return connector_client_mock - - -async def process_activity( - channel_id: str, channel_data_tenant_id: str, conversation_tenant_id: str -): - activity = None - mock_claims = unittest.mock.create_autospec(ClaimsIdentity) - mock_credential_provider = unittest.mock.create_autospec( - BotFrameworkAdapterSettings - ) - - sut = BotFrameworkAdapter(mock_credential_provider) - - async def aux_func(context): - nonlocal activity - activity = context.Activity - - await sut.process_activity( - Activity( - channel_id=channel_id, - service_url="https://smba.trafficmanager.net/amer/", - channel_data={"tenant": {"id": channel_data_tenant_id}}, - conversation=ConversationAccount(tenant_id=conversation_tenant_id), - ), - mock_claims, - aux_func, - ) - return activity - - -class TestBotFrameworkAdapter(aiounittest.AsyncTestCase): - async def test_should_create_connector_client(self): - adapter = AdapterUnderTest() - client = await adapter.aux_test_create_connector_client(REFERENCE.service_url) - self.assertIsNotNone(client, "client not returned.") - self.assertIsNotNone(client.conversations, "invalid client returned.") - - async def test_should_process_activity(self): - called = False - adapter = AdapterUnderTest() - - async def aux_func_assert_context(context): - self.assertIsNotNone(context, "context not passed.") - nonlocal called - called = True - - await adapter.process_activity(INCOMING_MESSAGE, "", aux_func_assert_context) - self.assertTrue(called, "bot logic not called.") - - async def test_should_update_activity(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - self.assertTrue( - await adapter.update_activity(context, INCOMING_MESSAGE), - "Activity not updated.", - ) - - async def test_should_fail_to_update_activity_if_service_url_missing(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - cpy = deepcopy(INCOMING_MESSAGE) - cpy.service_url = None - with self.assertRaises(Exception) as _: - await adapter.update_activity(context, cpy) - - async def test_should_fail_to_update_activity_if_conversation_missing(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - cpy = deepcopy(INCOMING_MESSAGE) - cpy.conversation = None - with self.assertRaises(Exception) as _: - await adapter.update_activity(context, cpy) - - async def test_should_fail_to_update_activity_if_activity_id_missing(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - cpy = deepcopy(INCOMING_MESSAGE) - cpy.id = None - with self.assertRaises(Exception) as _: - await adapter.update_activity(context, cpy) - - async def test_should_migrate_tenant_id_for_msteams(self): - incoming = TurnContext.apply_conversation_reference( - activity=Activity( - type=ActivityTypes.message, - text="foo", - channel_data={"tenant": {"id": "1234"}}, - ), - reference=REFERENCE, - is_incoming=True, - ) - - incoming.channel_id = "msteams" - adapter = AdapterUnderTest() - - async def aux_func_assert_tenant_id_copied(context): - self.assertEqual( - context.activity.conversation.tenant_id, - "1234", - "should have copied tenant id from " - "channel_data to conversation address", - ) - - await adapter.process_activity(incoming, "", aux_func_assert_tenant_id_copied) - - async def test_should_create_valid_conversation_for_msteams(self): - - tenant_id = "testTenant" - - reference = deepcopy(REFERENCE) - reference.conversation.tenant_id = tenant_id - reference.channel_data = {"tenant": {"id": tenant_id}} - adapter = AdapterUnderTest() - - called = False - - async def aux_func_assert_valid_conversation(context): - self.assertIsNotNone(context, "context not passed") - self.assertIsNotNone(context.activity, "context has no request") - self.assertIsNotNone( - context.activity.conversation, "request has invalid conversation" - ) - self.assertEqual( - context.activity.conversation.tenant_id, - tenant_id, - "request has invalid tenant_id on conversation", - ) - self.assertEqual( - context.activity.channel_data["tenant"]["id"], - tenant_id, - "request has invalid tenant_id in channel_data", - ) - nonlocal called - called = True - - await adapter.create_conversation(reference, aux_func_assert_valid_conversation) - self.assertTrue(called, "bot logic not called.") +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import copy, deepcopy +from unittest.mock import Mock +import unittest +import uuid +import aiounittest + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + TurnContext, +) +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationAccount, + ConversationReference, + ConversationResourceResponse, + ChannelAccount, +) +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import ( + ClaimsIdentity, + AuthenticationConstants, + AppCredentials, + CredentialProvider, +) + +REFERENCE = ConversationReference( + activity_id="1234", + channel_id="test", + service_url="https://example.org/channel", + user=ChannelAccount(id="user", name="User Name"), + bot=ChannelAccount(id="bot", name="Bot Name"), + conversation=ConversationAccount(id="convo1"), +) + +TEST_ACTIVITY = Activity(text="test", type=ActivityTypes.message) + +INCOMING_MESSAGE = TurnContext.apply_conversation_reference( + copy(TEST_ACTIVITY), REFERENCE, True +) +OUTGOING_MESSAGE = TurnContext.apply_conversation_reference( + copy(TEST_ACTIVITY), REFERENCE +) +INCOMING_INVOKE = TurnContext.apply_conversation_reference( + Activity(type=ActivityTypes.invoke), REFERENCE, True +) + + +class AdapterUnderTest(BotFrameworkAdapter): + def __init__(self, settings=None): + super().__init__(settings) + self.tester = aiounittest.AsyncTestCase() + self.fail_auth = False + self.fail_operation = False + self.expect_auth_header = "" + self.new_service_url = None + + def aux_test_authenticate_request(self, request: Activity, auth_header: str): + return super()._authenticate_request(request, auth_header) + + async def aux_test_create_connector_client(self, service_url: str): + return await super().create_connector_client(service_url) + + async def _authenticate_request( + self, request: Activity, auth_header: str + ) -> ClaimsIdentity: + self.tester.assertIsNotNone( + request, "authenticate_request() not passed request." + ) + self.tester.assertEqual( + auth_header, + self.expect_auth_header, + "authenticateRequest() not passed expected authHeader.", + ) + + if self.fail_auth: + raise PermissionError("Unauthorized Access. Request is not authorized") + + return ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: self.settings.app_id, + AuthenticationConstants.APP_ID_CLAIM: self.settings.app_id, + }, + is_authenticated=True, + ) + + async def create_connector_client( + self, + service_url: str, + identity: ClaimsIdentity = None, # pylint: disable=unused-argument + audience: str = None, # pylint: disable=unused-argument + ) -> ConnectorClient: + return self._get_or_create_connector_client(service_url, None) + + def _get_or_create_connector_client( + self, service_url: str, credentials: AppCredentials + ) -> ConnectorClient: + self.tester.assertIsNotNone( + service_url, "create_connector_client() not passed service_url." + ) + connector_client_mock = Mock() + + async def mock_reply_to_activity(conversation_id, activity_id, activity): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "reply_to_activity not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity_id, "reply_to_activity not passed activity_id" + ) + self.tester.assertIsNotNone( + activity, "reply_to_activity not passed activity" + ) + return not self.fail_auth + + async def mock_send_to_conversation(conversation_id, activity): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "send_to_conversation not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity, "send_to_conversation not passed activity" + ) + return not self.fail_auth + + async def mock_update_activity(conversation_id, activity_id, activity): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "update_activity not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity_id, "update_activity not passed activity_id" + ) + self.tester.assertIsNotNone(activity, "update_activity not passed activity") + return not self.fail_auth + + async def mock_delete_activity(conversation_id, activity_id): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "delete_activity not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity_id, "delete_activity not passed activity_id" + ) + return not self.fail_auth + + async def mock_create_conversation(parameters): + nonlocal self + self.tester.assertIsNotNone( + parameters, "create_conversation not passed parameters" + ) + response = ConversationResourceResponse( + activity_id=REFERENCE.activity_id, + service_url=REFERENCE.service_url, + id=uuid.uuid4(), + ) + return response + + connector_client_mock.conversations.reply_to_activity.side_effect = ( + mock_reply_to_activity + ) + connector_client_mock.conversations.send_to_conversation.side_effect = ( + mock_send_to_conversation + ) + connector_client_mock.conversations.update_activity.side_effect = ( + mock_update_activity + ) + connector_client_mock.conversations.delete_activity.side_effect = ( + mock_delete_activity + ) + connector_client_mock.conversations.create_conversation.side_effect = ( + mock_create_conversation + ) + + return connector_client_mock + + +async def process_activity( + channel_id: str, channel_data_tenant_id: str, conversation_tenant_id: str +): + activity = None + mock_claims = unittest.mock.create_autospec(ClaimsIdentity) + mock_credential_provider = unittest.mock.create_autospec( + BotFrameworkAdapterSettings + ) + + sut = BotFrameworkAdapter(mock_credential_provider) + + async def aux_func(context): + nonlocal activity + activity = context.Activity + + await sut.process_activity( + Activity( + channel_id=channel_id, + service_url="https://smba.trafficmanager.net/amer/", + channel_data={"tenant": {"id": channel_data_tenant_id}}, + conversation=ConversationAccount(tenant_id=conversation_tenant_id), + ), + mock_claims, + aux_func, + ) + return activity + + +class TestBotFrameworkAdapter(aiounittest.AsyncTestCase): + async def test_should_create_connector_client(self): + adapter = AdapterUnderTest() + client = await adapter.aux_test_create_connector_client(REFERENCE.service_url) + self.assertIsNotNone(client, "client not returned.") + self.assertIsNotNone(client.conversations, "invalid client returned.") + + async def test_should_process_activity(self): + called = False + adapter = AdapterUnderTest() + + async def aux_func_assert_context(context): + self.assertIsNotNone(context, "context not passed.") + nonlocal called + called = True + + await adapter.process_activity(INCOMING_MESSAGE, "", aux_func_assert_context) + self.assertTrue(called, "bot logic not called.") + + async def test_should_fail_to_update_activity_if_service_url_missing(self): + adapter = AdapterUnderTest() + context = TurnContext(adapter, INCOMING_MESSAGE) + cpy = deepcopy(INCOMING_MESSAGE) + cpy.service_url = None + with self.assertRaises(Exception) as _: + await adapter.update_activity(context, cpy) + + async def test_should_fail_to_update_activity_if_conversation_missing(self): + adapter = AdapterUnderTest() + context = TurnContext(adapter, INCOMING_MESSAGE) + cpy = deepcopy(INCOMING_MESSAGE) + cpy.conversation = None + with self.assertRaises(Exception) as _: + await adapter.update_activity(context, cpy) + + async def test_should_fail_to_update_activity_if_activity_id_missing(self): + adapter = AdapterUnderTest() + context = TurnContext(adapter, INCOMING_MESSAGE) + cpy = deepcopy(INCOMING_MESSAGE) + cpy.id = None + with self.assertRaises(Exception) as _: + await adapter.update_activity(context, cpy) + + async def test_should_migrate_tenant_id_for_msteams(self): + incoming = TurnContext.apply_conversation_reference( + activity=Activity( + type=ActivityTypes.message, + text="foo", + channel_data={"tenant": {"id": "1234"}}, + ), + reference=REFERENCE, + is_incoming=True, + ) + + incoming.channel_id = "msteams" + adapter = AdapterUnderTest() + + async def aux_func_assert_tenant_id_copied(context): + self.assertEqual( + context.activity.conversation.tenant_id, + "1234", + "should have copied tenant id from " + "channel_data to conversation address", + ) + + await adapter.process_activity(incoming, "", aux_func_assert_tenant_id_copied) + + async def test_should_create_valid_conversation_for_msteams(self): + + tenant_id = "testTenant" + + reference = deepcopy(REFERENCE) + reference.conversation.tenant_id = tenant_id + reference.channel_data = {"tenant": {"id": tenant_id}} + adapter = AdapterUnderTest() + + called = False + + async def aux_func_assert_valid_conversation(context): + self.assertIsNotNone(context, "context not passed") + self.assertIsNotNone(context.activity, "context has no request") + self.assertIsNotNone( + context.activity.conversation, "request has invalid conversation" + ) + self.assertEqual( + context.activity.conversation.tenant_id, + tenant_id, + "request has invalid tenant_id on conversation", + ) + self.assertEqual( + context.activity.channel_data["tenant"]["id"], + tenant_id, + "request has invalid tenant_id in channel_data", + ) + nonlocal called + called = True + + await adapter.create_conversation(reference, aux_func_assert_valid_conversation) + self.assertTrue(called, "bot logic not called.") + + @staticmethod + def get_creds_and_assert_values( + turn_context: TurnContext, + expected_app_id: str, + expected_scope: str, + creds_count: int = None, + ): + # pylint: disable=protected-access + credential_cache = turn_context.adapter._app_credential_map + cache_key = BotFrameworkAdapter.key_for_app_credentials( + expected_app_id, expected_scope + ) + credentials = credential_cache.get(cache_key) + assert credentials + + TestBotFrameworkAdapter.assert_credentials_values( + credentials, expected_app_id, expected_scope + ) + + if creds_count: + assert creds_count == len(credential_cache) + + @staticmethod + def get_client_and_assert_values( + turn_context: TurnContext, + expected_app_id: str, + expected_scope: str, + expected_url: str, + client_count: int = None, + ): + # pylint: disable=protected-access + client_cache = turn_context.adapter._connector_client_cache + cache_key = BotFrameworkAdapter.key_for_connector_client( + expected_url, expected_app_id, expected_scope + ) + client = client_cache[cache_key] + assert client + + TestBotFrameworkAdapter.assert_connectorclient_vaules( + client, expected_app_id, expected_url, expected_scope + ) + + if client_count: + assert client_count == len(client_cache) + + @staticmethod + def assert_connectorclient_vaules( + client: ConnectorClient, + expected_app_id, + expected_service_url: str, + expected_scope=AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ): + creds = client.config.credentials + assert expected_app_id == creds.microsoft_app_id + assert expected_scope == creds.oauth_scope + assert expected_service_url == client.config.base_url + + @staticmethod + def assert_credentials_values( + credentials: AppCredentials, + expected_app_id: str, + expected_scope: str = AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ): + assert expected_app_id == credentials.microsoft_app_id + assert expected_scope == credentials.oauth_scope + + async def test_process_activity_creates_correct_creds_and_client(self): + bot_app_id = "00000000-0000-0000-0000-000000000001" + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: bot_app_id, + AuthenticationConstants.APP_ID_CLAIM: bot_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + service_url = "https://smba.trafficmanager.net/amer/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, + bot_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, + bot_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + service_url, + 1, + ) + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope + + settings = BotFrameworkAdapterSettings(bot_app_id) + sut = BotFrameworkAdapter(settings) + await sut.process_activity_with_identity( + Activity(channel_id="emulator", service_url=service_url, text="test",), + identity, + callback, + ) + + async def test_process_activity_for_forwarded_activity(self): + bot_app_id = "00000000-0000-0000-0000-000000000001" + skill_1_app_id = "00000000-0000-0000-0000-000000skill1" + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: skill_1_app_id, + AuthenticationConstants.APP_ID_CLAIM: bot_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + service_url = "https://root-bot.test.azurewebsites.net/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, skill_1_app_id, bot_app_id, 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, skill_1_app_id, bot_app_id, service_url, 1, + ) + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert bot_app_id == scope + + settings = BotFrameworkAdapterSettings(bot_app_id) + sut = BotFrameworkAdapter(settings) + await sut.process_activity_with_identity( + Activity(channel_id="emulator", service_url=service_url, text="test",), + identity, + callback, + ) + + async def test_continue_conversation_without_audience(self): + mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) + + settings = BotFrameworkAdapterSettings( + app_id="bot_id", credential_provider=mock_credential_provider + ) + adapter = BotFrameworkAdapter(settings) + + skill_1_app_id = "00000000-0000-0000-0000-000000skill1" + skill_2_app_id = "00000000-0000-0000-0000-000000skill2" + + skills_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: skill_1_app_id, + AuthenticationConstants.APP_ID_CLAIM: skill_2_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + channel_service_url = "https://smba.trafficmanager.net/amer/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, + skill_1_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, + skill_1_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + channel_service_url, + 1, + ) + + # pylint: disable=protected-access + client_cache = context.adapter._connector_client_cache + client = client_cache.get( + BotFrameworkAdapter.key_for_connector_client( + channel_service_url, + skill_1_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ) + ) + assert client + + turn_state_client = context.turn_state.get( + BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + ) + assert turn_state_client + client_creds = turn_state_client.config.credentials + + assert skill_1_app_id == client_creds.microsoft_app_id + assert ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + == client_creds.oauth_scope + ) + assert client.config.base_url == turn_state_client.config.base_url + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope + + refs = ConversationReference(service_url=channel_service_url) + + await adapter.continue_conversation( + refs, callback, claims_identity=skills_identity + ) + + async def test_continue_conversation_with_audience(self): + mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) + + settings = BotFrameworkAdapterSettings( + app_id="bot_id", credential_provider=mock_credential_provider + ) + adapter = BotFrameworkAdapter(settings) + + skill_1_app_id = "00000000-0000-0000-0000-000000skill1" + skill_2_app_id = "00000000-0000-0000-0000-000000skill2" + + skills_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: skill_1_app_id, + AuthenticationConstants.APP_ID_CLAIM: skill_2_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + skill_2_service_url = "https://skill2.com/api/skills/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, skill_1_app_id, skill_2_app_id, 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, skill_1_app_id, skill_2_app_id, skill_2_service_url, 1, + ) + + # pylint: disable=protected-access + client_cache = context.adapter._connector_client_cache + client = client_cache.get( + BotFrameworkAdapter.key_for_connector_client( + skill_2_service_url, skill_1_app_id, skill_2_app_id, + ) + ) + assert client + + turn_state_client = context.turn_state.get( + BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + ) + assert turn_state_client + client_creds = turn_state_client.config.credentials + + assert skill_1_app_id == client_creds.microsoft_app_id + assert skill_2_app_id == client_creds.oauth_scope + assert client.config.base_url == turn_state_client.config.base_url + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert skill_2_app_id == scope + + refs = ConversationReference(service_url=skill_2_service_url) + + await adapter.continue_conversation( + refs, callback, claims_identity=skills_identity, audience=skill_2_app_id + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index bc97e67dc..6b9b6d925 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -14,6 +14,7 @@ from .channel_provider import * from .simple_channel_provider import * from .microsoft_app_credentials import * +from .microsoft_government_app_credentials import * from .certificate_app_credentials import * from .claims_identity import * from .jwt_token_validation import * diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index 70bfba050..2d21c4af1 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -174,7 +174,7 @@ def is_government(channel_service: str) -> bool: ) @staticmethod - def get_app_id_from_claims(claims: Dict[str, object]) -> bool: + def get_app_id_from_claims(claims: Dict[str, object]) -> str: app_id = None # Depending on Version, the is either in the diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 35fa21566..7ec9312fa 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -35,6 +35,10 @@ def __init__( self.app = None self.scopes = [self.oauth_scope] + @staticmethod + def empty(): + return MicrosoftAppCredentials("", "") + def get_access_token(self, force_refresh: bool = False) -> str: """ Implementation of AppCredentials.get_token. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py new file mode 100644 index 000000000..17403b414 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botframework.connector.auth import MicrosoftAppCredentials, GovernmentConstants + + +class MicrosoftGovernmentAppCredentials(MicrosoftAppCredentials): + """ + MicrosoftGovernmentAppCredentials auth implementation. + """ + + def __init__( + self, + app_id: str, + app_password: str, + channel_auth_tenant: str = None, + scope: str = GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ): + super().__init__(app_id, app_password, channel_auth_tenant, scope) + self.oauth_endpoint = GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + + @staticmethod + def empty(): + return MicrosoftGovernmentAppCredentials("", "") diff --git a/libraries/botframework-connector/botframework/connector/emulator_api_client.py b/libraries/botframework-connector/botframework/connector/emulator_api_client.py index 6012456ca..ad83f96f7 100644 --- a/libraries/botframework-connector/botframework/connector/emulator_api_client.py +++ b/libraries/botframework-connector/botframework/connector/emulator_api_client.py @@ -2,13 +2,13 @@ # Licensed under the MIT License. import requests -from .auth import MicrosoftAppCredentials +from .auth import AppCredentials class EmulatorApiClient: @staticmethod async def emulate_oauth_cards( - credentials: MicrosoftAppCredentials, emulator_url: str, emulate: bool + credentials: AppCredentials, emulator_url: str, emulate: bool ) -> bool: token = await credentials.get_token() request_url = ( From cca85537b9bd4710cc020a5c9b68be397709c94a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 28 Feb 2020 11:33:20 -0600 Subject: [PATCH 313/616] Updated SlackAdaptor and TestAdapter continue_conversation --- .../botbuilder/adapters/slack/slack_adapter.py | 5 +++-- .../botbuilder-core/botbuilder/core/adapters/test_adapter.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 93fac05b9..c24e4b904 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -130,8 +130,9 @@ async def continue_conversation( self, reference: ConversationReference, callback: Callable, - bot_id: str = None, # pylint: disable=unused-argument - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + bot_id: str = None, + claims_identity: ClaimsIdentity = None, + audience: str = None, ): """ Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 595871846..7df9c2506 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -164,7 +164,8 @@ async def continue_conversation( reference: ConversationReference, callback: Callable, bot_id: str = None, - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, + audience: str = None, ): """ The `TestAdapter` just calls parent implementation. @@ -175,7 +176,7 @@ async def continue_conversation( :return: """ await super().continue_conversation( - reference, callback, bot_id, claims_identity + reference, callback, bot_id, claims_identity, audience ) async def receive_activity(self, activity): From 4ddc647892736424fe8fc85f1108f9ed8dfff87d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 07:08:41 -0600 Subject: [PATCH 314/616] Handing None for AppCredentials in _get_or_create_connector_client --- .../botbuilder/core/bot_framework_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 62819c09e..c188215a0 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -1055,15 +1055,15 @@ async def create_connector_client( def _get_or_create_connector_client( self, service_url: str, credentials: AppCredentials ) -> ConnectorClient: + if not credentials: + credentials = MicrosoftAppCredentials.empty() + # Get ConnectorClient from cache or create. client_key = BotFrameworkAdapter.key_for_connector_client( service_url, credentials.microsoft_app_id, credentials.oauth_scope ) client = self._connector_client_cache.get(client_key) if not client: - if not credentials: - credentials = MicrosoftAppCredentials.empty() - client = ConnectorClient(credentials, base_url=service_url) client.config.add_user_agent(USER_AGENT) self._connector_client_cache[client_key] = client From f19778d36004e4a5f169b5c9297fc54fe442f5f6 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 11:28:51 -0600 Subject: [PATCH 315/616] Adding default scope if missing to make MSAL happy --- .../botframework/connector/auth/microsoft_app_credentials.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 7ec9312fa..5091ecc91 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -33,6 +33,9 @@ def __init__( self.microsoft_app_password = password self.app = None + + if self.oauth_scope and not self.oauth_scope.endswith("/.default"): + self.oauth_scope += "/.default" self.scopes = [self.oauth_scope] @staticmethod From 8baad85f5711a82bd65f67ed1ebbaa66fcb77ae9 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 11:36:26 -0600 Subject: [PATCH 316/616] Not changing passed oauth_scope when auto-appending .default --- .../connector/auth/microsoft_app_credentials.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 5091ecc91..d625d6ede 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -34,9 +34,12 @@ def __init__( self.microsoft_app_password = password self.app = None - if self.oauth_scope and not self.oauth_scope.endswith("/.default"): - self.oauth_scope += "/.default" - self.scopes = [self.oauth_scope] + # This check likely needs to be more nuanced than this. Assuming + # "/.default" precludes other valid suffixes + scope = self.oauth_scope + if oauth_scope and not scope.endswith("/.default"): + scope += "/.default" + self.scopes = [scope] @staticmethod def empty(): From c3ddbd92bdba57e3bc938fc16db9dfdb028df88f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 12:46:07 -0600 Subject: [PATCH 317/616] Moved BotFramework TurnState keys to BotAdapter to more closely match C# --- .../botbuilder/core/bot_adapter.py | 5 +++ .../botbuilder/core/bot_framework_adapter.py | 42 +++++++++---------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index ac18666a1..ca9a649bc 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -13,6 +13,11 @@ class BotAdapter(ABC): + BOT_IDENTITY_KEY = "BotIdentity" + BOT_OAUTH_SCOPE_KEY = "OAuthScope" + BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" + BOT_CALLBACK_HANDLER_KEY = "BotCallbackHandler" + def __init__( self, on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None ): diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index c188215a0..288ccb67a 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -162,10 +162,6 @@ class BotFrameworkAdapter(BotAdapter, UserTokenProvider): """ _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" - BOT_IDENTITY_KEY = "BotIdentity" - BOT_OAUTH_SCOPE_KEY = "OAuthScope" - BOT_CALLBACK_HANDLER_KEY = "BotCallbackHandler" - BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" def __init__(self, settings: BotFrameworkAdapterSettings): """ @@ -256,9 +252,9 @@ async def continue_conversation( audience = self.__get_botframework_oauth_scope() context = TurnContext(self, get_continuation_activity(reference)) - context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] = claims_identity - context.turn_state[BotFrameworkAdapter.BOT_CALLBACK_HANDLER_KEY] = callback - context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] = audience + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = callback + context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = audience # Add the channel service URL to the trusted services list so we can send messages back. # the service URL for skills is trusted because it is applied by the SkillHandler based @@ -268,7 +264,7 @@ async def continue_conversation( client = await self.create_connector_client( reference.service_url, claims_identity, audience ) - context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] = client return await self.run_pipeline(context, callback) @@ -377,7 +373,7 @@ async def create_conversation( ) context = self._create_context(event_activity) - context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] = client claims_identity = ClaimsIdentity( claims={ @@ -387,7 +383,7 @@ async def create_conversation( }, is_authenticated=True, ) - context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] = claims_identity + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity return await self.run_pipeline(context, logic) @@ -424,8 +420,8 @@ async def process_activity_with_identity( self, activity: Activity, identity: ClaimsIdentity, logic: Callable ): context = self._create_context(activity) - context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] = identity - context.turn_state[BotFrameworkAdapter.BOT_CALLBACK_HANDLER_KEY] = logic + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = identity + context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = logic # To create the correct cache key, provide the OAuthScope when calling CreateConnectorClientAsync. # The OAuthScope is also stored on the TurnState to get the correct AppCredentials if fetching a token @@ -435,12 +431,12 @@ async def process_activity_with_identity( if not SkillValidation.is_skill_claim(identity.claims) else JwtTokenValidation.get_app_id_from_claims(identity.claims) ) - context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] = scope + context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = scope client = await self.create_connector_client( activity.service_url, identity, scope ) - context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] = client # Fix to assign tenant_id from channelData to Conversation.tenant_id. # MS Teams currently sends the tenant ID in channelData and the correct behavior is to expose @@ -573,7 +569,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): of the activity to replace. """ try: - client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.update_activity( activity.conversation.id, activity.id, activity ) @@ -601,7 +597,7 @@ async def delete_activity( The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: - client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] await client.conversations.delete_activity( reference.conversation.id, reference.activity_id ) @@ -646,14 +642,14 @@ async def send_activities( pass elif activity.reply_to_id: client = context.turn_state[ - BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + BotAdapter.BOT_CONNECTOR_CLIENT_KEY ] response = await client.conversations.reply_to_activity( activity.conversation.id, activity.reply_to_id, activity ) else: client = context.turn_state[ - BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + BotAdapter.BOT_CONNECTOR_CLIENT_KEY ] response = await client.conversations.send_to_conversation( activity.conversation.id, activity @@ -696,7 +692,7 @@ async def delete_conversation_member( "conversation.id" ) - client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.delete_conversation_member( context.activity.conversation.id, member_id ) @@ -738,7 +734,7 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): "context.activity.id" ) - client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.get_activity_members( context.activity.conversation.id, activity_id ) @@ -770,7 +766,7 @@ async def get_conversation_members(self, context: TurnContext): "conversation.id" ) - client = context.turn_state[BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.get_conversation_members( context.activity.conversation.id ) @@ -1085,7 +1081,7 @@ async def _create_token_api_client( self._is_emulating_oauth_cards = True app_id = self.__get_app_id(context) - scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + scope = context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] app_credentials = oauth_app_credentials or await self.__get_app_credentials( app_id, scope ) @@ -1187,7 +1183,7 @@ def __get_botframework_oauth_scope(self) -> str: return AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE def __get_app_id(self, context: TurnContext) -> str: - identity = context.turn_state[BotFrameworkAdapter.BOT_IDENTITY_KEY] + identity = context.turn_state[BotAdapter.BOT_IDENTITY_KEY] if not identity: raise Exception("An IIdentity is required in TurnState for this operation.") From e907bfce89319391524572c9f5f7ef6b56cae49e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 13:05:24 -0600 Subject: [PATCH 318/616] Removed certificate auth properties from BotFrameworkAdapterSettings (pass in AppCredentials instead) --- .../botbuilder/core/bot_framework_adapter.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 288ccb67a..0e9100d01 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -87,8 +87,6 @@ def __init__( open_id_metadata: str = None, channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, - certificate_thumbprint: str = None, - certificate_private_key: str = None, app_credentials: AppCredentials = None, credential_provider: CredentialProvider = None, ): @@ -107,19 +105,14 @@ def __init__( :param open_id_metadata: :type open_id_metadata: str :param channel_provider: The channel provider - :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` + :type channel_provider: :class:`botframework.connector.auth.ChannelProvider`. Defaults to SimpleChannelProvider + if one isn't specified. :param auth_configuration: :type auth_configuration: :class:`botframework.connector.auth.AuthenticationConfiguration` - :param certificate_thumbprint: X509 thumbprint - :type certificate_thumbprint: str - :param certificate_private_key: X509 private key - :type certificate_private_key: str - - .. remarks:: - For credentials authorization, both app_id and app_password are required. - For certificate authorization, app_id, certificate_thumbprint, and certificate_private_key are required. - + :param credential_provider: Defaults to SimpleCredentialProvider if one isn't specified. + :param app_credentials: Allows for a custom AppCredentials. Used, for example, for CertificateAppCredentials. """ + self.app_id = app_id self.app_password = app_password self.app_credentials = app_credentials @@ -134,8 +127,6 @@ def __init__( else SimpleCredentialProvider(self.app_id, self.app_password) ) self.auth_configuration = auth_configuration or AuthenticationConfiguration() - self.certificate_thumbprint = certificate_thumbprint - self.certificate_private_key = certificate_private_key # If no open_id_metadata values were passed in the settings, check the # process' Environment Variable. From ab716df15fa4ceea248c4df71efe8904d095f2b4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 13:11:13 -0600 Subject: [PATCH 319/616] black fixes (again) --- .../botbuilder/core/bot_framework_adapter.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 0e9100d01..7e1a9deaa 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -632,16 +632,12 @@ async def send_activities( if activity.type == "trace" and activity.channel_id != "emulator": pass elif activity.reply_to_id: - client = context.turn_state[ - BotAdapter.BOT_CONNECTOR_CLIENT_KEY - ] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] response = await client.conversations.reply_to_activity( activity.conversation.id, activity.reply_to_id, activity ) else: - client = context.turn_state[ - BotAdapter.BOT_CONNECTOR_CLIENT_KEY - ] + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] response = await client.conversations.send_to_conversation( activity.conversation.id, activity ) From 02c5278689d5453fbf1479ef87e0d11be5a3d928 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 2 Mar 2020 13:42:41 -0600 Subject: [PATCH 320/616] Added missing awaits to _create_token_api_client --- .../botbuilder/core/bot_framework_adapter.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 7e1a9deaa..8300bfefe 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -829,7 +829,7 @@ async def get_user_token( "get_user_token() requires a connection_name but none was provided." ) - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) result = client.user_token.get_token( context.activity.from_property.id, @@ -869,7 +869,7 @@ async def sign_out_user( if not user_id: user_id = context.activity.from_property.id - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) client.user_token.sign_out( user_id, connection_name, context.activity.channel_id ) @@ -895,7 +895,7 @@ async def get_oauth_sign_in_link( :return: If the task completes successfully, the result contains the raw sign-in link """ - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) conversation = TurnContext.get_conversation_reference(context.activity) state = TokenExchangeState( @@ -943,7 +943,7 @@ async def get_token_status( "BotFrameworkAdapter.get_token_status(): missing from_property or from_property.id" ) - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) user_id = user_id or context.activity.from_property.id return client.user_token.get_token_status( @@ -981,7 +981,7 @@ async def get_aad_tokens( "BotFrameworkAdapter.get_aad_tokens(): missing from_property or from_property.id" ) - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) return client.user_token.get_aad_tokens( context.activity.from_property.id, connection_name, From 0a7ffcbb6a1f961638065a7b6ac893ac84febec3 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Mon, 2 Mar 2020 12:19:33 -0800 Subject: [PATCH 321/616] chore: add issue autotagging --- .github/ISSUE_TEMPLATE/workflows/main.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/workflows/main.yml diff --git a/.github/ISSUE_TEMPLATE/workflows/main.yml b/.github/ISSUE_TEMPLATE/workflows/main.yml new file mode 100644 index 000000000..8ae9df9dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/workflows/main.yml @@ -0,0 +1,13 @@ + +on: [issues] + +jobs: + on-issue-update: + runs-on: ubuntu-latest + name: Tag issues + steps: + - name: Issue tagging + id: issue-autotagger + uses: christopheranderson/issue-autotagger@v1 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" From 8a7119c42afd4ffcbb9da11d475e3d93722df26b Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Mon, 2 Mar 2020 13:58:19 -0800 Subject: [PATCH 322/616] bug: move main.yml to proper folder --- .github/{ISSUE_TEMPLATE => }/workflows/main.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ISSUE_TEMPLATE => }/workflows/main.yml (100%) diff --git a/.github/ISSUE_TEMPLATE/workflows/main.yml b/.github/workflows/main.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/workflows/main.yml rename to .github/workflows/main.yml From 6ba3cf5caafd0ae5293af5be7a1a247c44e826e0 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 3 Mar 2020 08:43:49 -0600 Subject: [PATCH 323/616] Added DeliveryMode bufferedReplies --- .../botbuilder/core/bot_framework_adapter.py | 3 + .../botbuilder/core/turn_context.py | 18 +++ .../tests/test_bot_framework_adapter.py | 104 ++++++++++++++++-- .../schema/_connector_client_enums.py | 1 + 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 8300bfefe..c060082e3 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -39,6 +39,7 @@ ConversationReference, TokenResponse, ResourceResponse, + DeliveryModes, ) from . import __version__ @@ -455,6 +456,8 @@ async def process_activity_with_identity( if invoke_response is None: return InvokeResponse(status=501) return invoke_response.value + if context.activity.delivery_mode == DeliveryModes.buffered_replies: + return InvokeResponse(status=200, body=context.buffered_replies) return None diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 614a43ce1..b3ec326c8 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -12,6 +12,7 @@ InputHints, Mention, ResourceResponse, + DeliveryModes, ) from .re_escape import escape @@ -50,6 +51,9 @@ def __init__(self, adapter_or_context, request: Activity = None): self._turn_state = {} + # A list of activities to send when `context.Activity.DeliveryMode == 'bufferedReplies'` + self.buffered_replies = [] + @property def turn_state(self) -> Dict[str, object]: return self._turn_state @@ -190,7 +194,21 @@ def activity_validator(activity: Activity) -> Activity: for act in activities ] + # send activities through adapter async def logic(): + nonlocal sent_non_trace_activity + + if self.activity.delivery_mode == DeliveryModes.buffered_replies: + responses = [] + for activity in output: + self.buffered_replies.append(activity) + responses.append(ResourceResponse()) + + if sent_non_trace_activity: + self.responded = True + + return responses + responses = await self.adapter.send_activities(self, output) if sent_non_trace_activity: self.responded = True diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index e53148d94..4bf1664dd 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -19,6 +19,7 @@ ConversationReference, ConversationResourceResponse, ChannelAccount, + DeliveryModes, ) from botframework.connector.aio import ConnectorClient from botframework.connector.auth import ( @@ -58,6 +59,7 @@ def __init__(self, settings=None): self.fail_operation = False self.expect_auth_header = "" self.new_service_url = None + self.connector_client_mock = None def aux_test_authenticate_request(self, request: Activity, auth_header: str): return super()._authenticate_request(request, auth_header) @@ -102,7 +104,10 @@ def _get_or_create_connector_client( self.tester.assertIsNotNone( service_url, "create_connector_client() not passed service_url." ) - connector_client_mock = Mock() + + if self.connector_client_mock: + return self.connector_client_mock + self.connector_client_mock = Mock() async def mock_reply_to_activity(conversation_id, activity_id, activity): nonlocal self @@ -160,23 +165,23 @@ async def mock_create_conversation(parameters): ) return response - connector_client_mock.conversations.reply_to_activity.side_effect = ( + self.connector_client_mock.conversations.reply_to_activity.side_effect = ( mock_reply_to_activity ) - connector_client_mock.conversations.send_to_conversation.side_effect = ( + self.connector_client_mock.conversations.send_to_conversation.side_effect = ( mock_send_to_conversation ) - connector_client_mock.conversations.update_activity.side_effect = ( + self.connector_client_mock.conversations.update_activity.side_effect = ( mock_update_activity ) - connector_client_mock.conversations.delete_activity.side_effect = ( + self.connector_client_mock.conversations.delete_activity.side_effect = ( mock_delete_activity ) - connector_client_mock.conversations.create_conversation.side_effect = ( + self.connector_client_mock.conversations.create_conversation.side_effect = ( mock_create_conversation ) - return connector_client_mock + return self.connector_client_mock async def process_activity( @@ -572,3 +577,88 @@ async def callback(context: TurnContext): await adapter.continue_conversation( refs, callback, claims_identity=skills_identity, audience=skill_2_app_id ) + + async def test_delivery_mode_buffered_replies(self): + mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) + + settings = BotFrameworkAdapterSettings( + app_id="bot_id", credential_provider=mock_credential_provider + ) + adapter = AdapterUnderTest(settings) + + async def callback(context: TurnContext): + await context.send_activity("activity 1") + await context.send_activity("activity 2") + await context.send_activity("activity 3") + + inbound_activity = Activity( + type=ActivityTypes.message, + channel_id="emulator", + service_url="http://tempuri.org/whatever", + delivery_mode=DeliveryModes.buffered_replies, + text="hello world", + ) + + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: "bot_id", + AuthenticationConstants.APP_ID_CLAIM: "bot_id", + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + invoke_response = await adapter.process_activity_with_identity( + inbound_activity, identity, callback + ) + assert invoke_response + assert invoke_response.status == 200 + activities = invoke_response.body + assert len(activities) == 3 + assert activities[0].text == "activity 1" + assert activities[1].text == "activity 2" + assert activities[2].text == "activity 3" + assert ( + adapter.connector_client_mock.conversations.send_to_conversation.call_count + == 0 + ) + + async def test_delivery_mode_normal(self): + mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) + + settings = BotFrameworkAdapterSettings( + app_id="bot_id", credential_provider=mock_credential_provider + ) + adapter = AdapterUnderTest(settings) + + async def callback(context: TurnContext): + await context.send_activity("activity 1") + await context.send_activity("activity 2") + await context.send_activity("activity 3") + + inbound_activity = Activity( + type=ActivityTypes.message, + channel_id="emulator", + service_url="http://tempuri.org/whatever", + delivery_mode=DeliveryModes.normal, + text="hello world", + conversation=ConversationAccount(id="conversationId"), + ) + + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: "bot_id", + AuthenticationConstants.APP_ID_CLAIM: "bot_id", + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + invoke_response = await adapter.process_activity_with_identity( + inbound_activity, identity, callback + ) + assert not invoke_response + assert ( + adapter.connector_client_mock.conversations.send_to_conversation.call_count + == 3 + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index a725f880b..68aab5ecf 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -99,6 +99,7 @@ class DeliveryModes(str, Enum): normal = "normal" notification = "notification" + buffered_replies = "bufferedReplies" class ContactRelationUpdateActionTypes(str, Enum): From 8620a58ae2815c3d5a88dd42de75e25a6cd1a5b3 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 3 Mar 2020 08:51:28 -0600 Subject: [PATCH 324/616] Added botbuilder-adapters-slack to libraries. Set version on botbuilder-integration-applicationinsights-aiohttp --- ci-pr-pipeline.yml | 7 ++++--- .../integration/applicationinsights/aiohttp/about.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 28a803d69..8a7a557fd 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -52,6 +52,7 @@ jobs: pip install -e ./libraries/botbuilder-azure pip install -e ./libraries/botbuilder-testing pip install -e ./libraries/botbuilder-integration-applicationinsights-aiohttp + pip install -e ./libraries/botbuilder-adapters-slack pip install -r ./libraries/botframework-connector/tests/requirements.txt pip install -r ./libraries/botbuilder-core/tests/requirements.txt pip install coveralls @@ -85,16 +86,16 @@ jobs: - powershell: | Set-Location .. Get-ChildItem -Recurse -Force - + displayName: 'Dir workspace' condition: succeededOrFailed() - powershell: | # This task copies the code coverage file created by dotnet test into a well known location. In all - # checks I've done, dotnet test ALWAYS outputs the coverage file to the temp directory. + # checks I've done, dotnet test ALWAYS outputs the coverage file to the temp directory. # My attempts to override this and have it go directly to the CodeCoverage directory have # all failed, so I'm just doing the copy here. (cmullins) - + Get-ChildItem -Path "$(Build.SourcesDirectory)" -Include "*coverage*" | Copy-Item -Destination "$(Build.ArtifactStagingDirectory)/CodeCoverage" displayName: 'Copy .coverage Files to CodeCoverage folder' continueOnError: true diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py index 1fc4d035b..552d52e6e 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-integration-applicationinsights-aiohttp" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" From 838fae628befbb19978326999290b6c7d546cbaf Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 4 Mar 2020 00:10:36 -0800 Subject: [PATCH 325/616] Support originating audience for skills conversations --- .../botbuilder/core/skills/__init__.py | 4 ++ .../core/skills/conversation_id_factory.py | 48 +++++++++++++++- .../skills/skill_conversation_id_factory.py | 2 +- .../skill_conversation_id_factory_options.py | 55 +++++++++++++++++++ .../skills/skill_conversation_reference.py | 29 ++++++++++ .../botbuilder/core/skills/skill_handler.py | 34 +++++++++--- .../tests/skills/test_skill_handler.py | 2 +- 7 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py diff --git a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py index 6bd5a66b8..5421f9bf7 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py @@ -9,10 +9,14 @@ from .conversation_id_factory import ConversationIdFactoryBase from .skill_conversation_id_factory import SkillConversationIdFactory from .skill_handler import SkillHandler +from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions +from .skill_conversation_reference import SkillConversationReference __all__ = [ "BotFrameworkSkill", "ConversationIdFactoryBase", "SkillConversationIdFactory", + "SkillConversationIdFactoryOptions", + "SkillConversationReference", "SkillHandler", ] diff --git a/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py index 7c015de08..35b1d8b6a 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py @@ -1,22 +1,66 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from abc import ABC, abstractmethod +from typing import Union from botbuilder.schema import ConversationReference +from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions +from .skill_conversation_reference import SkillConversationReference class ConversationIdFactoryBase(ABC): + """ + Handles creating conversation ids for skill and should be subclassed. + + .. remarks:: + Derive from this class to handle creation of conversation ids, retrieval of + SkillConversationReferences and deletion. + """ + @abstractmethod async def create_skill_conversation_id( - self, conversation_reference: ConversationReference + self, + options_or_conversation_reference: Union[ + SkillConversationIdFactoryOptions, ConversationReference + ], ) -> str: + """ + Using the options passed in, creates a conversation id and + SkillConversationReference, storing them for future use. + + :param options_or_conversation_reference: The options contain properties useful + for generating a SkillConversationReference and conversation id. + :type options_or_conversation_reference: :class: + `Union[SkillConversationIdFactoryOptions, ConversationReference]` + + :returns: A skill conversation id. + + .. note:: + SkillConversationIdFactoryOptions is the preferred parameter type, while ConversationReference + type is provided for backwards compatability. + """ raise NotImplementedError() @abstractmethod async def get_conversation_reference( self, skill_conversation_id: str - ) -> ConversationReference: + ) -> Union[SkillConversationReference, ConversationReference]: + """ + Retrieves a SkillConversationReference using a conversation id passed in. + + :param skill_conversation_id: The conversation id for which to retrieve + the SkillConversationReference. + :type skill_conversation_id: str + + .. note:: + SkillConversationReference is the preferred return type, while ConversationReference + type is provided for backwards compatability. + """ raise NotImplementedError() @abstractmethod async def delete_conversation_reference(self, skill_conversation_id: str): + """ + Removes any reference to objects keyed on the conversation id passed in. + """ raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py index 6b01865fc..be2eeeb77 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py @@ -18,7 +18,7 @@ def __init__(self, storage: Storage): self._forward_x_ref: Dict[str, str] = {} self._backward_x_ref: Dict[str, Tuple[str, str]] = {} - async def create_skill_conversation_id( + async def create_skill_conversation_id( # pylint: disable=W0221 self, conversation_reference: ConversationReference ) -> str: if not conversation_reference: diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py new file mode 100644 index 000000000..5167e7dcb --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import Activity +from .bot_framework_skill import BotFrameworkSkill + + +class SkillConversationIdFactoryOptions: + def __init__( + self, + from_bot_oauth_scope: str, + from_bot_id: str, + activity: Activity, + bot_framework_skill: BotFrameworkSkill, + ): + if from_bot_oauth_scope is None: + raise TypeError( + "SkillConversationIdFactoryOptions(): from_bot_oauth_scope cannot be None." + ) + + if from_bot_id is None: + raise TypeError( + "SkillConversationIdFactoryOptions(): from_bot_id cannot be None." + ) + + if activity is None: + raise TypeError( + "SkillConversationIdFactoryOptions(): activity cannot be None." + ) + + if bot_framework_skill is None: + raise TypeError( + "SkillConversationIdFactoryOptions(): bot_framework_skill cannot be None." + ) + + self._from_bot_oauth_scope = from_bot_oauth_scope + self._from_bot_id = from_bot_id + self._activity = activity + self._bot_framework_skill = bot_framework_skill + + @property + def from_bot_oauth_scope(self) -> str: + return self._from_bot_oauth_scope + + @property + def from_bot_id(self) -> str: + return self._from_bot_id + + @property + def activity(self) -> Activity: + return self._activity + + @property + def bot_framework_skill(self) -> BotFrameworkSkill: + return self._bot_framework_skill diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py new file mode 100644 index 000000000..068eb12d9 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botbuilder.schema import ConversationReference + + +class SkillConversationReference: + """ + ConversationReference implementation for Skills ConversationIdFactory. + """ + + def __init__(self, conversation_reference: ConversationReference, oauth_scope: str): + if conversation_reference is None: + raise TypeError( + "SkillConversationReference(): conversation_reference cannot be None." + ) + + if oauth_scope is None: + raise TypeError("SkillConversationReference(): oauth_scope cannot be None.") + + self._conversation_reference = conversation_reference + self._oauth_scope = oauth_scope + + @property + def conversation_reference(self) -> ConversationReference: + return self._conversation_reference + + @property + def oauth_scope(self) -> str: + return self._oauth_scope diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 3158d35e6..dd0066a87 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -12,12 +12,14 @@ ) from botframework.connector.auth import ( AuthenticationConfiguration, + AuthenticationConstants, ChannelProvider, ClaimsIdentity, CredentialProvider, + GovernmentConstants, ) - -from .skill_conversation_id_factory import SkillConversationIdFactory +from .skill_conversation_reference import SkillConversationReference +from .skill_conversation_id_factory import ConversationIdFactoryBase class SkillHandler(ChannelServiceHandler): @@ -30,7 +32,7 @@ def __init__( self, adapter: BotAdapter, bot: Bot, - conversation_id_factory: SkillConversationIdFactory, + conversation_id_factory: ConversationIdFactoryBase, credential_provider: CredentialProvider, auth_configuration: AuthenticationConfiguration, channel_provider: ChannelProvider = None, @@ -118,14 +120,29 @@ async def _process_activity( reply_to_activity_id: str, activity: Activity, ) -> ResourceResponse: - conversation_reference = await self._conversation_id_factory.get_conversation_reference( + conversation_reference_result = await self._conversation_id_factory.get_conversation_reference( conversation_id ) + oauth_scope = None + conversation_reference = None + if isinstance(conversation_reference_result, SkillConversationReference): + oauth_scope = conversation_reference_result.oauth_scope + conversation_reference = ( + conversation_reference_result.conversation_reference + ) + else: + conversation_reference = conversation_reference_result + oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + if self._channel_provider and self._channel_provider.is_government() + else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + if not conversation_reference: raise KeyError("ConversationReference not found") - skill_conversation_reference = ConversationReference( + activity_conversation_reference = ConversationReference( activity_id=activity.id, user=activity.from_property, bot=activity.recipient, @@ -137,7 +154,7 @@ async def _process_activity( async def callback(context: TurnContext): context.turn_state[ SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY - ] = skill_conversation_reference + ] = activity_conversation_reference TurnContext.apply_conversation_reference(activity, conversation_reference) context.activity.id = reply_to_activity_id @@ -154,7 +171,10 @@ async def callback(context: TurnContext): await context.send_activity(activity) await self._adapter.continue_conversation( - conversation_reference, callback, claims_identity=claims_identity + conversation_reference, + callback, + claims_identity=claims_identity, + audience=oauth_scope, ) return ResourceResponse(id=str(uuid4())) diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index cf0de8570..442ecb926 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -34,7 +34,7 @@ class ConversationIdFactoryForTest(ConversationIdFactoryBase): def __init__(self): self._conversation_refs: Dict[str, str] = {} - async def create_skill_conversation_id( + async def create_skill_conversation_id( # pylint: disable=W0221 self, conversation_reference: ConversationReference ) -> str: cr_json = json.dumps(conversation_reference.serialize()) From 496901523ba4f39569e5734b94b295219913fb2f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 4 Mar 2020 08:22:49 -0600 Subject: [PATCH 326/616] Serializing the InvokeResponse.body when DeliveryMode == bufferedReplies. Added method to BotFrameworkHttpClient to deserialized bufferedReplies for the caller. --- .../botbuilder/core/bot_framework_adapter.py | 7 +- .../core/bot_framework_http_client.py | 22 +++- .../botbuilder/core/invoke_response.py | 46 ++++---- .../experimental/skills-buffered/child/app.py | 78 ++++++++++++++ .../skills-buffered/child/bots/__init__.py | 6 ++ .../skills-buffered/child/bots/child_bot.py | 12 +++ .../skills-buffered/child/config.py | 13 +++ .../skills-buffered/child/requirements.txt | 2 + .../skills-buffered/parent/app.py | 100 ++++++++++++++++++ .../skills-buffered/parent/bots/__init__.py | 4 + .../skills-buffered/parent/bots/parent_bot.py | 43 ++++++++ .../skills-buffered/parent/config.py | 13 +++ .../skills-buffered/parent/requirements.txt | 2 + .../parent/skill_conversation_id_factory.py | 47 ++++++++ 14 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 samples/experimental/skills-buffered/child/app.py create mode 100644 samples/experimental/skills-buffered/child/bots/__init__.py create mode 100644 samples/experimental/skills-buffered/child/bots/child_bot.py create mode 100644 samples/experimental/skills-buffered/child/config.py create mode 100644 samples/experimental/skills-buffered/child/requirements.txt create mode 100644 samples/experimental/skills-buffered/parent/app.py create mode 100644 samples/experimental/skills-buffered/parent/bots/__init__.py create mode 100644 samples/experimental/skills-buffered/parent/bots/parent_bot.py create mode 100644 samples/experimental/skills-buffered/parent/config.py create mode 100644 samples/experimental/skills-buffered/parent/requirements.txt create mode 100644 samples/experimental/skills-buffered/parent/skill_conversation_id_factory.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index c060082e3..cc5ad1d83 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -456,8 +456,13 @@ async def process_activity_with_identity( if invoke_response is None: return InvokeResponse(status=501) return invoke_response.value + + # Return the buffered activities in the response. In this case, the invoker + # should deserialize accordingly: + # activities = [Activity().deserialize(activity) for activity in response.body] if context.activity.delivery_mode == DeliveryModes.buffered_replies: - return InvokeResponse(status=200, body=context.buffered_replies) + serialized_activities = [activity.serialize() for activity in context.buffered_replies] + return InvokeResponse(status=200, body=serialized_activities) return None diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py index a72e3a8f5..963cf8bd4 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py @@ -91,7 +91,7 @@ async def post_activity( content = json.loads(data) if data else None if content: - return InvokeResponse(status=resp.status_code, body=content) + return InvokeResponse(status=resp.status, body=content) finally: # Restore activity properties. @@ -99,6 +99,26 @@ async def post_activity( activity.service_url = original_service_url activity.caller_id = original_caller_id + async def post_buffered_activity( + self, + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, + conversation_id: str, + activity: Activity, + ) -> [Activity]: + """ + Helper method to return a list of activities when an Activity is being + sent with DeliveryMode == bufferedReplies. + """ + response = await self.post_activity( + from_bot_id, to_bot_id, to_url, service_url, conversation_id, activity + ) + if not response or (response.status / 100) != 2: + return [] + return [Activity().deserialize(activity) for activity in response.body] + async def _get_app_credentials( self, app_id: str, oauth_scope: str ) -> MicrosoftAppCredentials: diff --git a/libraries/botbuilder-core/botbuilder/core/invoke_response.py b/libraries/botbuilder-core/botbuilder/core/invoke_response.py index 408662707..7d258559e 100644 --- a/libraries/botbuilder-core/botbuilder/core/invoke_response.py +++ b/libraries/botbuilder-core/botbuilder/core/invoke_response.py @@ -1,20 +1,26 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class InvokeResponse: - """ - Tuple class containing an HTTP Status Code and a JSON Serializable - object. The HTTP Status code is, in the invoke activity scenario, what will - be set in the resulting POST. The Body of the resulting POST will be - the JSON Serialized content from the Body property. - """ - - def __init__(self, status: int = None, body: object = None): - """ - Gets or sets the HTTP status and/or body code for the response - :param status: The HTTP status code. - :param body: The body content for the response. - """ - self.status = status - self.body = body +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class InvokeResponse: + """ + Tuple class containing an HTTP Status Code and a JSON serializable + object. The HTTP Status code is, in the invoke activity scenario, what will + be set in the resulting POST. The Body of the resulting POST will be + JSON serialized content. + + The body content is defined by the producer. The caller must know what + the content is and deserialize as needed. + """ + + def __init__(self, status: int = None, body: object = None): + """ + Gets or sets the HTTP status and/or body code for the response + :param status: The HTTP status code. + :param body: The JSON serializable body content for the response. This object + must be serializable by the core Python json routines. The caller is responsible + for serializing more complex/nested objects into native classes (lists and + dictionaries of strings are acceptable). + """ + self.status = status + self.body = body diff --git a/samples/experimental/skills-buffered/child/app.py b/samples/experimental/skills-buffered/child/app.py new file mode 100644 index 000000000..27351c36d --- /dev/null +++ b/samples/experimental/skills-buffered/child/app.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback + +from aiohttp import web +from aiohttp.web import Request, Response +from aiohttp.web_response import json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity + +from bots import ChildBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings( + app_id=CONFIG.APP_ID, app_password=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) + traceback.print_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." + ) + + +ADAPTER.on_turn_error = on_error + +# Create the Bot +BOT = ChildBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-buffered/child/bots/__init__.py b/samples/experimental/skills-buffered/child/bots/__init__.py new file mode 100644 index 000000000..a1643fbf8 --- /dev/null +++ b/samples/experimental/skills-buffered/child/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .child_bot import ChildBot + +__all__ = ["ChildBot"] diff --git a/samples/experimental/skills-buffered/child/bots/child_bot.py b/samples/experimental/skills-buffered/child/bots/child_bot.py new file mode 100644 index 000000000..ad6a37839 --- /dev/null +++ b/samples/experimental/skills-buffered/child/bots/child_bot.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, TurnContext + + +class ChildBot(ActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + await turn_context.send_activity("child: activity (1)") + await turn_context.send_activity("child: activity (2)") + await turn_context.send_activity("child: activity (3)") + await turn_context.send_activity(f"child: {turn_context.activity.text}") diff --git a/samples/experimental/skills-buffered/child/config.py b/samples/experimental/skills-buffered/child/config.py new file mode 100644 index 000000000..f21c1df0e --- /dev/null +++ b/samples/experimental/skills-buffered/child/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 = 3979 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") diff --git a/samples/experimental/skills-buffered/child/requirements.txt b/samples/experimental/skills-buffered/child/requirements.txt new file mode 100644 index 000000000..20f8f8fe5 --- /dev/null +++ b/samples/experimental/skills-buffered/child/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.7.1 +aiohttp diff --git a/samples/experimental/skills-buffered/parent/app.py b/samples/experimental/skills-buffered/parent/app.py new file mode 100644 index 000000000..585a6873f --- /dev/null +++ b/samples/experimental/skills-buffered/parent/app.py @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback + +from aiohttp import web +from aiohttp.web import Request, Response +from aiohttp.web_response import json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + MemoryStorage, + TurnContext, + BotFrameworkAdapter, + BotFrameworkHttpClient, +) +from botbuilder.core.integration import ( + aiohttp_channel_service_routes, + aiohttp_error_middleware, +) +from botbuilder.core.skills import SkillHandler +from botbuilder.schema import Activity +from botframework.connector.auth import ( + AuthenticationConfiguration, + SimpleCredentialProvider, +) + +from bots.parent_bot import ParentBot +from skill_conversation_id_factory import SkillConversationIdFactory +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings( + app_id=CONFIG.APP_ID, app_password=CONFIG.APP_PASSWORD, +) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = BotFrameworkHttpClient(CREDENTIAL_PROVIDER) + + +# 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) + traceback.print_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." + ) + + +ADAPTER.on_turn_error = on_error + +# Create the Bot +BOT = ParentBot(CLIENT) + +STORAGE = MemoryStorage() +ID_FACTORY = SkillConversationIdFactory(STORAGE) +SKILL_HANDLER = SkillHandler( + ADAPTER, BOT, ID_FACTORY, CREDENTIAL_PROVIDER, AuthenticationConfiguration() +) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) +APP.router.add_routes(aiohttp_channel_service_routes(SKILL_HANDLER, "/api/skills")) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-buffered/parent/bots/__init__.py b/samples/experimental/skills-buffered/parent/bots/__init__.py new file mode 100644 index 000000000..01c37eaea --- /dev/null +++ b/samples/experimental/skills-buffered/parent/bots/__init__.py @@ -0,0 +1,4 @@ +from .parent_bot import ParentBot + + +__all__ = ["ParentBot"] diff --git a/samples/experimental/skills-buffered/parent/bots/parent_bot.py b/samples/experimental/skills-buffered/parent/bots/parent_bot.py new file mode 100644 index 000000000..91b85b654 --- /dev/null +++ b/samples/experimental/skills-buffered/parent/bots/parent_bot.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import uuid + +from botbuilder.core import ( + ActivityHandler, + TurnContext, + BotFrameworkHttpClient, + MessageFactory, +) + +from botbuilder.schema import DeliveryModes + + +class ParentBot(ActivityHandler): + def __init__( + self, skill_client: BotFrameworkHttpClient, + ): + self.client = skill_client + + async def on_message_activity(self, turn_context: TurnContext): + await turn_context.send_activity("parent: before child") + + activity = MessageFactory.text("parent to child") + TurnContext.apply_conversation_reference( + activity, TurnContext.get_conversation_reference(turn_context.activity) + ) + activity.delivery_mode = DeliveryModes.buffered_replies + + activities = await self.client.post_buffered_activity( + None, + "toBotId", + "http://localhost:3979/api/messages", + "http://tempuri.org/whatever", + str(uuid.uuid4()), + activity, + ) + + if activities: + await turn_context.send_activities(activities) + + await turn_context.send_activity("parent: after child") diff --git a/samples/experimental/skills-buffered/parent/config.py b/samples/experimental/skills-buffered/parent/config.py new file mode 100644 index 000000000..d66581d4c --- /dev/null +++ b/samples/experimental/skills-buffered/parent/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/samples/experimental/skills-buffered/parent/requirements.txt b/samples/experimental/skills-buffered/parent/requirements.txt new file mode 100644 index 000000000..20f8f8fe5 --- /dev/null +++ b/samples/experimental/skills-buffered/parent/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.7.1 +aiohttp diff --git a/samples/experimental/skills-buffered/parent/skill_conversation_id_factory.py b/samples/experimental/skills-buffered/parent/skill_conversation_id_factory.py new file mode 100644 index 000000000..8faaae025 --- /dev/null +++ b/samples/experimental/skills-buffered/parent/skill_conversation_id_factory.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import Storage +from botbuilder.core.skills import ConversationIdFactoryBase +from botbuilder.schema import ConversationReference + + +class SkillConversationIdFactory(ConversationIdFactoryBase): + def __init__(self, storage: Storage): + if not storage: + raise TypeError("storage can't be None") + + self._storage = storage + + async def create_skill_conversation_id( + self, conversation_reference: ConversationReference + ) -> str: + if not conversation_reference: + raise TypeError("conversation_reference can't be None") + + if not conversation_reference.conversation.id: + raise TypeError("conversation id in conversation reference can't be None") + + if not conversation_reference.channel_id: + raise TypeError("channel id in conversation reference can't be None") + + storage_key = f"{conversation_reference.channel_id}:{conversation_reference.conversation.id}" + + skill_conversation_info = {storage_key: conversation_reference} + + await self._storage.write(skill_conversation_info) + + return storage_key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + if not skill_conversation_id: + raise TypeError("skill_conversation_id can't be None") + + skill_conversation_info = await self._storage.read([skill_conversation_id]) + + return skill_conversation_info.get(skill_conversation_id) + + async def delete_conversation_reference(self, skill_conversation_id: str): + await self._storage.delete([skill_conversation_id]) From 07396d7c32469d3d6d43aa0802ce4b307701ad8b Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 4 Mar 2020 08:26:07 -0600 Subject: [PATCH 327/616] Now using Python 3.8.2 --- ci-pr-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 8a7a557fd..e70861290 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -8,7 +8,7 @@ variables: COVERALLS_SERVICE_NAME: python-ci python.36: 3.6.10 python.37: 3.7.6 - python.38: 3.8.1 + python.38: 3.8.2 jobs: From d4c25d9a4d554f513d655128905770bca01d8f42 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 4 Mar 2020 09:15:47 -0600 Subject: [PATCH 328/616] Adjusted BotFrameworkAdapter bufferedReplies test to account for different InvokeResponse body. --- .../botbuilder-core/tests/test_bot_framework_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 4bf1664dd..871e616a1 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -615,9 +615,9 @@ async def callback(context: TurnContext): assert invoke_response.status == 200 activities = invoke_response.body assert len(activities) == 3 - assert activities[0].text == "activity 1" - assert activities[1].text == "activity 2" - assert activities[2].text == "activity 3" + assert activities[0]["text"] == "activity 1" + assert activities[1]["text"] == "activity 2" + assert activities[2]["text"] == "activity 3" assert ( adapter.connector_client_mock.conversations.send_to_conversation.call_count == 0 From 867eab7bd7de347bab37b5964555e436cafe32c5 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 4 Mar 2020 09:19:22 -0600 Subject: [PATCH 329/616] black compliance --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index cc5ad1d83..ffe81ec7d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -461,7 +461,9 @@ async def process_activity_with_identity( # should deserialize accordingly: # activities = [Activity().deserialize(activity) for activity in response.body] if context.activity.delivery_mode == DeliveryModes.buffered_replies: - serialized_activities = [activity.serialize() for activity in context.buffered_replies] + serialized_activities = [ + activity.serialize() for activity in context.buffered_replies + ] return InvokeResponse(status=200, body=serialized_activities) return None From d5344f4e8aecc4100f8bd90ad9c416aedb5fe162 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 4 Mar 2020 11:12:18 -0800 Subject: [PATCH 330/616] black updates --- .../botbuilder/core/skills/skill_conversation_id_factory.py | 2 +- libraries/botbuilder-core/tests/skills/test_skill_handler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py index be2eeeb77..bbff40674 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py @@ -18,7 +18,7 @@ def __init__(self, storage: Storage): self._forward_x_ref: Dict[str, str] = {} self._backward_x_ref: Dict[str, Tuple[str, str]] = {} - async def create_skill_conversation_id( # pylint: disable=W0221 + async def create_skill_conversation_id( # pylint: disable=W0221 self, conversation_reference: ConversationReference ) -> str: if not conversation_reference: diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 442ecb926..cbe61d0d0 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -34,7 +34,7 @@ class ConversationIdFactoryForTest(ConversationIdFactoryBase): def __init__(self): self._conversation_refs: Dict[str, str] = {} - async def create_skill_conversation_id( # pylint: disable=W0221 + async def create_skill_conversation_id( # pylint: disable=W0221 self, conversation_reference: ConversationReference ) -> str: cr_json = json.dumps(conversation_reference.serialize()) From 37c60286adc31d7a292adc4a28fa33748754344d Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 4 Mar 2020 12:08:16 -0800 Subject: [PATCH 331/616] address feedback --- .../botbuilder/core/skills/__init__.py | 2 - .../skills/skill_conversation_id_factory.py | 54 -------------- .../skill_conversation_id_factory_options.py | 24 +----- .../skills/skill_conversation_reference.py | 12 +-- .../core/skills/skill_http_client.py | 74 +++++++++++++++++++ 5 files changed, 80 insertions(+), 86 deletions(-) delete mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py diff --git a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py index 5421f9bf7..116f9aeef 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py @@ -7,7 +7,6 @@ from .bot_framework_skill import BotFrameworkSkill from .conversation_id_factory import ConversationIdFactoryBase -from .skill_conversation_id_factory import SkillConversationIdFactory from .skill_handler import SkillHandler from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions from .skill_conversation_reference import SkillConversationReference @@ -15,7 +14,6 @@ __all__ = [ "BotFrameworkSkill", "ConversationIdFactoryBase", - "SkillConversationIdFactory", "SkillConversationIdFactoryOptions", "SkillConversationReference", "SkillHandler", diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py deleted file mode 100644 index bbff40674..000000000 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -import hashlib -from typing import Dict, Tuple - -from botbuilder.core import Storage -from botbuilder.schema import ConversationReference - -from .conversation_id_factory import ConversationIdFactoryBase - - -class SkillConversationIdFactory(ConversationIdFactoryBase): - def __init__(self, storage: Storage): - if not storage: - raise TypeError("storage can't be None") - - self._storage = storage - self._forward_x_ref: Dict[str, str] = {} - self._backward_x_ref: Dict[str, Tuple[str, str]] = {} - - async def create_skill_conversation_id( # pylint: disable=W0221 - self, conversation_reference: ConversationReference - ) -> str: - if not conversation_reference: - raise TypeError("conversation_reference can't be None") - - if not conversation_reference.conversation.id: - raise TypeError("conversation id in conversation reference can't be None") - - if not conversation_reference.channel_id: - raise TypeError("channel id in conversation reference can't be None") - - storage_key = hashlib.md5( - f"{conversation_reference.conversation.id}{conversation_reference.channel_id}".encode() - ).hexdigest() - - skill_conversation_info = {storage_key: conversation_reference} - - await self._storage.write(skill_conversation_info) - - return storage_key - - async def get_conversation_reference( - self, skill_conversation_id: str - ) -> ConversationReference: - if not skill_conversation_id: - raise TypeError("skill_conversation_id can't be None") - - skill_conversation_info = await self._storage.read([skill_conversation_id]) - - return skill_conversation_info.get(skill_conversation_id) - - async def delete_conversation_reference(self, skill_conversation_id: str): - await self._storage.delete([skill_conversation_id]) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py index 5167e7dcb..9eae6ec75 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py @@ -33,23 +33,7 @@ def __init__( "SkillConversationIdFactoryOptions(): bot_framework_skill cannot be None." ) - self._from_bot_oauth_scope = from_bot_oauth_scope - self._from_bot_id = from_bot_id - self._activity = activity - self._bot_framework_skill = bot_framework_skill - - @property - def from_bot_oauth_scope(self) -> str: - return self._from_bot_oauth_scope - - @property - def from_bot_id(self) -> str: - return self._from_bot_id - - @property - def activity(self) -> Activity: - return self._activity - - @property - def bot_framework_skill(self) -> BotFrameworkSkill: - return self._bot_framework_skill + self.from_bot_oauth_scope = from_bot_oauth_scope + self.from_bot_id = from_bot_id + self.activity = activity + self.bot_framework_skill = bot_framework_skill diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py index 068eb12d9..877f83141 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py @@ -17,13 +17,5 @@ def __init__(self, conversation_reference: ConversationReference, oauth_scope: s if oauth_scope is None: raise TypeError("SkillConversationReference(): oauth_scope cannot be None.") - self._conversation_reference = conversation_reference - self._oauth_scope = oauth_scope - - @property - def conversation_reference(self) -> ConversationReference: - return self._conversation_reference - - @property - def oauth_scope(self) -> str: - return self._oauth_scope + self.conversation_reference = conversation_reference + self.oauth_scope = oauth_scope diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py new file mode 100644 index 000000000..8699c0ad8 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ( + BotFrameworkHttpClient, + InvokeResponse, +) +from botbuilder.core.skills import ( + ConversationIdFactoryBase, + SkillConversationIdFactoryOptions, + BotFrameworkSkill, +) +from botbuilder.schema import Activity +from botframework.connector.auth import ( + AuthenticationConstants, + ChannelProvider, + GovernmentConstants, + SimpleCredentialProvider, +) + + +class SkillHttpClient(BotFrameworkHttpClient): + def __init__( + self, + credential_provider: SimpleCredentialProvider, + skill_conversation_id_factory: ConversationIdFactoryBase, + channel_provider: ChannelProvider = None, + ): + if not skill_conversation_id_factory: + raise TypeError( + "SkillHttpClient(): skill_conversation_id_factory can't be None" + ) + + super().__init__(credential_provider) + + self._skill_conversation_id_factory = skill_conversation_id_factory + self._channel_provider = channel_provider + + async def post_activity_to_skill( + self, + from_bot_id: str, + to_skill: BotFrameworkSkill, + service_url: str, + activity: Activity, + originating_audience: str = None, + ) -> InvokeResponse: + + if originating_audience is None: + originating_audience = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + if self._channel_provider is not None + and self._channel_provider.IsGovernment() + else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + options = SkillConversationIdFactoryOptions( + from_bot_oauth_scope=originating_audience, + from_bot_id=from_bot_id, + activity=activity, + bot_framework_skill=to_skill, + ) + + skill_conversation_id = await self._skill_conversation_id_factory.create_skill_conversation_id( + options + ) + + return await super().post_activity( + from_bot_id, + to_skill.app_id, + to_skill.skill_endpoint, + service_url, + skill_conversation_id, + activity, + ) From 77053b3c8c18b65b87e147cef3b0afb9cfa7e79e Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 4 Mar 2020 12:15:17 -0800 Subject: [PATCH 332/616] fix reference issue --- .../botbuilder-core/botbuilder/core/skills/skill_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index dd0066a87..0aabdc9f3 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -19,7 +19,7 @@ GovernmentConstants, ) from .skill_conversation_reference import SkillConversationReference -from .skill_conversation_id_factory import ConversationIdFactoryBase +from .conversation_id_factory import ConversationIdFactoryBase class SkillHandler(ChannelServiceHandler): From 0f32eefb1433433e48e713e810db89d41faac41f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 4 Mar 2020 16:23:58 -0600 Subject: [PATCH 333/616] Provided impl for continue_conversation with ClaimsIdentity --- .../adapters/slack/slack_adapter.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index c24e4b904..2f1af54e8 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -138,12 +138,11 @@ async def continue_conversation( Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. Most _channels require a user to initiate a conversation with a bot before the bot can send activities to the user. - :param bot_id: The application ID of the bot. This parameter is ignored in - single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter - which is multi-tenant aware. - :param reference: A reference to the conversation to continue. - :param callback: The method to call for the resulting bot turn. - :param claims_identity: + :param bot_id: Unused for this override. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. + :param claims_identity: A ClaimsIdentity for the conversation. + :param audience: Unused for this override. """ if not reference: @@ -151,11 +150,19 @@ async def continue_conversation( if not callback: raise Exception("callback is required") - request = TurnContext.apply_conversation_reference( - conversation_reference_extension.get_continuation_activity(reference), - reference, - ) - context = TurnContext(self, request) + if claims_identity: + request = conversation_reference_extension.get_continuation_activity( + reference + ) + context = TurnContext(self, request) + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = callback + else: + request = TurnContext.apply_conversation_reference( + conversation_reference_extension.get_continuation_activity(reference), + reference, + ) + context = TurnContext(self, request) return await self.run_pipeline(context, callback) From b8b8d814ffa95dae56771f01842306e5612b2a95 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 6 Mar 2020 12:04:16 -0800 Subject: [PATCH 334/616] add member_count and channel_count to TeamDetails --- .../botbuilder/schema/teams/_models_py3.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 529ab6851..96cc65c42 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1736,21 +1736,29 @@ class TeamDetails(Model): :type name: str :param aad_group_id: Azure Active Directory (AAD) Group Id for the team. :type aad_group_id: str + :param channel_count: The count of channels in the team. + :type chanel_count: int + :param member_count: The count of members in the team. + :type member_count: int """ _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, "aad_group_id": {"key": "aadGroupId", "type": "str"}, + "channel_count": {"key": "channelCount", "type": "int"}, + "member_count": {"key": "memberCount", "type": "int"}, } def __init__( - self, *, id: str = None, name: str = None, aad_group_id: str = None, **kwargs + self, *, id: str = None, name: str = None, aad_group_id: str = None, member_count: int = None, chanel_count: int = None, **kwargs ) -> None: super(TeamDetails, self).__init__(**kwargs) self.id = id self.name = name self.aad_group_id = aad_group_id + self.channel_count = chanel_count + self.member_count = member_count class TeamInfo(Model): From 455f79a99f2ffe6c9da5e50b49801bc731578a74 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 6 Mar 2020 12:37:40 -0800 Subject: [PATCH 335/616] update libraries --- .../botbuilder/schema/teams/_models_py3.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 96cc65c42..d2b3999dd 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1751,7 +1751,14 @@ class TeamDetails(Model): } def __init__( - self, *, id: str = None, name: str = None, aad_group_id: str = None, member_count: int = None, chanel_count: int = None, **kwargs + self, + *, + id: str = None, + name: str = None, + aad_group_id: str = None, + member_count: int = None, + chanel_count: int = None, + **kwargs ) -> None: super(TeamDetails, self).__init__(**kwargs) self.id = id From b79fb6dc3a4c3710b9d36ff4e4c4d6605a1abf34 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 6 Mar 2020 13:02:45 -0800 Subject: [PATCH 336/616] add to ... other models file --- .../botbuilder-schema/botbuilder/schema/teams/_models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 3cce195d6..953ce4ec2 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1478,12 +1478,18 @@ class TeamDetails(Model): :type name: str :param aad_group_id: Azure Active Directory (AAD) Group Id for the team. :type aad_group_id: str + :param channel_count: The count of channels in the team. + :type chanel_count: int + :param member_count: The count of members in the team. + :type member_count: int """ _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, "aad_group_id": {"key": "aadGroupId", "type": "str"}, + "channel_count": {"key": "channelCount", "type": "int"}, + "member_count": {"key": "memberCount", "type": "int"}, } def __init__(self, **kwargs): @@ -1491,6 +1497,8 @@ def __init__(self, **kwargs): self.id = kwargs.get("id", None) self.name = kwargs.get("name", None) self.aad_group_id = kwargs.get("aad_group_id", None) + self.channel_count = kwargs.get("channel_count", None) + self.member_count = kwargs.get("member_count", None) class TeamInfo(Model): From 11c8a15d063adad43bd33c3a97fd5c67e4877453 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 6 Mar 2020 13:13:05 -0800 Subject: [PATCH 337/616] fix typo --- .../botbuilder-schema/botbuilder/schema/teams/_models_py3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index d2b3999dd..06ee45abe 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1757,14 +1757,14 @@ def __init__( name: str = None, aad_group_id: str = None, member_count: int = None, - chanel_count: int = None, + channel_count: int = None, **kwargs ) -> None: super(TeamDetails, self).__init__(**kwargs) self.id = id self.name = name self.aad_group_id = aad_group_id - self.channel_count = chanel_count + self.channel_count = channel_count self.member_count = member_count From cdf2f58e54850488b9b49b6301a0881b27ff6089 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 6 Mar 2020 13:13:35 -0800 Subject: [PATCH 338/616] fix typo... all the way. --- .../botbuilder-schema/botbuilder/schema/teams/_models_py3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 06ee45abe..5f868f813 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1737,7 +1737,7 @@ class TeamDetails(Model): :param aad_group_id: Azure Active Directory (AAD) Group Id for the team. :type aad_group_id: str :param channel_count: The count of channels in the team. - :type chanel_count: int + :type channel_count: int :param member_count: The count of members in the team. :type member_count: int """ From f714ace4cbafe7bd053f5cec78f895aa22e1ead2 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 6 Mar 2020 14:40:43 -0800 Subject: [PATCH 339/616] change DeliveryMode bufferedReplies to expectsReply --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 2 +- .../botbuilder/core/bot_framework_http_client.py | 2 +- libraries/botbuilder-core/botbuilder/core/turn_context.py | 4 ++-- libraries/botbuilder-core/tests/test_bot_framework_adapter.py | 4 ++-- .../botbuilder/schema/_connector_client_enums.py | 2 +- .../experimental/skills-buffered/parent/bots/parent_bot.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index ffe81ec7d..b0ef89422 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -460,7 +460,7 @@ async def process_activity_with_identity( # Return the buffered activities in the response. In this case, the invoker # should deserialize accordingly: # activities = [Activity().deserialize(activity) for activity in response.body] - if context.activity.delivery_mode == DeliveryModes.buffered_replies: + if context.activity.delivery_mode == DeliveryModes.expects_reply: serialized_activities = [ activity.serialize() for activity in context.buffered_replies ] diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py index 963cf8bd4..de38aba91 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py @@ -110,7 +110,7 @@ async def post_buffered_activity( ) -> [Activity]: """ Helper method to return a list of activities when an Activity is being - sent with DeliveryMode == bufferedReplies. + sent with DeliveryMode == expectsReply. """ response = await self.post_activity( from_bot_id, to_bot_id, to_url, service_url, conversation_id, activity diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index b3ec326c8..1b3b1d994 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -51,7 +51,7 @@ def __init__(self, adapter_or_context, request: Activity = None): self._turn_state = {} - # A list of activities to send when `context.Activity.DeliveryMode == 'bufferedReplies'` + # A list of activities to send when `context.Activity.DeliveryMode == 'expectsReply'` self.buffered_replies = [] @property @@ -198,7 +198,7 @@ def activity_validator(activity: Activity) -> Activity: async def logic(): nonlocal sent_non_trace_activity - if self.activity.delivery_mode == DeliveryModes.buffered_replies: + if self.activity.delivery_mode == DeliveryModes.expects_reply: responses = [] for activity in output: self.buffered_replies.append(activity) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 871e616a1..bdc8a6b41 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -578,7 +578,7 @@ async def callback(context: TurnContext): refs, callback, claims_identity=skills_identity, audience=skill_2_app_id ) - async def test_delivery_mode_buffered_replies(self): + async def test_delivery_mode_expects_reply(self): mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) settings = BotFrameworkAdapterSettings( @@ -595,7 +595,7 @@ async def callback(context: TurnContext): type=ActivityTypes.message, channel_id="emulator", service_url="http://tempuri.org/whatever", - delivery_mode=DeliveryModes.buffered_replies, + delivery_mode=DeliveryModes.expects_reply, text="hello world", ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index 68aab5ecf..2da7cc2de 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -99,7 +99,7 @@ class DeliveryModes(str, Enum): normal = "normal" notification = "notification" - buffered_replies = "bufferedReplies" + expects_reply = "expectsReply" class ContactRelationUpdateActionTypes(str, Enum): diff --git a/samples/experimental/skills-buffered/parent/bots/parent_bot.py b/samples/experimental/skills-buffered/parent/bots/parent_bot.py index 91b85b654..3df2c58f4 100644 --- a/samples/experimental/skills-buffered/parent/bots/parent_bot.py +++ b/samples/experimental/skills-buffered/parent/bots/parent_bot.py @@ -26,7 +26,7 @@ async def on_message_activity(self, turn_context: TurnContext): TurnContext.apply_conversation_reference( activity, TurnContext.get_conversation_reference(turn_context.activity) ) - activity.delivery_mode = DeliveryModes.buffered_replies + activity.delivery_mode = DeliveryModes.expects_reply activities = await self.client.post_buffered_activity( None, From 4a2140144c8bad8e974d3ff94b504e0f023e327d Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 6 Mar 2020 18:43:08 -0800 Subject: [PATCH 340/616] change to expectedReplies --- .../botbuilder/core/bot_framework_adapter.py | 4 ++-- .../botbuilder/core/bot_framework_http_client.py | 2 +- libraries/botbuilder-core/botbuilder/core/turn_context.py | 8 ++++---- .../botbuilder-core/tests/test_bot_framework_adapter.py | 4 ++-- .../botbuilder/schema/_connector_client_enums.py | 2 +- libraries/botbuilder-schema/botbuilder/schema/_models.py | 2 +- .../botbuilder-schema/botbuilder/schema/_models_py3.py | 2 +- libraries/swagger/ConnectorAPI.json | 3 ++- .../skills-buffered/parent/bots/parent_bot.py | 2 +- 9 files changed, 15 insertions(+), 14 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index b0ef89422..5c127bef1 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -460,9 +460,9 @@ async def process_activity_with_identity( # Return the buffered activities in the response. In this case, the invoker # should deserialize accordingly: # activities = [Activity().deserialize(activity) for activity in response.body] - if context.activity.delivery_mode == DeliveryModes.expects_reply: + if context.activity.delivery_mode == DeliveryModes.expect_replies: serialized_activities = [ - activity.serialize() for activity in context.buffered_replies + activity.serialize() for activity in context.buffered_reply_activties ] return InvokeResponse(status=200, body=serialized_activities) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py index de38aba91..987668b79 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py @@ -110,7 +110,7 @@ async def post_buffered_activity( ) -> [Activity]: """ Helper method to return a list of activities when an Activity is being - sent with DeliveryMode == expectsReply. + sent with DeliveryMode == expectReplies. """ response = await self.post_activity( from_bot_id, to_bot_id, to_url, service_url, conversation_id, activity diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 1b3b1d994..c22bd4fa4 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -51,8 +51,8 @@ def __init__(self, adapter_or_context, request: Activity = None): self._turn_state = {} - # A list of activities to send when `context.Activity.DeliveryMode == 'expectsReply'` - self.buffered_replies = [] + # A list of activities to send when `context.Activity.DeliveryMode == 'expectReplies'` + self.buffered_reply_activties = [] @property def turn_state(self) -> Dict[str, object]: @@ -198,10 +198,10 @@ def activity_validator(activity: Activity) -> Activity: async def logic(): nonlocal sent_non_trace_activity - if self.activity.delivery_mode == DeliveryModes.expects_reply: + if self.activity.delivery_mode == DeliveryModes.expect_replies: responses = [] for activity in output: - self.buffered_replies.append(activity) + self.buffered_reply_activties.append(activity) responses.append(ResourceResponse()) if sent_non_trace_activity: diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index bdc8a6b41..62041b7db 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -578,7 +578,7 @@ async def callback(context: TurnContext): refs, callback, claims_identity=skills_identity, audience=skill_2_app_id ) - async def test_delivery_mode_expects_reply(self): + async def test_delivery_mode_expect_replies(self): mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) settings = BotFrameworkAdapterSettings( @@ -595,7 +595,7 @@ async def callback(context: TurnContext): type=ActivityTypes.message, channel_id="emulator", service_url="http://tempuri.org/whatever", - delivery_mode=DeliveryModes.expects_reply, + delivery_mode=DeliveryModes.expect_replies, text="hello world", ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index 2da7cc2de..ba33d9c00 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -99,7 +99,7 @@ class DeliveryModes(str, Enum): normal = "normal" notification = "notification" - expects_reply = "expectsReply" + expect_replies = "expectReplies" class ContactRelationUpdateActionTypes(str, Enum): diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 2cb85d663..042c9e0bd 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -133,7 +133,7 @@ class Activity(Model): :param delivery_mode: A delivery hint to signal to the recipient alternate delivery paths for the activity. The default delivery mode is "default". Possible values include: 'normal', - 'notification' + 'notification', 'expectReplies' :type delivery_mode: str or ~botframework.connector.models.DeliveryModes :param listen_for: List of phrases and references that speech and language priming systems should listen for diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index fe583a9b8..f079ac383 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -133,7 +133,7 @@ class Activity(Model): :param delivery_mode: A delivery hint to signal to the recipient alternate delivery paths for the activity. The default delivery mode is "default". Possible values include: 'normal', - 'notification' + 'notification', 'expectReplies' :type delivery_mode: str or ~botframework.connector.models.DeliveryModes :param listen_for: List of phrases and references that speech and language priming systems should listen for diff --git a/libraries/swagger/ConnectorAPI.json b/libraries/swagger/ConnectorAPI.json index f3a5b6e49..bae96e716 100644 --- a/libraries/swagger/ConnectorAPI.json +++ b/libraries/swagger/ConnectorAPI.json @@ -2293,7 +2293,8 @@ "description": "Values for deliveryMode field", "enum": [ "normal", - "notification" + "notification", + "expectReplies" ], "type": "string", "properties": {}, diff --git a/samples/experimental/skills-buffered/parent/bots/parent_bot.py b/samples/experimental/skills-buffered/parent/bots/parent_bot.py index 3df2c58f4..1aa077624 100644 --- a/samples/experimental/skills-buffered/parent/bots/parent_bot.py +++ b/samples/experimental/skills-buffered/parent/bots/parent_bot.py @@ -26,7 +26,7 @@ async def on_message_activity(self, turn_context: TurnContext): TurnContext.apply_conversation_reference( activity, TurnContext.get_conversation_reference(turn_context.activity) ) - activity.delivery_mode = DeliveryModes.expects_reply + activity.delivery_mode = DeliveryModes.expect_replies activities = await self.client.post_buffered_activity( None, From 7f54b35353baa2893ea60dfee11569b8ec98438b Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 6 Mar 2020 19:18:30 -0800 Subject: [PATCH 341/616] spelling --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 5c127bef1..f53aa8d98 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -462,7 +462,7 @@ async def process_activity_with_identity( # activities = [Activity().deserialize(activity) for activity in response.body] if context.activity.delivery_mode == DeliveryModes.expect_replies: serialized_activities = [ - activity.serialize() for activity in context.buffered_reply_activties + activity.serialize() for activity in context.buffered_reply_activities ] return InvokeResponse(status=200, body=serialized_activities) From a78d4523bf4830fd937d95104329dc48e6a1f6e3 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 6 Mar 2020 19:19:12 -0800 Subject: [PATCH 342/616] spelling --- libraries/botbuilder-core/botbuilder/core/turn_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index c22bd4fa4..00bdf5d43 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -52,7 +52,7 @@ def __init__(self, adapter_or_context, request: Activity = None): self._turn_state = {} # A list of activities to send when `context.Activity.DeliveryMode == 'expectReplies'` - self.buffered_reply_activties = [] + self.buffered_reply_activities = [] @property def turn_state(self) -> Dict[str, object]: @@ -201,7 +201,7 @@ async def logic(): if self.activity.delivery_mode == DeliveryModes.expect_replies: responses = [] for activity in output: - self.buffered_reply_activties.append(activity) + self.buffered_reply_activities.append(activity) responses.append(ResourceResponse()) if sent_non_trace_activity: From d12613804c004cf23bcbc73839bef0a2acb2fbb5 Mon Sep 17 00:00:00 2001 From: Scott Gellock Date: Sat, 7 Mar 2020 11:51:58 -0800 Subject: [PATCH 343/616] remove beta language readme stated incorrectly that production bots should be developed with JS or .NET SDKs. Python is GA. This text was outdated. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 06705fa91..3736d6e58 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This repository contains code for the Python version of the [Microsoft Bot Frame This repo is part the [Microsoft Bot Framework](https://github.com/Microsoft/botframework) - a comprehensive framework for building enterprise-grade conversational AI experiences. -In addition to the Python SDK, Bot Builder supports creating bots in other popular programming languages like [.Net SDK](https://github.com/Microsoft/botbuilder-dotnet), [JavaScript](https://github.com/Microsoft/botbuilder-js), and [Java](https://github.com/Microsoft/botbuilder-java). Production bots should be developed using the JavaScript or .Net SDKs. +In addition to the Python SDK, Bot Builder supports creating bots in other popular programming languages like [.Net SDK](https://github.com/Microsoft/botbuilder-dotnet), [JavaScript](https://github.com/Microsoft/botbuilder-js), and [Java](https://github.com/Microsoft/botbuilder-java). To get started see the [Azure Bot Service Documentation](https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0) for the v4 SDK. From 7494bc0a9810ad760f8087797098dbd779166011 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Sun, 8 Mar 2020 10:25:16 -0700 Subject: [PATCH 344/616] Add ExpectedReplies --- .../botbuilder/core/bot_framework_adapter.py | 11 ++++++----- .../botbuilder/core/bot_framework_http_client.py | 4 ++-- .../botbuilder/schema/__init__.py | 3 +++ .../botbuilder/schema/_models.py | 15 +++++++++++++++ .../botbuilder/schema/_models_py3.py | 15 +++++++++++++++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index f53aa8d98..1a4407895 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -37,6 +37,7 @@ ConversationAccount, ConversationParameters, ConversationReference, + ExpectedReplies, TokenResponse, ResourceResponse, DeliveryModes, @@ -459,12 +460,12 @@ async def process_activity_with_identity( # Return the buffered activities in the response. In this case, the invoker # should deserialize accordingly: - # activities = [Activity().deserialize(activity) for activity in response.body] + # activities = ExpectedReplies().deserialize(response.body).activities if context.activity.delivery_mode == DeliveryModes.expect_replies: - serialized_activities = [ - activity.serialize() for activity in context.buffered_reply_activities - ] - return InvokeResponse(status=200, body=serialized_activities) + expected_replies = ExpectedReplies( + activities=context.buffered_reply_activities + ).serialize() + return InvokeResponse(status=200, body=expected_replies) return None diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py index 987668b79..dc9dd1a9e 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py @@ -6,7 +6,7 @@ from logging import Logger import aiohttp -from botbuilder.schema import Activity +from botbuilder.schema import Activity, ExpectedReplies from botframework.connector.auth import ( ChannelProvider, CredentialProvider, @@ -117,7 +117,7 @@ async def post_buffered_activity( ) if not response or (response.status / 100) != 2: return [] - return [Activity().deserialize(activity) for activity in response.body] + return ExpectedReplies().deserialize(response.body).activities async def _get_app_credentials( self, app_id: str, oauth_scope: str diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index bb3e7d75f..5fcb88ed7 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -27,6 +27,7 @@ from ._models_py3 import ConversationReference from ._models_py3 import ConversationResourceResponse from ._models_py3 import ConversationsResult + from ._models_py3 import ExpectedReplies from ._models_py3 import Entity from ._models_py3 import Error from ._models_py3 import ErrorResponse, ErrorResponseException @@ -74,6 +75,7 @@ from ._models import ConversationReference from ._models import ConversationResourceResponse from ._models import ConversationsResult + from ._models import ExpectedReplies from ._models import Entity from ._models import Error from ._models import ErrorResponse, ErrorResponseException @@ -136,6 +138,7 @@ "ConversationReference", "ConversationResourceResponse", "ConversationsResult", + "ExpectedReplies", "Entity", "Error", "ErrorResponse", diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 042c9e0bd..31e1a3f31 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -806,6 +806,21 @@ def __init__(self, **kwargs): self.conversations = kwargs.get("conversations", None) +class ExpectedReplies(Model): + """ExpectedReplies. + + :param activities: A collection of Activities that conforms to the + ExpectedReplies schema. + :type activities: list[~botframework.connector.models.Activity] + """ + + _attribute_map = {"activities": {"key": "activities", "type": "[Activity]"}} + + def __init__(self, **kwargs): + super(ExpectedReplies, self).__init__(**kwargs) + self.activities = kwargs.get("activities", None) + + class Entity(Model): """Metadata object pertaining to an activity. diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index f079ac383..792ab6c13 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -978,6 +978,21 @@ def __init__( self.conversations = conversations +class ExpectedReplies(Model): + """ExpectedReplies. + + :param activities: A collection of Activities that conforms to the + ExpectedReplies schema. + :type activities: list[~botframework.connector.models.Activity] + """ + + _attribute_map = {"activities": {"key": "activities", "type": "[Activity]"}} + + def __init__(self, *, activities=None, **kwargs) -> None: + super(ExpectedReplies, self).__init__(**kwargs) + self.activities = activities + + class Entity(Model): """Metadata object pertaining to an activity. From 807b15cf9f6aa7f04fa8c15bea52c90e18ac0035 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Sun, 8 Mar 2020 10:34:51 -0700 Subject: [PATCH 345/616] fix test --- libraries/botbuilder-core/tests/test_bot_framework_adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 62041b7db..8493814fc 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -20,6 +20,7 @@ ConversationResourceResponse, ChannelAccount, DeliveryModes, + ExpectedReplies, ) from botframework.connector.aio import ConnectorClient from botframework.connector.auth import ( @@ -613,7 +614,7 @@ async def callback(context: TurnContext): ) assert invoke_response assert invoke_response.status == 200 - activities = invoke_response.body + activities = ExpectedReplies().deserialize(invoke_response.body).activities assert len(activities) == 3 assert activities[0]["text"] == "activity 1" assert activities[1]["text"] == "activity 2" From cea0e58882ef4fdc1742eb94e0519e5363e7757c Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Sun, 8 Mar 2020 10:46:10 -0700 Subject: [PATCH 346/616] fix expectReplies unit test --- .../botbuilder-core/tests/test_bot_framework_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 8493814fc..891ecdeb5 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -616,9 +616,9 @@ async def callback(context: TurnContext): assert invoke_response.status == 200 activities = ExpectedReplies().deserialize(invoke_response.body).activities assert len(activities) == 3 - assert activities[0]["text"] == "activity 1" - assert activities[1]["text"] == "activity 2" - assert activities[2]["text"] == "activity 3" + assert activities[0].text == "activity 1" + assert activities[1].text == "activity 2" + assert activities[2].text == "activity 3" assert ( adapter.connector_client_mock.conversations.send_to_conversation.call_count == 0 From b893e8930238fa9bd4308c9777f0f6e810df4ad8 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Sun, 8 Mar 2020 17:48:29 -0700 Subject: [PATCH 347/616] fix teams connector client creation credential retrieval bug --- .../botbuilder-core/botbuilder/core/teams/teams_info.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 0395b4945..85dc73ed4 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -107,9 +107,12 @@ async def get_members(turn_context: TurnContext) -> List[TeamsChannelAccount]: async def get_teams_connector_client( turn_context: TurnContext, ) -> TeamsConnectorClient: + # A normal connector client is retrieved in order to use the credentials + # while creating a TeamsConnectorClient below + connector_client = await TeamsInfo._get_connector_client(turn_context) + return TeamsConnectorClient( - turn_context.adapter._credentials, # pylint: disable=protected-access - turn_context.activity.service_url, + connector_client.config.credentials, turn_context.activity.service_url, ) @staticmethod From cb7f7af286ad541f741c1be4104ec3fdd7b1610e Mon Sep 17 00:00:00 2001 From: Andrew Clear <1139814+clearab@users.noreply.github.com> Date: Mon, 9 Mar 2020 09:49:03 -0700 Subject: [PATCH 348/616] get member updates (#839) * get member updates * export the new type * typo of doom. * no really * I think this is all necessary? * fix missing metadata * single member working * good * remove unecessary try/catch, update comments Co-authored-by: Eric Dahlvang Co-authored-by: tracyboehrer --- .../botbuilder/core/bot_framework_adapter.py | 68 ++++++-- .../core/channel_service_handler.py | 26 +++ .../integration/aiohttp_channel_service.py | 9 + .../botbuilder/core/teams/teams_info.py | 119 ++++++++++++- .../botbuilder/schema/teams/__init__.py | 3 + .../botbuilder/schema/teams/_models.py | 22 ++- .../botbuilder/schema/teams/_models_py3.py | 30 +++- .../_conversations_operations_async.py | 163 ++++++++++++++++++ .../operations/_conversations_operations.py | 159 +++++++++++++++++ 9 files changed, 577 insertions(+), 22 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 1a4407895..3da9df2c2 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -34,6 +34,7 @@ from botbuilder.schema import ( Activity, ActivityTypes, + ChannelAccount, ConversationAccount, ConversationParameters, ConversationReference, @@ -746,30 +747,59 @@ async def get_conversation_members(self, context: TurnContext): :param context: The context object for the turn :type context: :class:`botbuilder.core.TurnContext` - :raises: An exception error + :raises: TypeError if missing service_url or conversation.id :return: List of members of the current conversation """ - try: - if not context.activity.service_url: - raise TypeError( - "BotFrameworkAdapter.get_conversation_members(): missing service_url" - ) - if ( - not context.activity.conversation - or not context.activity.conversation.id - ): - raise TypeError( - "BotFrameworkAdapter.get_conversation_members(): missing conversation or " - "conversation.id" - ) - client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] - return await client.conversations.get_conversation_members( - context.activity.conversation.id + if not context.activity.service_url: + raise TypeError( + "BotFrameworkAdapter.get_conversation_members(): missing service_url" ) - except Exception as error: - raise error + if not context.activity.conversation or not context.activity.conversation.id: + raise TypeError( + "BotFrameworkAdapter.get_conversation_members(): missing conversation or " + "conversation.id" + ) + + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] + return await client.conversations.get_conversation_members( + context.activity.conversation.id + ) + + async def get_conversation_member( + self, context: TurnContext, member_id: str + ) -> ChannelAccount: + """ + Retrieve a member of a current conversation. + + :param context: The context object for the turn + :type context: :class:`botbuilder.core.TurnContext` + :param member_id: The member Id + :type member_id: str + + :raises: A TypeError if missing member_id, service_url, or conversation.id + + :return: A member of the current conversation + """ + if not context.activity.service_url: + raise TypeError( + "BotFrameworkAdapter.get_conversation_member(): missing service_url" + ) + if not context.activity.conversation or not context.activity.conversation.id: + raise TypeError( + "BotFrameworkAdapter.get_conversation_member(): missing conversation or " + "conversation.id" + ) + if not member_id: + raise TypeError( + "BotFrameworkAdapter.get_conversation_member(): missing memberId" + ) + + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] + return await client.conversations.get_conversation_member( + context.activity.conversation.id, member_id + ) async def get_conversations( self, diff --git a/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py index 9d9fce6df..0cf90327c 100644 --- a/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py @@ -104,6 +104,14 @@ async def handle_get_conversation_members( claims_identity = await self._authenticate(auth_header) return await self.on_get_conversation_members(claims_identity, conversation_id) + async def handle_get_conversation_member( + self, auth_header, conversation_id, member_id + ) -> ChannelAccount: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversation_member( + claims_identity, conversation_id, member_id + ) + async def handle_get_conversation_paged_members( self, auth_header, @@ -343,6 +351,24 @@ async def on_get_conversation_members( """ raise BotActionNotImplementedError() + async def on_get_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ) -> ChannelAccount: + """ + get_conversation_member() API for Skill. + + Enumerate the members of a conversation. + + This REST API takes a ConversationId and returns a list of ChannelAccount + objects representing the members of the conversation. + + :param claims_identity: + :param conversation_id: + :param member_id: + :return: + """ + raise BotActionNotImplementedError() + async def on_get_conversation_paged_members( self, claims_identity: ClaimsIdentity, diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py index af2545d89..c4e8b3b2f 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -132,6 +132,15 @@ async def get_conversation_members(request: Request): return get_serialized_response(result) + @routes.get(base_url + "/v3/conversations/{conversation_id}/members/{member_id}") + async def get_conversation_member(request: Request): + result = await handler.handle_get_conversation_member( + request.headers.get("Authorization"), + request.match_info["conversation_id", "member_id"], + ) + + return get_serialized_response(result) + @routes.get(base_url + "/v3/conversations/{conversation_id}/pagedmembers") async def get_conversation_paged_members(request: Request): # TODO: continuation token? page size? diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 85dc73ed4..3593650c4 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -9,6 +9,7 @@ TeamDetails, TeamsChannelData, TeamsChannelAccount, + TeamsPagedMembersResult, ) from botframework.connector.aio import ConnectorClient from botframework.connector.teams.teams_connector_client import TeamsConnectorClient @@ -79,7 +80,9 @@ async def get_team_channels( return teams_connector.teams.get_teams_channels(team_id).conversations @staticmethod - async def get_team_members(turn_context: TurnContext, team_id: str = ""): + async def get_team_members( + turn_context: TurnContext, team_id: str = "" + ) -> List[TeamsChannelAccount]: if not team_id: team_id = TeamsInfo.get_team_id(turn_context) @@ -103,6 +106,78 @@ async def get_members(turn_context: TurnContext) -> List[TeamsChannelAccount]: return await TeamsInfo.get_team_members(turn_context, team_id) + @staticmethod + async def get_paged_team_members( + turn_context: TurnContext, + team_id: str = "", + continuation_token: str = None, + page_size: int = None, + ) -> List[TeamsPagedMembersResult]: + if not team_id: + team_id = TeamsInfo.get_team_id(turn_context) + + if not team_id: + raise TypeError( + "TeamsInfo.get_team_members: method is only valid within the scope of MS Teams Team." + ) + + connector_client = await TeamsInfo._get_connector_client(turn_context) + return await TeamsInfo._get_paged_members( + connector_client, + turn_context.activity.conversation.id, + continuation_token, + page_size, + ) + + @staticmethod + async def get_paged_members( + turn_context: TurnContext, continuation_token: str = None, page_size: int = None + ) -> List[TeamsPagedMembersResult]: + + team_id = TeamsInfo.get_team_id(turn_context) + if not team_id: + conversation_id = turn_context.activity.conversation.id + connector_client = await TeamsInfo._get_connector_client(turn_context) + return await TeamsInfo._get_paged_members( + connector_client, conversation_id, continuation_token, page_size + ) + + return await TeamsInfo.get_paged_team_members(turn_context, team_id, page_size) + + @staticmethod + async def get_team_member( + turn_context: TurnContext, team_id: str = "", member_id: str = None + ) -> TeamsChannelAccount: + if not team_id: + team_id = TeamsInfo.get_team_id(turn_context) + + if not team_id: + raise TypeError( + "TeamsInfo.get_team_member: method is only valid within the scope of MS Teams Team." + ) + + if not member_id: + raise TypeError("TeamsInfo.get_team_member: method requires a member_id") + + connector_client = await TeamsInfo._get_connector_client(turn_context) + return await TeamsInfo._get_member( + connector_client, turn_context.activity.conversation.id, member_id + ) + + @staticmethod + async def get_member( + turn_context: TurnContext, member_id: str + ) -> TeamsChannelAccount: + team_id = TeamsInfo.get_team_id(turn_context) + if not team_id: + conversation_id = turn_context.activity.conversation.id + connector_client = await TeamsInfo._get_connector_client(turn_context) + return await TeamsInfo._get_member( + connector_client, conversation_id, member_id + ) + + return await TeamsInfo.get_team_member(turn_context, team_id, member_id) + @staticmethod async def get_teams_connector_client( turn_context: TurnContext, @@ -151,3 +226,45 @@ async def _get_members( ) return teams_members + + @staticmethod + async def _get_paged_members( + connector_client: ConnectorClient, + conversation_id: str, + continuation_token: str = None, + page_size: int = None, + ) -> List[TeamsPagedMembersResult]: + if connector_client is None: + raise TypeError( + "TeamsInfo._get_paged_members.connector_client: cannot be None." + ) + + if not conversation_id: + raise TypeError( + "TeamsInfo._get_paged_members.conversation_id: cannot be empty." + ) + + return await connector_client.conversations.get_teams_conversation_paged_members( + conversation_id, continuation_token, page_size + ) + + @staticmethod + async def _get_member( + connector_client: ConnectorClient, conversation_id: str, member_id: str + ) -> TeamsChannelAccount: + if connector_client is None: + raise TypeError("TeamsInfo._get_member.connector_client: cannot be None.") + + if not conversation_id: + raise TypeError("TeamsInfo._get_member.conversation_id: cannot be empty.") + + if not member_id: + raise TypeError("TeamsInfo._get_member.member_id: cannot be empty.") + + member: TeamsChannelAccount = await connector_client.conversations.get_conversation_member( + conversation_id, member_id + ) + + return TeamsChannelAccount().deserialize( + dict(member.serialize(), **member.additional_properties) + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index bae8bf5cf..0f1f0edfe 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -65,6 +65,7 @@ from ._models_py3 import TeamInfo from ._models_py3 import TeamsChannelAccount from ._models_py3 import TeamsChannelData + from ._models_py3 import TeamsPagedMembersResult from ._models_py3 import TenantInfo except (SyntaxError, ImportError): from ._models import AppBasedLinkQuery @@ -122,6 +123,7 @@ from ._models import TeamInfo from ._models import TeamsChannelAccount from ._models import TeamsChannelData + from ._models import TeamsPagedMembersResult from ._models import TenantInfo __all__ = [ @@ -180,5 +182,6 @@ "TeamInfo", "TeamsChannelAccount", "TeamsChannelData", + "TeamsPagedMembersResult", "TenantInfo", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 953ce4ec2..29372b73b 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -10,7 +10,7 @@ # -------------------------------------------------------------------------- from msrest.serialization import Model -from botbuilder.schema import Activity +from botbuilder.schema import Activity, PagedMembersResult, TeamsChannelAccount class AppBasedLinkQuery(Model): @@ -1557,6 +1557,26 @@ def __init__(self, **kwargs): self.user_principal_name = kwargs.get("userPrincipalName", None) +class TeamsPagedMembersResult(PagedMembersResult): + """Page of members for Teams. + + :param continuation_token: Paging token + :type continuation_token: str + :param members: The Teams Channel Accounts. + :type members: list[~botframework.connector.models.TeamsChannelAccount] + """ + + _attribute_map = { + "continuation_token": {"key": "continuationToken", "type": "str"}, + "members": {"key": "members", "type": "[TeamsChannelAccount]"}, + } + + def __init__(self, **kwargs): + super(TeamsPagedMembersResult, self).__init__(**kwargs) + self.continuation_token = kwargs.get("continuation_token", None) + self.members = kwargs.get("members", None) + + class TeamsChannelData(Model): """Channel data specific to messages received in Microsoft Teams. diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 5f868f813..b18d58a95 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -10,7 +10,7 @@ # -------------------------------------------------------------------------- from msrest.serialization import Model -from botbuilder.schema import Activity, Attachment, ChannelAccount +from botbuilder.schema import Activity, Attachment, ChannelAccount, PagedMembersResult class TaskModuleRequest(Model): @@ -1834,6 +1834,34 @@ def __init__( self.user_principal_name = user_principal_name +class TeamsPagedMembersResult(PagedMembersResult): + """Page of members for Teams. + + :param continuation_token: Paging token + :type continuation_token: str + :param members: The Teams Channel Accounts. + :type members: list[~botframework.connector.models.TeamsChannelAccount] + """ + + _attribute_map = { + "continuation_token": {"key": "continuationToken", "type": "str"}, + "members": {"key": "members", "type": "[TeamsChannelAccount]"}, + } + + def __init__( + self, + *, + continuation_token: str = None, + members: [TeamsChannelAccount] = None, + **kwargs + ) -> None: + super(TeamsPagedMembersResult, self).__init__( + continuation_token=continuation_token, members=members, **kwargs + ) + self.continuation_token = continuation_token + self.members = members + + class TeamsChannelData(Model): """Channel data specific to messages received in Microsoft Teams. diff --git a/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py b/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py index 6afdf82c4..db5e00ae0 100644 --- a/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py @@ -677,6 +677,77 @@ async def get_conversation_members( "url": "/v3/conversations/{conversationId}/members" } + async def get_conversation_member( + self, + conversation_id, + member_id, + custom_headers=None, + raw=False, + **operation_config + ): + """GetConversationMember. + + Get a member of a conversation. + This REST API takes a ConversationId and memberId and returns a + ChannelAccount object representing the member of the conversation. + + :param conversation_id: Conversation Id + :type conversation_id: str + :param member_id: Member Id + :type member_id: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: list or ClientRawResponse if raw=true + :rtype: list[~botframework.connector.models.ChannelAccount] or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`ErrorResponseException` + """ + # Construct URL + url = self.get_conversation_member.metadata["url"] + path_format_arguments = { + "conversationId": self._serialize.url( + "conversation_id", conversation_id, "str" + ), + "memberId": self._serialize.url("member_id", member_id, "str"), + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = await self._client.async_send( + request, stream=False, **operation_config + ) + + if response.status_code not in [200]: + raise models.ErrorResponseException(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("ChannelAccount", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + get_conversation_member.metadata = { + "url": "/v3/conversations/{conversationId}/members/{memberId}" + } + async def get_conversation_paged_members( self, conversation_id, @@ -770,6 +841,98 @@ async def get_conversation_paged_members( "url": "/v3/conversations/{conversationId}/pagedmembers" } + async def get_teams_conversation_paged_members( + self, + conversation_id, + page_size=None, + continuation_token=None, + custom_headers=None, + raw=False, + **operation_config + ): + """GetTeamsConversationPagedMembers. + + Enumerate the members of a Teams conversation one page at a time. + This REST API takes a ConversationId. Optionally a pageSize and/or + continuationToken can be provided. It returns a PagedMembersResult, + which contains an array + of ChannelAccounts representing the members of the conversation and a + continuation token that can be used to get more values. + One page of ChannelAccounts records are returned with each call. The + number of records in a page may vary between channels and calls. The + pageSize parameter can be used as + a suggestion. If there are no additional results the response will not + contain a continuation token. If there are no members in the + conversation the Members will be empty or not present in the response. + A response to a request that has a continuation token from a prior + request may rarely return members from a previous request. + + :param conversation_id: Conversation ID + :type conversation_id: str + :param page_size: Suggested page size + :type page_size: int + :param continuation_token: Continuation Token + :type continuation_token: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: PagedMembersResult or ClientRawResponse if raw=true + :rtype: ~botframework.connector.models.PagedMembersResult or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + # Construct URL + url = self.get_conversation_paged_members.metadata["url"] + path_format_arguments = { + "conversationId": self._serialize.url( + "conversation_id", conversation_id, "str" + ) + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + if page_size is not None: + query_parameters["pageSize"] = self._serialize.query( + "page_size", page_size, "int" + ) + if continuation_token is not None: + query_parameters["continuationToken"] = self._serialize.query( + "continuation_token", continuation_token, "str" + ) + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = await self._client.async_send( + request, stream=False, **operation_config + ) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("TeamsPagedMembersResult", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + get_conversation_paged_members.metadata = { + "url": "/v3/conversations/{conversationId}/pagedmembers" + } + async def delete_conversation_member( self, conversation_id, diff --git a/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py b/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py index c5a8bb68c..5fab0cc22 100644 --- a/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py +++ b/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py @@ -655,6 +655,75 @@ def get_conversation_members( "url": "/v3/conversations/{conversationId}/members" } + def get_conversation_member( + self, + conversation_id, + member_id, + custom_headers=None, + raw=False, + **operation_config + ): + """GetConversationMember. + + Get a member of a conversation. + This REST API takes a ConversationId and memberId and returns a + ChannelAccount object representing the member of the conversation. + + :param conversation_id: Conversation Id + :type conversation_id: str + :param member_id: Member Id + :type member_id: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: list or ClientRawResponse if raw=true + :rtype: list[~botframework.connector.models.ChannelAccount] or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`ErrorResponseException` + """ + # Construct URL + url = self.get_conversation_member.metadata["url"] + path_format_arguments = { + "conversationId": self._serialize.url( + "conversation_id", conversation_id, "str" + ), + "memberId": self._serialize.url("member_id", member_id, "str"), + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200]: + raise models.ErrorResponseException(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("ChannelAccount", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + get_conversation_member.metadata = { + "url": "/v3/conversations/{conversationId}/members/{memberId}" + } + def get_conversation_paged_members( self, conversation_id, @@ -745,6 +814,96 @@ def get_conversation_paged_members( "url": "/v3/conversations/{conversationId}/pagedmembers" } + def get_teams_conversation_paged_members( + self, + conversation_id, + page_size=None, + continuation_token=None, + custom_headers=None, + raw=False, + **operation_config + ): + """GetTeamsConversationPagedMembers. + + Enumerate the members of a Teams conversation one page at a time. + This REST API takes a ConversationId. Optionally a pageSize and/or + continuationToken can be provided. It returns a PagedMembersResult, + which contains an array + of ChannelAccounts representing the members of the conversation and a + continuation token that can be used to get more values. + One page of ChannelAccounts records are returned with each call. The + number of records in a page may vary between channels and calls. The + pageSize parameter can be used as + a suggestion. If there are no additional results the response will not + contain a continuation token. If there are no members in the + conversation the Members will be empty or not present in the response. + A response to a request that has a continuation token from a prior + request may rarely return members from a previous request. + + :param conversation_id: Conversation ID + :type conversation_id: str + :param page_size: Suggested page size + :type page_size: int + :param continuation_token: Continuation Token + :type continuation_token: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: PagedMembersResult or ClientRawResponse if raw=true + :rtype: ~botframework.connector.models.PagedMembersResult or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + # Construct URL + url = self.get_conversation_paged_members.metadata["url"] + path_format_arguments = { + "conversationId": self._serialize.url( + "conversation_id", conversation_id, "str" + ) + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + if page_size is not None: + query_parameters["pageSize"] = self._serialize.query( + "page_size", page_size, "int" + ) + if continuation_token is not None: + query_parameters["continuationToken"] = self._serialize.query( + "continuation_token", continuation_token, "str" + ) + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("TeamsPagedMembersResult", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + get_conversation_paged_members.metadata = { + "url": "/v3/conversations/{conversationId}/pagedmembers" + } + def delete_conversation_member( # pylint: disable=inconsistent-return-statements self, conversation_id, From 10fdfb3dc4d63adb437a91cb07c25c807d751262 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 9 Mar 2020 12:45:52 -0700 Subject: [PATCH 349/616] Add ephemeral to DeliveryModes (parity) (#847) --- .../botbuilder/schema/_connector_client_enums.py | 1 + libraries/botbuilder-schema/botbuilder/schema/_models.py | 4 ++-- libraries/botbuilder-schema/botbuilder/schema/_models_py3.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index ba33d9c00..d2027a277 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -100,6 +100,7 @@ class DeliveryModes(str, Enum): normal = "normal" notification = "notification" expect_replies = "expectReplies" + ephemeral = "ephemeral" class ContactRelationUpdateActionTypes(str, Enum): diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 31e1a3f31..1b9599a61 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -133,7 +133,7 @@ class Activity(Model): :param delivery_mode: A delivery hint to signal to the recipient alternate delivery paths for the activity. The default delivery mode is "default". Possible values include: 'normal', - 'notification', 'expectReplies' + 'notification', 'expectReplies', 'ephemeral' :type delivery_mode: str or ~botframework.connector.models.DeliveryModes :param listen_for: List of phrases and references that speech and language priming systems should listen for @@ -146,7 +146,7 @@ class Activity(Model): :type semantic_action: ~botframework.connector.models.SemanticAction :param caller_id: A string containing an IRI identifying the caller of a bot. This field is not intended to be transmitted over the wire, but is - instead populated by bots and clients based on cryptographically + instead populated by bots and clients based on cryptographically verifiable data that asserts the identity of the callers (e.g. tokens). :type caller_id: str """ diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 792ab6c13..709c352f0 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -133,7 +133,7 @@ class Activity(Model): :param delivery_mode: A delivery hint to signal to the recipient alternate delivery paths for the activity. The default delivery mode is "default". Possible values include: 'normal', - 'notification', 'expectReplies' + 'notification', 'expectReplies', 'ephemeral' :type delivery_mode: str or ~botframework.connector.models.DeliveryModes :param listen_for: List of phrases and references that speech and language priming systems should listen for From 4dd096622e32fd8edf9906e192e229c0a26445ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 9 Mar 2020 18:04:51 -0700 Subject: [PATCH 350/616] Axsuarez/sso protocol (#793) * Initial changes for SSO, schema done * fixes for schema * black: fixes for schema * Regen token-api * schema cleanup * Activity Handler changes pending * SSO code complete testing pending. * black: Merge w/master and auth changes * Adding token exchange resource in SignInCard * Fix on get_sign_in_resource * Adding credentials for token_api_client * Adding temporal fix by duplicating TokenExchangeResource in schema * Serialization work-around * sso: Small fixes on BFAdapter * sso: small fixes in ActivityHandler * exchange token noe receives creds * black: sso fixes * sso: updated TestAdapter * Added latest js fixes and swagat samples for testing * removed unused file in sso sample * fixed error introduced in merge with master * Activity Handler cleanup * pylint: Activity Handler cleanup * Removed StatusCodes Co-authored-by: tracyboehrer --- .../botbuilder/core/__init__.py | 2 + .../botbuilder/core/activity_handler.py | 83 +- .../botbuilder/core/adapters/test_adapter.py | 1110 +++++++++-------- .../botbuilder/core/bot_framework_adapter.py | 149 ++- .../core/bot_framework_http_client.py | 25 +- .../core/extended_user_token_provider.py | 190 +++ .../botbuilder/core/serializer_helper.py | 37 + .../core/teams/teams_activity_handler.py | 67 +- .../tests/test_activity_handler.py | 71 ++ .../dialogs/prompts/oauth_prompt.py | 182 ++- .../botbuilder/schema/__init__.py | 9 + .../botbuilder/schema/_models_py3.py | 123 +- .../botbuilder/schema/_sign_in_enums.py | 18 + .../botframework/connector/auth/__init__.py | 1 + .../_bot_sign_in_operations_async.py | 82 +- .../_user_token_operations_async.py | 90 +- .../connector/token_api/models/__init__.py | 9 + .../connector/token_api/models/_models.py | 70 ++ .../connector/token_api/models/_models_py3.py | 74 ++ .../operations/_bot_sign_in_operations.py | 79 +- .../operations/_user_token_operations.py | 85 +- libraries/swagger/TokenAPI.json | 162 ++- .../sso/child/adapter_with_error_handler.py | 64 + samples/experimental/sso/child/app.py | 102 ++ .../experimental/sso/child/bots/__init__.py | 4 + .../experimental/sso/child/bots/child_bot.py | 73 ++ samples/experimental/sso/child/config.py | 16 + .../sso/child/dialogs/__init__.py | 5 + .../sso/child/dialogs/main_dialog.py | 55 + .../sso/child/helpers/__init__.py | 6 + .../sso/child/helpers/dialog_helper.py | 19 + .../sso/parent/ReadMeForSSOTesting.md | 37 + samples/experimental/sso/parent/app.py | 108 ++ .../experimental/sso/parent/bots/__init__.py | 4 + .../sso/parent/bots/parent_bot.py | 221 ++++ samples/experimental/sso/parent/config.py | 19 + .../sso/parent/dialogs/__init__.py | 5 + .../sso/parent/dialogs/main_dialog.py | 56 + .../sso/parent/helpers/__init__.py | 6 + .../sso/parent/helpers/dialog_helper.py | 19 + .../experimental/sso/parent/skill_client.py | 30 + 41 files changed, 2967 insertions(+), 600 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py create mode 100644 libraries/botbuilder-core/botbuilder/core/serializer_helper.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py create mode 100644 samples/experimental/sso/child/adapter_with_error_handler.py create mode 100755 samples/experimental/sso/child/app.py create mode 100755 samples/experimental/sso/child/bots/__init__.py create mode 100755 samples/experimental/sso/child/bots/child_bot.py create mode 100755 samples/experimental/sso/child/config.py create mode 100755 samples/experimental/sso/child/dialogs/__init__.py create mode 100755 samples/experimental/sso/child/dialogs/main_dialog.py create mode 100755 samples/experimental/sso/child/helpers/__init__.py create mode 100755 samples/experimental/sso/child/helpers/dialog_helper.py create mode 100644 samples/experimental/sso/parent/ReadMeForSSOTesting.md create mode 100755 samples/experimental/sso/parent/app.py create mode 100755 samples/experimental/sso/parent/bots/__init__.py create mode 100755 samples/experimental/sso/parent/bots/parent_bot.py create mode 100755 samples/experimental/sso/parent/config.py create mode 100755 samples/experimental/sso/parent/dialogs/__init__.py create mode 100644 samples/experimental/sso/parent/dialogs/main_dialog.py create mode 100755 samples/experimental/sso/parent/helpers/__init__.py create mode 100755 samples/experimental/sso/parent/helpers/dialog_helper.py create mode 100644 samples/experimental/sso/parent/skill_client.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index cdac7c42c..c65569f52 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -19,6 +19,7 @@ from .card_factory import CardFactory from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler from .conversation_state import ConversationState +from .extended_user_token_provider import ExtendedUserTokenProvider from .intent_score import IntentScore from .invoke_response import InvokeResponse from .bot_framework_http_client import BotFrameworkHttpClient @@ -59,6 +60,7 @@ "ChannelServiceHandler", "ConversationState", "conversation_reference_extension", + "ExtendedUserTokenProvider", "IntentScore", "InvokeResponse", "BotFrameworkHttpClient", diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index cecab9205..c41ba7e1d 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -1,8 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - -from botbuilder.schema import ActivityTypes, ChannelAccount, MessageReaction +from http import HTTPStatus +from typing import List, Union + +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + MessageReaction, + SignInConstants, +) +from .serializer_helper import serializer_helper +from .bot_framework_adapter import BotFrameworkAdapter +from .invoke_response import InvokeResponse from .turn_context import TurnContext @@ -58,6 +68,16 @@ async def on_turn(self, turn_context: TurnContext): await self.on_message_reaction_activity(turn_context) elif turn_context.activity.type == ActivityTypes.event: await self.on_event_activity(turn_context) + elif turn_context.activity.type == ActivityTypes.invoke: + invoke_response = await self.on_invoke_activity(turn_context) + + # If OnInvokeActivityAsync has already sent an InvokeResponse, do not send another one. + if invoke_response and not turn_context.turn_state.get( + BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access + ): + await turn_context.send_activity( + Activity(value=invoke_response, type=ActivityTypes.invoke_response) + ) elif turn_context.activity.type == ActivityTypes.end_of_conversation: await self.on_end_of_conversation_activity(turn_context) else: @@ -269,7 +289,7 @@ async def on_event_activity(self, turn_context: TurnContext): The meaning of an event activity is defined by the event activity name property, which is meaningful within the scope of a channel. """ - if turn_context.activity.name == "tokens/response": + if turn_context.activity.name == SignInConstants.token_response_event_name: return await self.on_token_response_event(turn_context) return await self.on_event(turn_context) @@ -344,3 +364,58 @@ async def on_unrecognized_activity_type( # pylint: disable=unused-argument conversation update, message reaction, or event activity, it calls this method. """ return + + async def on_invoke_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext + ) -> Union[InvokeResponse, None]: + """ + Registers an activity event handler for the _invoke_ event, emitted for every incoming event activity. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + + :returns: A task that represents the work queued to execute + """ + try: + if ( + turn_context.activity.name + == SignInConstants.verify_state_operation_name + or turn_context.activity.name + == SignInConstants.token_exchange_operation_name + ): + await self.on_sign_in_invoke(turn_context) + return self._create_invoke_response() + + raise _InvokeResponseException(HTTPStatus.NOT_IMPLEMENTED) + except _InvokeResponseException as invoke_exception: + return invoke_exception.create_invoke_response() + + async def on_sign_in_invoke( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + """ + Invoked when a signin/verifyState or signin/tokenExchange event is received when the base behavior of + on_invoke_activity(TurnContext{InvokeActivity}) is used. + If using an OAuthPrompt, override this method to forward this Activity"/ to the current dialog. + By default, this method does nothing. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + + :returns: A task that represents the work queued to execute + """ + raise _InvokeResponseException(HTTPStatus.NOT_IMPLEMENTED) + + @staticmethod + def _create_invoke_response(body: object = None) -> InvokeResponse: + return InvokeResponse(status=int(HTTPStatus.OK), body=serializer_helper(body)) + + +class _InvokeResponseException(Exception): + def __init__(self, status_code: HTTPStatus, body: object = None): + super(_InvokeResponseException, self).__init__() + self._status_code = status_code + self._body = body + + def create_invoke_response(self) -> InvokeResponse: + return InvokeResponse(status=int(self._status_code), body=self._body) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 7df9c2506..c731d6ada 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -1,495 +1,615 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# TODO: enable this in the future -# With python 3.7 the line below will allow to do Postponed Evaluation of Annotations. See PEP 563 -# from __future__ import annotations - -import asyncio -import inspect -from datetime import datetime -from typing import Awaitable, Coroutine, Dict, List, Callable, Union -from copy import copy -from threading import Lock -from botbuilder.schema import ( - ActivityTypes, - Activity, - ConversationAccount, - ConversationReference, - ChannelAccount, - ResourceResponse, - TokenResponse, -) -from botframework.connector.auth import ClaimsIdentity, AppCredentials -from ..bot_adapter import BotAdapter -from ..turn_context import TurnContext -from ..user_token_provider import UserTokenProvider - - -class UserToken: - def __init__( - self, - connection_name: str = None, - user_id: str = None, - channel_id: str = None, - token: str = None, - ): - self.connection_name = connection_name - self.user_id = user_id - self.channel_id = channel_id - self.token = token - - def equals_key(self, rhs: "UserToken"): - return ( - rhs is not None - and self.connection_name == rhs.connection_name - and self.user_id == rhs.user_id - and self.channel_id == rhs.channel_id - ) - - -class TokenMagicCode: - def __init__(self, key: UserToken = None, magic_code: str = None): - self.key = key - self.magic_code = magic_code - - -class TestAdapter(BotAdapter, UserTokenProvider): - __test__ = False - - def __init__( - self, - logic: Coroutine = None, - template_or_conversation: Union[Activity, ConversationReference] = None, - send_trace_activities: bool = False, - ): # pylint: disable=unused-argument - """ - Creates a new TestAdapter instance. - :param logic: - :param conversation: A reference to the conversation to begin the adapter state with. - """ - super(TestAdapter, self).__init__() - self.logic = logic - self._next_id: int = 0 - self._user_tokens: List[UserToken] = [] - self._magic_codes: List[TokenMagicCode] = [] - self._conversation_lock = Lock() - self.activity_buffer: List[Activity] = [] - self.updated_activities: List[Activity] = [] - self.deleted_activities: List[ConversationReference] = [] - self.send_trace_activities = send_trace_activities - - self.template = ( - template_or_conversation - if isinstance(template_or_conversation, Activity) - else Activity( - channel_id="test", - service_url="https://test.com", - from_property=ChannelAccount(id="User1", name="user"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount(id="Convo1"), - ) - ) - - if isinstance(template_or_conversation, ConversationReference): - self.template.channel_id = template_or_conversation.channel_id - - async def process_activity( - self, activity: Activity, logic: Callable[[TurnContext], Awaitable] - ): - self._conversation_lock.acquire() - try: - # ready for next reply - if activity.type is None: - activity.type = ActivityTypes.message - - activity.channel_id = self.template.channel_id - activity.from_property = self.template.from_property - activity.recipient = self.template.recipient - activity.conversation = self.template.conversation - activity.service_url = self.template.service_url - - activity.id = str((self._next_id)) - self._next_id += 1 - finally: - self._conversation_lock.release() - - activity.timestamp = activity.timestamp or datetime.utcnow() - await self.run_pipeline(TurnContext(self, activity), logic) - - async def send_activities( - self, context, activities: List[Activity] - ) -> List[ResourceResponse]: - """ - INTERNAL: called by the logic under test to send a set of activities. These will be buffered - to the current `TestFlow` instance for comparison against the expected results. - :param context: - :param activities: - :return: - """ - - def id_mapper(activity): - self.activity_buffer.append(activity) - self._next_id += 1 - return ResourceResponse(id=str(self._next_id)) - - return [ - id_mapper(activity) - for activity in activities - if self.send_trace_activities or activity.type != "trace" - ] - - async def delete_activity(self, context, reference: ConversationReference): - """ - INTERNAL: called by the logic under test to delete an existing activity. These are simply - pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn - completes. - :param reference: - :return: - """ - self.deleted_activities.append(reference) - - async def update_activity(self, context, activity: Activity): - """ - INTERNAL: called by the logic under test to replace an existing activity. These are simply - pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn - completes. - :param activity: - :return: - """ - self.updated_activities.append(activity) - - async def continue_conversation( - self, - reference: ConversationReference, - callback: Callable, - bot_id: str = None, - claims_identity: ClaimsIdentity = None, - audience: str = None, - ): - """ - The `TestAdapter` just calls parent implementation. - :param reference: - :param callback: - :param bot_id: - :param claims_identity: - :return: - """ - await super().continue_conversation( - reference, callback, bot_id, claims_identity, audience - ) - - async def receive_activity(self, activity): - """ - INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. - This will cause the adapters middleware pipe to be run and it's logic to be called. - :param activity: - :return: - """ - if isinstance(activity, str): - activity = Activity(type="message", text=activity) - # Initialize request. - request = copy(self.template) - - for key, value in vars(activity).items(): - if value is not None and key != "additional_properties": - setattr(request, key, value) - - request.type = request.type or ActivityTypes.message - if not request.id: - self._next_id += 1 - request.id = str(self._next_id) - - # Create context object and run middleware. - context = TurnContext(self, request) - return await self.run_pipeline(context, self.logic) - - def get_next_activity(self) -> Activity: - return self.activity_buffer.pop(0) - - async def send(self, user_says) -> object: - """ - Sends something to the bot. This returns a new `TestFlow` instance which can be used to add - additional steps for inspecting the bots reply and then sending additional activities. - :param user_says: - :return: A new instance of the TestFlow object - """ - return TestFlow(await self.receive_activity(user_says), self) - - async def test( - self, user_says, expected, description=None, timeout=None - ) -> "TestFlow": - """ - Send something to the bot and expects the bot to return with a given reply. This is simply a - wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a - helper is provided. - :param user_says: - :param expected: - :param description: - :param timeout: - :return: - """ - test_flow = await self.send(user_says) - test_flow = await test_flow.assert_reply(expected, description, timeout) - return test_flow - - async def tests(self, *args): - """ - Support multiple test cases without having to manually call `test()` repeatedly. This is a - convenience layer around the `test()`. Valid args are either lists or tuples of parameters - :param args: - :return: - """ - for arg in args: - description = None - timeout = None - if len(arg) >= 3: - description = arg[2] - if len(arg) == 4: - timeout = arg[3] - await self.test(arg[0], arg[1], description, timeout) - - def add_user_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - token: str, - magic_code: str = None, - ): - key = UserToken() - key.channel_id = channel_id - key.connection_name = connection_name - key.user_id = user_id - key.token = token - - if not magic_code: - self._user_tokens.append(key) - else: - code = TokenMagicCode() - code.key = key - code.magic_code = magic_code - self._magic_codes.append(code) - - async def get_user_token( - self, - context: TurnContext, - connection_name: str, - magic_code: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> TokenResponse: - key = UserToken() - key.channel_id = context.activity.channel_id - key.connection_name = connection_name - key.user_id = context.activity.from_property.id - - if magic_code: - magic_code_record = list( - filter(lambda x: key.equals_key(x.key), self._magic_codes) - ) - if magic_code_record and magic_code_record[0].magic_code == magic_code: - # Move the token to long term dictionary. - self.add_user_token( - connection_name, - key.channel_id, - key.user_id, - magic_code_record[0].key.token, - ) - - # Remove from the magic code list. - idx = self._magic_codes.index(magic_code_record[0]) - self._magic_codes = [self._magic_codes.pop(idx)] - - match = [token for token in self._user_tokens if key.equals_key(token)] - - if match: - return TokenResponse( - connection_name=match[0].connection_name, - token=match[0].token, - expiration=None, - ) - # Not found. - return None - - async def sign_out_user( - self, - context: TurnContext, - connection_name: str = None, - user_id: str = None, - oauth_app_credentials: AppCredentials = None, - ): - channel_id = context.activity.channel_id - user_id = context.activity.from_property.id - - new_records = [] - for token in self._user_tokens: - if ( - token.channel_id != channel_id - or token.user_id != user_id - or (connection_name and connection_name != token.connection_name) - ): - new_records.append(token) - self._user_tokens = new_records - - async def get_oauth_sign_in_link( - self, - context: TurnContext, - connection_name: str, - final_redirect: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> str: - return ( - f"https://fake.com/oauthsignin" - f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" - ) - - async def get_token_status( - self, - context: TurnContext, - connection_name: str = None, - user_id: str = None, - include_filter: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> Dict[str, TokenResponse]: - return None - - async def get_aad_tokens( - self, - context: TurnContext, - connection_name: str, - resource_urls: List[str], - user_id: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> Dict[str, TokenResponse]: - return None - - -class TestFlow: - __test__ = False - - def __init__(self, previous: Callable, adapter: TestAdapter): - """ - INTERNAL: creates a new TestFlow instance. - :param previous: - :param adapter: - """ - self.previous = previous - self.adapter = adapter - - async def test( - self, user_says, expected, description=None, timeout=None - ) -> "TestFlow": - """ - Send something to the bot and expects the bot to return with a given reply. This is simply a - wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a - helper is provided. - :param user_says: - :param expected: - :param description: - :param timeout: - :return: - """ - test_flow = await self.send(user_says) - return await test_flow.assert_reply( - expected, description or f'test("{user_says}", "{expected}")', timeout - ) - - async def send(self, user_says) -> "TestFlow": - """ - Sends something to the bot. - :param user_says: - :return: - """ - - async def new_previous(): - nonlocal self, user_says - if callable(self.previous): - await self.previous() - await self.adapter.receive_activity(user_says) - - return TestFlow(await new_previous(), self.adapter) - - async def assert_reply( - self, - expected: Union[str, Activity, Callable[[Activity, str], None]], - description=None, - timeout=None, # pylint: disable=unused-argument - is_substring=False, - ) -> "TestFlow": - """ - Generates an assertion if the bots response doesn't match the expected text/activity. - :param expected: - :param description: - :param timeout: - :param is_substring: - :return: - """ - # TODO: refactor method so expected can take a Callable[[Activity], None] - def default_inspector(reply, description=None): - if isinstance(expected, Activity): - validate_activity(reply, expected) - else: - assert reply.type == "message", description + f" type == {reply.type}" - if is_substring: - assert expected in reply.text.strip(), ( - description + f" text == {reply.text}" - ) - else: - assert reply.text.strip() == expected.strip(), ( - description + f" text == {reply.text}" - ) - - if description is None: - description = "" - - inspector = expected if callable(expected) else default_inspector - - async def test_flow_previous(): - nonlocal timeout - if not timeout: - timeout = 3000 - start = datetime.now() - adapter = self.adapter - - async def wait_for_activity(): - nonlocal expected, timeout - current = datetime.now() - if (current - start).total_seconds() * 1000 > timeout: - if isinstance(expected, Activity): - expecting = expected.text - elif callable(expected): - expecting = inspect.getsourcefile(expected) - else: - expecting = str(expected) - raise RuntimeError( - f"TestAdapter.assert_reply({expecting}): {description} Timed out after " - f"{current - start}ms." - ) - if adapter.activity_buffer: - reply = adapter.activity_buffer.pop(0) - try: - await inspector(reply, description) - except Exception: - inspector(reply, description) - - else: - await asyncio.sleep(0.05) - await wait_for_activity() - - await wait_for_activity() - - return TestFlow(await test_flow_previous(), self.adapter) - - -def validate_activity(activity, expected) -> None: - """ - Helper method that compares activities - :param activity: - :param expected: - :return: - """ - iterable_expected = vars(expected).items() - - for attr, value in iterable_expected: - if value is not None and attr != "additional_properties": - assert value == getattr(activity, attr) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# TODO: enable this in the future +# With python 3.7 the line below will allow to do Postponed Evaluation of Annotations. See PEP 563 +# from __future__ import annotations + +import asyncio +import inspect +from datetime import datetime +from uuid import uuid4 +from typing import Awaitable, Coroutine, Dict, List, Callable, Union +from copy import copy +from threading import Lock +from botbuilder.schema import ( + ActivityTypes, + Activity, + ConversationAccount, + ConversationReference, + ChannelAccount, + ResourceResponse, + TokenResponse, +) +from botframework.connector.auth import AppCredentials, ClaimsIdentity +from botframework.connector.token_api.models import ( + SignInUrlResponse, + TokenExchangeResource, + TokenExchangeRequest, +) +from ..bot_adapter import BotAdapter +from ..turn_context import TurnContext +from ..extended_user_token_provider import ExtendedUserTokenProvider + + +class UserToken: + def __init__( + self, + connection_name: str = None, + user_id: str = None, + channel_id: str = None, + token: str = None, + ): + self.connection_name = connection_name + self.user_id = user_id + self.channel_id = channel_id + self.token = token + + def equals_key(self, rhs: "UserToken"): + return ( + rhs is not None + and self.connection_name == rhs.connection_name + and self.user_id == rhs.user_id + and self.channel_id == rhs.channel_id + ) + + +class ExchangeableToken(UserToken): + def __init__( + self, + connection_name: str = None, + user_id: str = None, + channel_id: str = None, + token: str = None, + exchangeable_item: str = None, + ): + super(ExchangeableToken, self).__init__( + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + token=token, + ) + + self.exchangeable_item = exchangeable_item + + def equals_key(self, rhs: "ExchangeableToken") -> bool: + return ( + rhs is not None + and self.exchangeable_item == rhs.exchangeable_item + and super().equals_key(rhs) + ) + + def to_key(self) -> str: + return self.exchangeable_item + + +class TokenMagicCode: + def __init__(self, key: UserToken = None, magic_code: str = None): + self.key = key + self.magic_code = magic_code + + +class TestAdapter(BotAdapter, ExtendedUserTokenProvider): + __test__ = False + + def __init__( + self, + logic: Coroutine = None, + template_or_conversation: Union[Activity, ConversationReference] = None, + send_trace_activities: bool = False, + ): + """ + Creates a new TestAdapter instance. + :param logic: + :param conversation: A reference to the conversation to begin the adapter state with. + """ + super(TestAdapter, self).__init__() + self.logic = logic + self._next_id: int = 0 + self._user_tokens: List[UserToken] = [] + self._magic_codes: List[TokenMagicCode] = [] + self._conversation_lock = Lock() + self.exchangeable_tokens: Dict[str, ExchangeableToken] = {} + self.activity_buffer: List[Activity] = [] + self.updated_activities: List[Activity] = [] + self.deleted_activities: List[ConversationReference] = [] + self.send_trace_activities = send_trace_activities + + self.template = ( + template_or_conversation + if isinstance(template_or_conversation, Activity) + else Activity( + channel_id="test", + service_url="https://test.com", + from_property=ChannelAccount(id="User1", name="user"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount(id="Convo1"), + ) + ) + + if isinstance(template_or_conversation, ConversationReference): + self.template.channel_id = template_or_conversation.channel_id + + async def process_activity( + self, activity: Activity, logic: Callable[[TurnContext], Awaitable] + ): + self._conversation_lock.acquire() + try: + # ready for next reply + if activity.type is None: + activity.type = ActivityTypes.message + + activity.channel_id = self.template.channel_id + activity.from_property = self.template.from_property + activity.recipient = self.template.recipient + activity.conversation = self.template.conversation + activity.service_url = self.template.service_url + + activity.id = str((self._next_id)) + self._next_id += 1 + finally: + self._conversation_lock.release() + + activity.timestamp = activity.timestamp or datetime.utcnow() + await self.run_pipeline(TurnContext(self, activity), logic) + + async def send_activities( + self, context, activities: List[Activity] + ) -> List[ResourceResponse]: + """ + INTERNAL: called by the logic under test to send a set of activities. These will be buffered + to the current `TestFlow` instance for comparison against the expected results. + :param context: + :param activities: + :return: + """ + + def id_mapper(activity): + self.activity_buffer.append(activity) + self._next_id += 1 + return ResourceResponse(id=str(self._next_id)) + + return [ + id_mapper(activity) + for activity in activities + if self.send_trace_activities or activity.type != "trace" + ] + + async def delete_activity(self, context, reference: ConversationReference): + """ + INTERNAL: called by the logic under test to delete an existing activity. These are simply + pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn + completes. + :param reference: + :return: + """ + self.deleted_activities.append(reference) + + async def update_activity(self, context, activity: Activity): + """ + INTERNAL: called by the logic under test to replace an existing activity. These are simply + pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn + completes. + :param activity: + :return: + """ + self.updated_activities.append(activity) + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + audience: str = None, + ): + """ + The `TestAdapter` just calls parent implementation. + :param reference: + :param callback: + :param bot_id: + :param claims_identity: + :return: + """ + await super().continue_conversation( + reference, callback, bot_id, claims_identity, audience + ) + + async def receive_activity(self, activity): + """ + INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. + This will cause the adapters middleware pipe to be run and it's logic to be called. + :param activity: + :return: + """ + if isinstance(activity, str): + activity = Activity(type="message", text=activity) + # Initialize request. + request = copy(self.template) + + for key, value in vars(activity).items(): + if value is not None and key != "additional_properties": + setattr(request, key, value) + + request.type = request.type or ActivityTypes.message + if not request.id: + self._next_id += 1 + request.id = str(self._next_id) + + # Create context object and run middleware. + context = TurnContext(self, request) + return await self.run_pipeline(context, self.logic) + + def get_next_activity(self) -> Activity: + return self.activity_buffer.pop(0) + + async def send(self, user_says) -> object: + """ + Sends something to the bot. This returns a new `TestFlow` instance which can be used to add + additional steps for inspecting the bots reply and then sending additional activities. + :param user_says: + :return: A new instance of the TestFlow object + """ + return TestFlow(await self.receive_activity(user_says), self) + + async def test( + self, user_says, expected, description=None, timeout=None + ) -> "TestFlow": + """ + Send something to the bot and expects the bot to return with a given reply. This is simply a + wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a + helper is provided. + :param user_says: + :param expected: + :param description: + :param timeout: + :return: + """ + test_flow = await self.send(user_says) + test_flow = await test_flow.assert_reply(expected, description, timeout) + return test_flow + + async def tests(self, *args): + """ + Support multiple test cases without having to manually call `test()` repeatedly. This is a + convenience layer around the `test()`. Valid args are either lists or tuples of parameters + :param args: + :return: + """ + for arg in args: + description = None + timeout = None + if len(arg) >= 3: + description = arg[2] + if len(arg) == 4: + timeout = arg[3] + await self.test(arg[0], arg[1], description, timeout) + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str = None, + ): + key = UserToken() + key.channel_id = channel_id + key.connection_name = connection_name + key.user_id = user_id + key.token = token + + if not magic_code: + self._user_tokens.append(key) + else: + code = TokenMagicCode() + code.key = key + code.magic_code = magic_code + self._magic_codes.append(code) + + async def get_user_token( + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, # pylint: disable=unused-argument + ) -> TokenResponse: + key = UserToken() + key.channel_id = context.activity.channel_id + key.connection_name = connection_name + key.user_id = context.activity.from_property.id + + if magic_code: + magic_code_record = list( + filter(lambda x: key.equals_key(x.key), self._magic_codes) + ) + if magic_code_record and magic_code_record[0].magic_code == magic_code: + # Move the token to long term dictionary. + self.add_user_token( + connection_name, + key.channel_id, + key.user_id, + magic_code_record[0].key.token, + ) + + # Remove from the magic code list. + idx = self._magic_codes.index(magic_code_record[0]) + self._magic_codes = [self._magic_codes.pop(idx)] + + match = [token for token in self._user_tokens if key.equals_key(token)] + + if match: + return TokenResponse( + connection_name=match[0].connection_name, + token=match[0].token, + expiration=None, + ) + # Not found. + return None + + async def sign_out_user( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, # pylint: disable=unused-argument + ): + channel_id = context.activity.channel_id + user_id = context.activity.from_property.id + + new_records = [] + for token in self._user_tokens: + if ( + token.channel_id != channel_id + or token.user_id != user_id + or (connection_name and connection_name != token.connection_name) + ): + new_records.append(token) + self._user_tokens = new_records + + async def get_oauth_sign_in_link( + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, # pylint: disable=unused-argument + oauth_app_credentials: AppCredentials = None, # pylint: disable=unused-argument + ) -> str: + return ( + f"https://fake.com/oauthsignin" + f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" + ) + + async def get_token_status( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + return None + + async def get_aad_tokens( + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, # pylint: disable=unused-argument + oauth_app_credentials: AppCredentials = None, # pylint: disable=unused-argument + ) -> Dict[str, TokenResponse]: + return None + + def add_exchangeable_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + token: str, + ): + key = ExchangeableToken( + connection_name=connection_name, + channel_id=channel_id, + user_id=user_id, + exchangeable_item=exchangeable_item, + token=token, + ) + self.exchangeable_tokens[key.to_key()] = key + + async def get_sign_in_resource_from_user( + self, + turn_context: TurnContext, + connection_name: str, + user_id: str, + final_redirect: str = None, + ) -> SignInUrlResponse: + return await self.get_sign_in_resource_from_user_and_credentials( + turn_context, None, connection_name, user_id, final_redirect + ) + + async def get_sign_in_resource_from_user_and_credentials( + self, + turn_context: TurnContext, + oauth_app_credentials: AppCredentials, + connection_name: str, + user_id: str, + final_redirect: str = None, + ) -> SignInUrlResponse: + return SignInUrlResponse( + sign_in_link=f"https://fake.com/oauthsignin/{connection_name}/{turn_context.activity.channel_id}/{user_id}", + token_exchange_resource=TokenExchangeResource( + id=str(uuid4()), + provider_id=None, + uri=f"api://{connection_name}/resource", + ), + ) + + async def exchange_token( + self, + turn_context: TurnContext, + connection_name: str, + user_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + return await self.exchange_token_from_credentials( + turn_context, None, connection_name, user_id, exchange_request + ) + + async def exchange_token_from_credentials( + self, + turn_context: TurnContext, + oauth_app_credentials: AppCredentials, + connection_name: str, + user_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + exchangeable_value = exchange_request.token or exchange_request.uri + + key = ExchangeableToken( + channel_id=turn_context.activity.channel_id, + connection_name=connection_name, + exchangeable_item=exchangeable_value, + user_id=user_id, + ) + + token_exchange_response = self.exchangeable_tokens.get(key.to_key()) + if token_exchange_response: + return TokenResponse( + channel_id=key.channel_id, + connection_name=key.connection_name, + token=token_exchange_response.token, + expiration=None, + ) + + return None + + +class TestFlow: + __test__ = False + + def __init__(self, previous: Callable, adapter: TestAdapter): + """ + INTERNAL: creates a TestFlow instance. + :param previous: + :param adapter: + """ + self.previous = previous + self.adapter = adapter + + async def test( + self, user_says, expected, description=None, timeout=None + ) -> "TestFlow": + """ + Send something to the bot and expects the bot to return with a given reply. This is simply a + wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a + helper is provided. + :param user_says: + :param expected: + :param description: + :param timeout: + :return: + """ + test_flow = await self.send(user_says) + return await test_flow.assert_reply( + expected, description or f'test("{user_says}", "{expected}")', timeout + ) + + async def send(self, user_says) -> "TestFlow": + """ + Sends something to the bot. + :param user_says: + :return: + """ + + async def new_previous(): + nonlocal self, user_says + if callable(self.previous): + await self.previous() + await self.adapter.receive_activity(user_says) + + return TestFlow(await new_previous(), self.adapter) + + async def assert_reply( + self, + expected: Union[str, Activity, Callable[[Activity, str], None]], + description=None, + timeout=None, # pylint: disable=unused-argument + is_substring=False, + ) -> "TestFlow": + """ + Generates an assertion if the bots response doesn't match the expected text/activity. + :param expected: + :param description: + :param timeout: + :param is_substring: + :return: + """ + # TODO: refactor method so expected can take a Callable[[Activity], None] + def default_inspector(reply, description=None): + if isinstance(expected, Activity): + validate_activity(reply, expected) + else: + assert reply.type == "message", description + f" type == {reply.type}" + if is_substring: + assert expected in reply.text.strip(), ( + description + f" text == {reply.text}" + ) + else: + assert reply.text.strip() == expected.strip(), ( + description + f" text == {reply.text}" + ) + + if description is None: + description = "" + + inspector = expected if callable(expected) else default_inspector + + async def test_flow_previous(): + nonlocal timeout + if not timeout: + timeout = 3000 + start = datetime.now() + adapter = self.adapter + + async def wait_for_activity(): + nonlocal expected, timeout + current = datetime.now() + if (current - start).total_seconds() * 1000 > timeout: + if isinstance(expected, Activity): + expecting = expected.text + elif callable(expected): + expecting = inspect.getsourcefile(expected) + else: + expecting = str(expected) + raise RuntimeError( + f"TestAdapter.assert_reply({expecting}): {description} Timed out after " + f"{current - start}ms." + ) + if adapter.activity_buffer: + reply = adapter.activity_buffer.pop(0) + try: + await inspector(reply, description) + except Exception: + inspector(reply, description) + + else: + await asyncio.sleep(0.05) + await wait_for_activity() + + await wait_for_activity() + + return TestFlow(await test_flow_previous(), self.adapter) + + +def validate_activity(activity, expected) -> None: + """ + Helper method that compares activities + :param activity: + :param expected: + :return: + """ + iterable_expected = vars(expected).items() + + for attr, value in iterable_expected: + if value is not None and attr != "additional_properties": + assert value == getattr(activity, attr) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 3da9df2c2..7edf9da2e 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -8,6 +8,7 @@ import json import os import uuid +from http import HTTPStatus from typing import List, Callable, Awaitable, Union, Dict from msrest.serialization import Model @@ -31,6 +32,11 @@ MicrosoftGovernmentAppCredentials, ) from botframework.connector.token_api import TokenApiClient +from botframework.connector.token_api.models import ( + TokenStatus, + TokenExchangeRequest, + SignInUrlResponse, +) from botbuilder.schema import ( Activity, ActivityTypes, @@ -47,7 +53,7 @@ from . import __version__ from .bot_adapter import BotAdapter from .turn_context import TurnContext -from .user_token_provider import UserTokenProvider +from .extended_user_token_provider import ExtendedUserTokenProvider from .invoke_response import InvokeResponse from .conversation_reference_extension import get_continuation_activity @@ -57,10 +63,25 @@ class TokenExchangeState(Model): + """TokenExchangeState + + :param connection_name: The connection name that was used. + :type connection_name: str + :param conversation: Gets or sets a reference to the conversation. + :type conversation: ~botframework.connector.models.ConversationReference + :param relates_to: Gets or sets a reference to a related parent conversation for this token exchange. + :type relates_to: ~botframework.connector.models.ConversationReference + :param bot_ur: The URL of the bot messaging endpoint. + :type bot_ur: str + :param ms_app_id: The bot's registered application ID. + :type ms_app_id: str + """ + _attribute_map = { "connection_name": {"key": "connectionName", "type": "str"}, "conversation": {"key": "conversation", "type": "ConversationReference"}, - "bot_url": {"key": "botUrl", "type": "str"}, + "relates_to": {"key": "relatesTo", "type": "ConversationReference"}, + "bot_url": {"key": "connectionName", "type": "str"}, "ms_app_id": {"key": "msAppId", "type": "str"}, } @@ -68,7 +89,8 @@ def __init__( self, *, connection_name: str = None, - conversation: ConversationReference = None, + conversation=None, + relates_to=None, bot_url: str = None, ms_app_id: str = None, **kwargs, @@ -76,6 +98,7 @@ def __init__( super(TokenExchangeState, self).__init__(**kwargs) self.connection_name = connection_name self.conversation = conversation + self.relates_to = relates_to self.bot_url = bot_url self.ms_app_id = ms_app_id @@ -140,7 +163,7 @@ def __init__( ) -class BotFrameworkAdapter(BotAdapter, UserTokenProvider): +class BotFrameworkAdapter(BotAdapter, ExtendedUserTokenProvider): """ Defines an adapter to connect a bot to a service endpoint. @@ -456,7 +479,7 @@ async def process_activity_with_identity( BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access ) if invoke_response is None: - return InvokeResponse(status=501) + return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) return invoke_response.value # Return the buffered activities in the response. In this case, the invoker @@ -466,7 +489,7 @@ async def process_activity_with_identity( expected_replies = ExpectedReplies( activities=context.buffered_reply_activities ).serialize() - return InvokeResponse(status=200, body=expected_replies) + return InvokeResponse(status=int(HTTPStatus.OK), body=expected_replies) return None @@ -836,7 +859,7 @@ async def get_user_token( context: TurnContext, connection_name: str, magic_code: str = None, - oauth_app_credentials: AppCredentials = None, + oauth_app_credentials: AppCredentials = None, # pylint: disable=unused-argument ) -> TokenResponse: """ @@ -887,7 +910,7 @@ async def get_user_token( async def sign_out_user( self, context: TurnContext, - connection_name: str = None, + connection_name: str = None, # pylint: disable=unused-argument user_id: str = None, oauth_app_credentials: AppCredentials = None, ): @@ -919,7 +942,7 @@ async def get_oauth_sign_in_link( self, context: TurnContext, connection_name: str, - final_redirect: str = None, + final_redirect: str = None, # pylint: disable=unused-argument oauth_app_credentials: AppCredentials = None, ) -> str: """ @@ -943,6 +966,7 @@ async def get_oauth_sign_in_link( connection_name=connection_name, conversation=conversation, ms_app_id=client.config.credentials.microsoft_app_id, + relates_to=context.activity.relates_to, ) final_state = base64.b64encode( @@ -958,7 +982,7 @@ async def get_token_status( user_id: str = None, include_filter: str = None, oauth_app_credentials: AppCredentials = None, - ) -> Dict[str, TokenResponse]: + ) -> List[TokenStatus]: """ Retrieves the token status for each configured connection for the given user. @@ -996,7 +1020,7 @@ async def get_aad_tokens( context: TurnContext, connection_name: str, resource_urls: List[str], - user_id: str = None, + user_id: str = None, # pylint: disable=unused-argument oauth_app_credentials: AppCredentials = None, ) -> Dict[str, TokenResponse]: """ @@ -1094,6 +1118,107 @@ def _get_or_create_connector_client( return client + async def get_sign_in_resource_from_user( + self, + turn_context: TurnContext, + connection_name: str, + user_id: str, + final_redirect: str = None, + ) -> SignInUrlResponse: + return await self.get_sign_in_resource_from_user_and_credentials( + turn_context, None, connection_name, user_id, final_redirect + ) + + async def get_sign_in_resource_from_user_and_credentials( + self, + turn_context: TurnContext, + oauth_app_credentials: AppCredentials, + connection_name: str, + user_id: str, + final_redirect: str = None, + ) -> SignInUrlResponse: + if not connection_name: + raise TypeError( + "BotFrameworkAdapter.get_sign_in_resource_from_user(): missing connection_name" + ) + if ( + not turn_context.activity.from_property + or not turn_context.activity.from_property.id + ): + raise TypeError( + "BotFrameworkAdapter.get_sign_in_resource_from_user(): missing activity id" + ) + if user_id and turn_context.activity.from_property.id != user_id: + raise TypeError( + "BotFrameworkAdapter.get_sign_in_resource_from_user(): cannot get signin resource" + " for a user that is different from the conversation" + ) + + client = await self._create_token_api_client( + turn_context, oauth_app_credentials + ) + conversation = TurnContext.get_conversation_reference(turn_context.activity) + + state = TokenExchangeState( + connection_name=connection_name, + conversation=conversation, + relates_to=turn_context.activity.relates_to, + ms_app_id=client.config.credentials.microsoft_app_id, + ) + + final_state = base64.b64encode( + json.dumps(state.serialize()).encode(encoding="UTF-8", errors="strict") + ).decode() + + return client.bot_sign_in.get_sign_in_resource( + final_state, final_redirect=final_redirect + ) + + async def exchange_token( + self, + turn_context: TurnContext, + connection_name: str, + user_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + return await self.exchange_token_from_credentials( + turn_context, None, connection_name, user_id, exchange_request + ) + + async def exchange_token_from_credentials( + self, + turn_context: TurnContext, + oauth_app_credentials: AppCredentials, + connection_name: str, + user_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + # pylint: disable=no-member + + if not connection_name: + raise TypeError( + "BotFrameworkAdapter.exchange_token(): missing connection_name" + ) + if not user_id: + raise TypeError("BotFrameworkAdapter.exchange_token(): missing user_id") + if exchange_request and not exchange_request.token and not exchange_request.uri: + raise TypeError( + "BotFrameworkAdapter.exchange_token(): Either a Token or Uri property is required" + " on the TokenExchangeRequest" + ) + + client = await self._create_token_api_client( + turn_context, oauth_app_credentials + ) + + return client.user_token.exchange_async( + user_id, + connection_name, + turn_context.activity.channel_id, + exchange_request.uri, + exchange_request.token, + ) + @staticmethod def key_for_connector_client(service_url: str, app_id: str, scope: str): return f"{service_url}:{app_id}:{scope}" @@ -1109,7 +1234,7 @@ async def _create_token_api_client( self._is_emulating_oauth_cards = True app_id = self.__get_app_id(context) - scope = context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] + scope = context.turn_state.get(BotAdapter.BOT_OAUTH_SCOPE_KEY) app_credentials = oauth_app_credentials or await self.__get_app_credentials( app_id, scope ) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py index dc9dd1a9e..ac015e80a 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py @@ -6,7 +6,12 @@ from logging import Logger import aiohttp -from botbuilder.schema import Activity, ExpectedReplies +from botbuilder.schema import ( + Activity, + ExpectedReplies, + ConversationAccount, + ConversationReference, +) from botframework.connector.auth import ( ChannelProvider, CredentialProvider, @@ -70,6 +75,24 @@ async def post_activity( original_caller_id = activity.caller_id try: + # TODO: The relato has to be ported to the adapter in the new integration library when + # resolving conflicts in merge + activity.relates_to = ConversationReference( + service_url=activity.service_url, + activity_id=activity.id, + channel_id=activity.channel_id, + conversation=ConversationAccount( + id=activity.conversation.id, + name=activity.conversation.name, + conversation_type=activity.conversation.conversation_type, + aad_object_id=activity.conversation.aad_object_id, + is_group=activity.conversation.is_group, + role=activity.conversation.role, + tenant_id=activity.conversation.tenant_id, + properties=activity.conversation.properties, + ), + bot=None, + ) activity.conversation.id = conversation_id activity.service_url = service_url activity.caller_id = from_bot_id diff --git a/libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py b/libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py new file mode 100644 index 000000000..f1c8301e8 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py @@ -0,0 +1,190 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from typing import Dict, List + +from botframework.connector.token_api.models import ( + SignInUrlResponse, + TokenExchangeRequest, + TokenResponse, +) +from botframework.connector.auth import AppCredentials + +from .turn_context import TurnContext +from .user_token_provider import UserTokenProvider + + +class ExtendedUserTokenProvider(UserTokenProvider, ABC): + # pylint: disable=unused-argument + + async def get_sign_in_resource( + self, turn_context: TurnContext, connection_name: str + ) -> SignInUrlResponse: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + + :param turn_context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + + + :return: A task that represents the work queued to execute. + .. remarks:: If the task completes successfully, the result contains the raw signin link. + """ + return + + async def get_sign_in_resource_from_user( + self, + turn_context: TurnContext, + connection_name: str, + user_id: str, + final_redirect: str = None, + ) -> SignInUrlResponse: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + + :param turn_context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: The user id that will be associated with the token. + :param final_redirect: The final URL that the OAuth flow will redirect to. + + + :return: A task that represents the work queued to execute. + .. remarks:: If the task completes successfully, the result contains the raw signin link. + """ + return + + async def get_sign_in_resource_from_user_and_credentials( + self, + turn_context: TurnContext, + oauth_app_credentials: AppCredentials, + connection_name: str, + user_id: str, + final_redirect: str = None, + ) -> SignInUrlResponse: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + + :param turn_context: Context for the current turn of conversation with the user. + :param oauth_app_credentials: Credentials for OAuth. + :param connection_name: Name of the auth connection to use. + :param user_id: The user id that will be associated with the token. + :param final_redirect: The final URL that the OAuth flow will redirect to. + + + :return: A task that represents the work queued to execute. + .. remarks:: If the task completes successfully, the result contains the raw signin link. + """ + return + + async def exchange_token( + self, + turn_context: TurnContext, + connection_name: str, + user_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + """ + Performs a token exchange operation such as for single sign-on. + + :param turn_context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: The user id associated with the token.. + :param exchange_request: The exchange request details, either a token to exchange or a uri to exchange. + + + :return: If the task completes, the exchanged token is returned. + """ + return + + async def exchange_token_from_credentials( + self, + turn_context: TurnContext, + oauth_app_credentials: AppCredentials, + connection_name: str, + user_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + """ + Performs a token exchange operation such as for single sign-on. + + :param turn_context: Context for the current turn of conversation with the user. + :param oauth_app_credentials: AppCredentials for OAuth. + :param connection_name: Name of the auth connection to use. + :param user_id: The user id associated with the token.. + :param exchange_request: The exchange request details, either a token to exchange or a uri to exchange. + + + :return: If the task completes, the exchanged token is returned. + """ + return + + async def get_user_token( + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> TokenResponse: + """ + Retrieves the OAuth token for a user that is in a sign-in flow. + :param context: + :param connection_name: + :param magic_code: + :param oauth_app_credentials: + :return: + """ + raise NotImplementedError() + + async def sign_out_user( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): + """ + Signs the user out with the token server. + :param context: + :param connection_name: + :param user_id: + :param oauth_app_credentials: + :return: + """ + raise NotImplementedError() + + async def get_oauth_sign_in_link( + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> str: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + :param context: + :param connection_name: + :param final_redirect: + :param oauth_app_credentials: + :return: + """ + raise NotImplementedError() + + async def get_aad_tokens( + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: + :param connection_name: + :param resource_urls: + :param user_id: + :param oauth_app_credentials: + :return: + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/serializer_helper.py b/libraries/botbuilder-core/botbuilder/core/serializer_helper.py new file mode 100644 index 000000000..766cd6291 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/serializer_helper.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from inspect import getmembers +from typing import Type +from enum import Enum + +from msrest.serialization import Model, Deserializer, Serializer + +import botbuilder.schema as schema +import botbuilder.schema.teams as teams_schema + +DEPENDICIES = [ + schema_cls + for key, schema_cls in getmembers(schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) +] +DEPENDICIES += [ + schema_cls + for key, schema_cls in getmembers(teams_schema) + if isinstance(schema_cls, type) and issubclass(schema_cls, (Model, Enum)) +] +DEPENDICIES_DICT = {dependency.__name__: dependency for dependency in DEPENDICIES} + + +def deserializer_helper(msrest_cls: Type[Model], dict_to_deserialize: dict) -> Model: + deserializer = Deserializer(DEPENDICIES_DICT) + return deserializer(msrest_cls.__name__, dict_to_deserialize) + + +def serializer_helper(object_to_serialize: Model) -> dict: + if object_to_serialize is None: + return None + + serializer = Serializer(DEPENDICIES_DICT) + # pylint: disable=protected-access + return serializer._serialize(object_to_serialize) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 6b541d7ff..4c767d40a 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -2,8 +2,9 @@ # Licensed under the MIT License. from http import HTTPStatus -from botbuilder.schema import Activity, ActivityTypes, ChannelAccount -from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter +from botbuilder.schema import ChannelAccount, SignInConstants +from botbuilder.core import ActivityHandler, InvokeResponse +from botbuilder.core.activity_handler import _InvokeResponseException from botbuilder.core.turn_context import TurnContext from botbuilder.core.teams.teams_info import TeamsInfo from botbuilder.schema.teams import ( @@ -22,36 +23,10 @@ TaskModuleResponse, ) from botframework.connector import Channels -from .teams_helper import deserializer_helper, serializer_helper +from ..serializer_helper import deserializer_helper class TeamsActivityHandler(ActivityHandler): - async def on_turn(self, turn_context: TurnContext): - if turn_context is None: - raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.") - - if not getattr(turn_context, "activity", None): - raise TypeError( - "ActivityHandler.on_turn(): turn_context must have a non-None activity." - ) - - if not getattr(turn_context.activity, "type", None): - raise TypeError( - "ActivityHandler.on_turn(): turn_context activity must have a non-None type." - ) - - if turn_context.activity.type == ActivityTypes.invoke: - invoke_response = await self.on_invoke_activity(turn_context) - if invoke_response and not turn_context.turn_state.get( - BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access - ): - await turn_context.send_activity( - Activity(value=invoke_response, type=ActivityTypes.invoke_response) - ) - return - - await super().on_turn(turn_context) - async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: try: if ( @@ -60,8 +35,11 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: ): return await self.on_teams_card_action_invoke(turn_context) - if turn_context.activity.name == "signin/verifyState": - await self.on_teams_signin_verify_state(turn_context) + if ( + turn_context.activity.name + == SignInConstants.token_exchange_operation_name + ): + await self.on_teams_signin_token_exchange(turn_context) return self._create_invoke_response() if turn_context.activity.name == "fileConsent/invoke": @@ -170,9 +148,13 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: ) ) - raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) - except _InvokeResponseException as err: - return err.create_invoke_response() + return await super().on_invoke_activity(turn_context) + + except _InvokeResponseException as invoke_exception: + return invoke_exception.create_invoke_response() + + async def on_sign_in_invoke(self, turn_context: TurnContext): + return await self.on_teams_signin_verify_state(turn_context) async def on_teams_card_action_invoke( self, turn_context: TurnContext @@ -182,6 +164,9 @@ async def on_teams_card_action_invoke( async def on_teams_signin_verify_state(self, turn_context: TurnContext): raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + async def on_teams_signin_token_exchange(self, turn_context: TurnContext): + raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) + async def on_teams_file_consent( self, turn_context: TurnContext, @@ -439,17 +424,3 @@ async def on_teams_channel_renamed( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): return - - @staticmethod - def _create_invoke_response(body: object = None) -> InvokeResponse: - return InvokeResponse(status=int(HTTPStatus.OK), body=serializer_helper(body)) - - -class _InvokeResponseException(Exception): - def __init__(self, status_code: HTTPStatus, body: object = None): - super(_InvokeResponseException, self).__init__() - self._status_code = status_code - self._body = body - - def create_invoke_response(self) -> InvokeResponse: - return InvokeResponse(status=int(self._status_code), body=self._body) diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 5e6916dd0..ab738b791 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -1,3 +1,4 @@ +from http import HTTPStatus from typing import List import aiounittest @@ -62,6 +63,19 @@ async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) + async def on_invoke_activity(self, turn_context: TurnContext): + self.record.append("on_invoke_activity") + if turn_context.activity.name == "some.random.invoke": + return self._create_invoke_response() + + return await super().on_invoke_activity(turn_context) + + async def on_sign_in_invoke( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + self.record.append("on_sign_in_invoke") + return + class NotImplementedAdapter(BotAdapter): async def delete_activity( @@ -78,6 +92,35 @@ async def update_activity(self, context: TurnContext, activity: Activity): raise NotImplementedError() +class TestInvokeAdapter(NotImplementedAdapter): + def __init__(self, on_turn_error=None, activity: Activity = None): + super().__init__(on_turn_error) + + self.activity = activity + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + raise NotImplementedError() + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + self.activity = next( + ( + activity + for activity in activities + if activity.type == ActivityTypes.invoke_response + ), + None, + ) + + return [] + + async def update_activity(self, context: TurnContext, activity: Activity): + raise NotImplementedError() + + class TestActivityHandler(aiounittest.AsyncTestCase): async def test_message_reaction(self): # Note the code supports multiple adds and removes in the same activity though @@ -101,3 +144,31 @@ async def test_message_reaction(self): assert bot.record[0] == "on_message_reaction_activity" assert bot.record[1] == "on_reactions_added" assert bot.record[2] == "on_reactions_removed" + + async def test_invoke(self): + activity = Activity(type=ActivityTypes.invoke, name="some.random.invoke") + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_invoke_activity" + assert adapter.activity.value.status == int(HTTPStatus.OK) + + async def test_invoke_should_not_match(self): + activity = Activity(type=ActivityTypes.invoke, name="should.not.match") + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_invoke_activity" + assert adapter.activity.value.status == int(HTTPStatus.NOT_IMPLEMENTED) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 0f9a82453..f04799c9d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -3,17 +3,21 @@ import re from datetime import datetime, timedelta +from http import HTTPStatus from typing import Union, Awaitable, Callable from botframework.connector import Channels from botframework.connector.auth import ClaimsIdentity, SkillValidation +from botframework.connector.token_api.models import SignInUrlResponse from botbuilder.core import ( CardFactory, + ExtendedUserTokenProvider, MessageFactory, InvokeResponse, TurnContext, UserTokenProvider, ) +from botbuilder.core.bot_framework_adapter import TokenExchangeRequest from botbuilder.dialogs import Dialog, DialogContext, DialogTurnResult from botbuilder.schema import ( Activity, @@ -22,14 +26,20 @@ CardAction, InputHints, SigninCard, + SignInConstants, OAuthCard, TokenResponse, + TokenExchangeInvokeRequest, + TokenExchangeInvokeResponse, ) + from .prompt_options import PromptOptions from .oauth_prompt_settings import OAuthPromptSettings from .prompt_validator_context import PromptValidatorContext from .prompt_recognizer_result import PromptRecognizerResult +# TODO: Consider moving TokenExchangeInvokeRequest and TokenExchangeInvokeResponse to here + class OAuthPrompt(Dialog): """ @@ -70,8 +80,8 @@ def __init__( """ Creates a new instance of the :class:`OAuthPrompt` class. - :param dialogId: The Id to assign to this prompt. - :type dialogId: str + :param dialog_id: The Id to assign to this prompt. + :type dialog_id: str :param settings: Additional authentication settings to use with this instance of the prompt :type settings: :class:`OAuthPromptSettings` :param validator: Optional, contains additional, custom validation for this prompt @@ -290,33 +300,30 @@ async def _send_oauth_card( att.content_type == CardFactory.content_types.oauth_card for att in prompt.attachments ): - link = None + adapter: ExtendedUserTokenProvider = context.adapter card_action_type = ActionTypes.signin + sign_in_resource: SignInUrlResponse = await adapter.get_sign_in_resource_from_user_and_credentials( + context, + self._settings.oath_app_credentials, + self._settings.connection_name, + context.activity.from_property.id, + ) + link = sign_in_resource.sign_in_link bot_identity: ClaimsIdentity = context.turn_state.get("BotIdentity") - # check if it's from streaming connection - if not context.activity.service_url.startswith("http"): - if not hasattr(context.adapter, "get_oauth_sign_in_link"): - raise Exception( - "OAuthPrompt: get_oauth_sign_in_link() not supported by the current adapter" - ) - link = await context.adapter.get_oauth_sign_in_link( - context, - self._settings.connection_name, - None, - self._settings.oath_app_credentials, - ) - elif bot_identity and SkillValidation.is_skill_claim( - bot_identity.claims - ): - link = await context.adapter.get_oauth_sign_in_link( - context, - self._settings.connection_name, - None, - self._settings.oath_app_credentials, - ) - card_action_type = ActionTypes.open_url + if ( + bot_identity and SkillValidation.is_skill_claim(bot_identity.claims) + ) or not context.activity.service_url.startswith("http"): + if context.activity.channel_id == Channels.emulator: + card_action_type = ActionTypes.open_url + else: + link = None + json_token_ex_resource = ( + sign_in_resource.token_exchange_resource.as_dict() + if sign_in_resource.token_exchange_resource + else None + ) prompt.attachments.append( CardFactory.oauth_card( OAuthCard( @@ -330,6 +337,7 @@ async def _send_oauth_card( value=link, ) ], + token_exchange_resource=json_token_ex_resource, ) ) ) @@ -377,16 +385,99 @@ async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult token = await self.get_user_token(context, code) if token is not None: await context.send_activity( - Activity(type="invokeResponse", value=InvokeResponse(200)) + Activity( + type="invokeResponse", + value=InvokeResponse(int(HTTPStatus.OK)), + ) ) else: await context.send_activity( - Activity(type="invokeResponse", value=InvokeResponse(404)) + Activity( + type="invokeResponse", + value=InvokeResponse(int(HTTPStatus.NOT_FOUND)), + ) ) except Exception: - context.send_activity( - Activity(type="invokeResponse", value=InvokeResponse(500)) + await context.send_activity( + Activity( + type="invokeResponse", + value=InvokeResponse(int(HTTPStatus.INTERNAL_SERVER_ERROR)), + ) + ) + elif self._is_token_exchange_request_invoke(context): + if isinstance(context.activity.value, dict): + context.activity.value = TokenExchangeInvokeRequest().from_dict( + context.activity.value + ) + + if not ( + context.activity.value + and self._is_token_exchange_request(context.activity.value) + ): + # Received activity is not a token exchange request. + await context.send_activity( + self._get_token_exchange_invoke_response( + int(HTTPStatus.BAD_REQUEST), + "The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value." + " This is required to be sent with the InvokeActivity.", + ) + ) + elif ( + context.activity.value.connection_name != self._settings.connection_name + ): + # Connection name on activity does not match that of setting. + await context.send_activity( + self._get_token_exchange_invoke_response( + int(HTTPStatus.BAD_REQUEST), + "The bot received an InvokeActivity with a TokenExchangeInvokeRequest containing a" + " ConnectionName that does not match the ConnectionName expected by the bots active" + " OAuthPrompt. Ensure these names match when sending the InvokeActivityInvalid" + " ConnectionName in the TokenExchangeInvokeRequest", + ) + ) + elif not getattr(context.adapter, "exchange_token"): + # Token Exchange not supported in the adapter. + await context.send_activity( + self._get_token_exchange_invoke_response( + int(HTTPStatus.BAD_GATEWAY), + "The bot's BotAdapter does not support token exchange operations." + " Ensure the bot's Adapter supports the ITokenExchangeProvider interface.", + ) + ) + + raise AttributeError( + "OAuthPrompt.recognize(): not supported by the current adapter." ) + else: + # No errors. Proceed with token exchange. + extended_user_token_provider: ExtendedUserTokenProvider = context.adapter + token_exchange_response = await extended_user_token_provider.exchange_token_from_credentials( + context, + self._settings.oath_app_credentials, + self._settings.connection_name, + context.activity.from_property.id, + TokenExchangeRequest(token=context.activity.value.token), + ) + + if not token_exchange_response or not token_exchange_response.token: + await context.send_activity( + self._get_token_exchange_invoke_response( + int(HTTPStatus.CONFLICT), + "The bot is unable to exchange token. Proceed with regular login.", + ) + ) + else: + await context.send_activity( + self._get_token_exchange_invoke_response( + int(HTTPStatus.OK), None, context.activity.value.id + ) + ) + token = TokenResponse( + channel_id=token_exchange_response.channel_id, + connection_name=token_exchange_response.connection_name, + token=token_exchange_response.token, + expiration=None, + ) elif context.activity.type == ActivityTypes.message and context.activity.text: match = re.match(r"(? PromptRecognizerResult else PromptRecognizerResult() ) + def _get_token_exchange_invoke_response( + self, status: int, failure_detail: str, identifier: str = None + ) -> Activity: + return Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse( + status=status, + body=TokenExchangeInvokeResponse( + id=identifier, + connection_name=self._settings.connection_name, + failure_detail=failure_detail, + ), + ), + ) + @staticmethod def _is_token_response_event(context: TurnContext) -> bool: activity = context.activity return ( - activity.type == ActivityTypes.event and activity.name == "tokens/response" + activity.type == ActivityTypes.event + and activity.name == SignInConstants.token_response_event_name ) @staticmethod @@ -412,7 +519,7 @@ def _is_teams_verification_invoke(context: TurnContext) -> bool: return ( activity.type == ActivityTypes.invoke - and activity.name == "signin/verifyState" + and activity.name == SignInConstants.verify_state_operation_name ) @staticmethod @@ -426,3 +533,16 @@ def _channel_suppports_oauth_card(channel_id: str) -> bool: return False return True + + @staticmethod + def _is_token_exchange_request_invoke(context: TurnContext) -> bool: + activity = context.activity + + return ( + activity.type == ActivityTypes.invoke + and activity.name == SignInConstants.token_exchange_operation_name + ) + + @staticmethod + def _is_token_exchange_request(obj: TokenExchangeInvokeRequest) -> bool: + return bool(obj.connection_name) and bool(obj.token) diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index 5fcb88ed7..78a35caa5 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -53,6 +53,9 @@ from ._models_py3 import Thing from ._models_py3 import ThumbnailCard from ._models_py3 import ThumbnailUrl + from ._models_py3 import TokenExchangeInvokeRequest + from ._models_py3 import TokenExchangeInvokeResponse + from ._models_py3 import TokenExchangeState from ._models_py3 import TokenRequest from ._models_py3 import TokenResponse from ._models_py3 import Transcript @@ -120,6 +123,8 @@ TextFormatTypes, ) +from ._sign_in_enums import SignInConstants + __all__ = [ "Activity", "AnimationCard", @@ -160,11 +165,15 @@ "ResourceResponse", "SemanticAction", "SigninCard", + "SignInConstants", "SuggestedActions", "TextHighlight", "Thing", "ThumbnailCard", "ThumbnailUrl", + "TokenExchangeInvokeRequest", + "TokenExchangeInvokeResponse", + "TokenExchangeState", "TokenRequest", "TokenResponse", "Transcript", diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 709c352f0..f95185d1e 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -1369,15 +1369,23 @@ class OAuthCard(Model): "text": {"key": "text", "type": "str"}, "connection_name": {"key": "connectionName", "type": "str"}, "buttons": {"key": "buttons", "type": "[CardAction]"}, + "token_exchange_resource": {"key": "tokenExchangeResource", "type": "object"}, } def __init__( - self, *, text: str = None, connection_name: str = None, buttons=None, **kwargs + self, + *, + text: str = None, + connection_name: str = None, + buttons=None, + token_exchange_resource=None, + **kwargs ) -> None: super(OAuthCard, self).__init__(**kwargs) self.text = text self.connection_name = connection_name self.buttons = buttons + self.token_exchange_resource = token_exchange_resource class PagedMembersResult(Model): @@ -1744,6 +1752,119 @@ def __init__(self, *, url: str = None, alt: str = None, **kwargs) -> None: self.alt = alt +class TokenExchangeInvokeRequest(Model): + """TokenExchangeInvokeRequest. + + :param id: The id from the OAuthCard. + :type id: str + :param connection_name: The connection name. + :type connection_name: str + :param token: The user token that can be exchanged. + :type token: str + :param properties: Extension data for overflow of properties. + :type properties: dict[str, object] + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "connection_name": {"key": "connectionName", "type": "str"}, + "token": {"key": "token", "type": "str"}, + "properties": {"key": "properties", "type": "{object}"}, + } + + def __init__( + self, + *, + id: str = None, + connection_name: str = None, + token: str = None, + properties=None, + **kwargs + ) -> None: + super(TokenExchangeInvokeRequest, self).__init__(**kwargs) + self.id = id + self.connection_name = connection_name + self.token = token + self.properties = properties + + +class TokenExchangeInvokeResponse(Model): + """TokenExchangeInvokeResponse. + + :param id: The id from the OAuthCard. + :type id: str + :param connection_name: The connection name. + :type connection_name: str + :param failure_detail: The details of why the token exchange failed. + :type failure_detail: str + :param properties: Extension data for overflow of properties. + :type properties: dict[str, object] + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "connection_name": {"key": "connectionName", "type": "str"}, + "failure_detail": {"key": "failureDetail", "type": "str"}, + "properties": {"key": "properties", "type": "{object}"}, + } + + def __init__( + self, + *, + id: str = None, + connection_name: str = None, + failure_detail: str = None, + properties=None, + **kwargs + ) -> None: + super(TokenExchangeInvokeResponse, self).__init__(**kwargs) + self.id = id + self.connection_name = connection_name + self.failure_detail = failure_detail + self.properties = properties + + +class TokenExchangeState(Model): + """TokenExchangeState + + :param connection_name: The connection name that was used. + :type connection_name: str + :param conversation: Gets or sets a reference to the conversation. + :type conversation: ~botframework.connector.models.ConversationReference + :param relates_to: Gets or sets a reference to a related parent conversation for this token exchange. + :type relates_to: ~botframework.connector.models.ConversationReference + :param bot_ur: The URL of the bot messaging endpoint. + :type bot_ur: str + :param ms_app_id: The bot's registered application ID. + :type ms_app_id: str + """ + + _attribute_map = { + "connection_name": {"key": "connectionName", "type": "str"}, + "conversation": {"key": "conversation", "type": "ConversationReference"}, + "relates_to": {"key": "relatesTo", "type": "ConversationReference"}, + "bot_url": {"key": "connectionName", "type": "str"}, + "ms_app_id": {"key": "msAppId", "type": "str"}, + } + + def __init__( + self, + *, + connection_name: str = None, + conversation=None, + relates_to=None, + bot_url: str = None, + ms_app_id: str = None, + **kwargs + ) -> None: + super(TokenExchangeState, self).__init__(**kwargs) + self.connection_name = connection_name + self.conversation = conversation + self.relates_to = relates_to + self.bot_url = bot_url + self.ms_app_id = ms_app_id + + class TokenRequest(Model): """A request to receive a user token. diff --git a/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py new file mode 100644 index 000000000..4e411687a --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py @@ -0,0 +1,18 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from enum import Enum + + +class SignInConstants(str, Enum): + + # Name for the signin invoke to verify the 6-digit authentication code as part of sign-in. + verify_state_operation_name = "signin/verifyState" + # Name for signin invoke to perform a token exchange. + token_exchange_operation_name = "signin/tokenExchange" + # The EventActivity name when a token is sent to the bot. + token_response_event_name = "tokens/response" diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index 6b9b6d925..e1f08743f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -13,6 +13,7 @@ from .government_constants import * from .channel_provider import * from .simple_channel_provider import * +from .app_credentials import * from .microsoft_app_credentials import * from .microsoft_government_app_credentials import * from .certificate_app_credentials import * diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py index 1ec07e04c..8798b13e1 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py @@ -18,8 +18,8 @@ class BotSignInOperations: """BotSignInOperations async operations. - You should not instantiate directly this class, but create a Client instance that will create it for you and attach - it as attribute. + You should not instantiate directly this class, but create a Client instance that will create it for you and + attach it as attribute. :param client: Client for service requests. :param config: Configuration of service client. @@ -118,3 +118,81 @@ async def get_sign_in_url( return deserialized get_sign_in_url.metadata = {"url": "/api/botsignin/GetSignInUrl"} + + async def get_sign_in_resource( + self, + state, + code_challenge=None, + emulator_url=None, + final_redirect=None, + *, + custom_headers=None, + raw=False, + **operation_config + ): + """ + + :param state: + :type state: str + :param code_challenge: + :type code_challenge: str + :param emulator_url: + :type emulator_url: str + :param final_redirect: + :type final_redirect: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: SignInUrlResponse or ClientRawResponse if raw=true + :rtype: ~botframework.tokenapi.models.SignInUrlResponse or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + # Construct URL + url = self.get_sign_in_resource.metadata["url"] + + # Construct parameters + query_parameters = {} + query_parameters["state"] = self._serialize.query("state", state, "str") + if code_challenge is not None: + query_parameters["code_challenge"] = self._serialize.query( + "code_challenge", code_challenge, "str" + ) + if emulator_url is not None: + query_parameters["emulatorUrl"] = self._serialize.query( + "emulator_url", emulator_url, "str" + ) + if final_redirect is not None: + query_parameters["finalRedirect"] = self._serialize.query( + "final_redirect", final_redirect, "str" + ) + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = await self._client.async_send( + request, stream=False, **operation_config + ) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("SignInUrlResponse", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + get_sign_in_resource.metadata = {"url": "/api/botsignin/GetSignInResource"} diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py index 53fc2947a..b4fda1b37 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py @@ -17,8 +17,8 @@ class UserTokenOperations: """UserTokenOperations async operations. - You should not instantiate directly this class, but create a Client instance that will create it for you and attach - it as attribute. + You should not instantiate directly this class, but create a Client instance that will create it for you and + attach it as attribute. :param client: Client for service requests. :param config: Configuration of service client. @@ -348,3 +348,89 @@ async def get_token_status( return deserialized get_token_status.metadata = {"url": "/api/usertoken/GetTokenStatus"} + + async def exchange_async( + self, + user_id, + connection_name, + channel_id, + uri=None, + token=None, + *, + custom_headers=None, + raw=False, + **operation_config + ): + """ + + :param user_id: + :type user_id: str + :param connection_name: + :type connection_name: str + :param channel_id: + :type channel_id: str + :param uri: + :type uri: str + :param token: + :type token: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: object or ClientRawResponse if raw=true + :rtype: object or ~msrest.pipeline.ClientRawResponse + :raises: + :class:`ErrorResponseException` + """ + exchange_request = models.TokenExchangeRequest(uri=uri, token=token) + + # Construct URL + url = self.exchange_async.metadata["url"] + + # Construct parameters + query_parameters = {} + query_parameters["userId"] = self._serialize.query("user_id", user_id, "str") + query_parameters["connectionName"] = self._serialize.query( + "connection_name", connection_name, "str" + ) + query_parameters["channelId"] = self._serialize.query( + "channel_id", channel_id, "str" + ) + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + header_parameters["Content-Type"] = "application/json; charset=utf-8" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct body + body_content = self._serialize.body(exchange_request, "TokenExchangeRequest") + + # Construct and send request + request = self._client.post( + url, query_parameters, header_parameters, body_content + ) + response = await self._client.async_send( + request, stream=False, **operation_config + ) + + if response.status_code not in [200, 400, 404]: + raise models.ErrorResponseException(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("TokenResponse", response) + if response.status_code == 400: + deserialized = self._deserialize("ErrorResponse", response) + if response.status_code == 404: + deserialized = self._deserialize("TokenResponse", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + exchange_async.metadata = {"url": "/api/usertoken/exchange"} diff --git a/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py b/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py index a4896757f..be368b2f2 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py +++ b/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py @@ -14,6 +14,9 @@ from ._models_py3 import Error from ._models_py3 import ErrorResponse, ErrorResponseException from ._models_py3 import InnerHttpError + from ._models_py3 import SignInUrlResponse + from ._models_py3 import TokenExchangeRequest + from ._models_py3 import TokenExchangeResource from ._models_py3 import TokenResponse from ._models_py3 import TokenStatus except (SyntaxError, ImportError): @@ -21,6 +24,9 @@ from ._models import Error from ._models import ErrorResponse, ErrorResponseException from ._models import InnerHttpError + from ._models import SignInUrlResponse + from ._models import TokenExchangeRequest + from ._models import TokenExchangeResource from ._models import TokenResponse from ._models import TokenStatus @@ -30,6 +36,9 @@ "ErrorResponse", "ErrorResponseException", "InnerHttpError", + "SignInUrlResponse", + "TokenExchangeRequest", + "TokenExchangeResource", "TokenResponse", "TokenStatus", ] diff --git a/libraries/botframework-connector/botframework/connector/token_api/models/_models.py b/libraries/botframework-connector/botframework/connector/token_api/models/_models.py index bf92ee596..e100013a7 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/models/_models.py +++ b/libraries/botframework-connector/botframework/connector/token_api/models/_models.py @@ -12,6 +12,8 @@ from msrest.serialization import Model from msrest.exceptions import HttpOperationError +# pylint: disable=invalid-name + class AadResourceUrls(Model): """AadResourceUrls. @@ -99,6 +101,74 @@ def __init__(self, **kwargs): self.body = kwargs.get("body", None) +class SignInUrlResponse(Model): + """SignInUrlResponse. + + :param sign_in_link: + :type sign_in_link: str + :param token_exchange_resource: + :type token_exchange_resource: + ~botframework.tokenapi.models.TokenExchangeResource + """ + + _attribute_map = { + "sign_in_link": {"key": "signInLink", "type": "str"}, + "token_exchange_resource": { + "key": "tokenExchangeResource", + "type": "TokenExchangeResource", + }, + } + + def __init__(self, **kwargs): + super(SignInUrlResponse, self).__init__(**kwargs) + self.sign_in_link = kwargs.get("sign_in_link", None) + self.token_exchange_resource = kwargs.get("token_exchange_resource", None) + + +class TokenExchangeRequest(Model): + """TokenExchangeRequest. + + :param uri: + :type uri: str + :param token: + :type token: str + """ + + _attribute_map = { + "uri": {"key": "uri", "type": "str"}, + "token": {"key": "token", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TokenExchangeRequest, self).__init__(**kwargs) + self.uri = kwargs.get("uri", None) + self.token = kwargs.get("token", None) + + +class TokenExchangeResource(Model): + """TokenExchangeResource. + + :param id: + :type id: str + :param uri: + :type uri: str + :param provider_id: + :type provider_id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "uri": {"key": "uri", "type": "str"}, + "provider_id": {"key": "providerId", "type": "str"}, + } + + def __init__(self, **kwargs): + super(TokenExchangeResource, self).__init__(**kwargs) + self.id = kwargs.get("id", None) + self.uri = kwargs.get("uri", None) + self.provider_id = kwargs.get("provider_id", None) + + class TokenResponse(Model): """TokenResponse. diff --git a/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py b/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py index d5aee86de..bc2602eab 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py +++ b/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py @@ -12,6 +12,8 @@ from msrest.serialization import Model from msrest.exceptions import HttpOperationError +# pylint: disable=invalid-name + class AadResourceUrls(Model): """AadResourceUrls. @@ -101,6 +103,78 @@ def __init__(self, *, status_code: int = None, body=None, **kwargs) -> None: self.body = body +class SignInUrlResponse(Model): + """SignInUrlResponse. + + :param sign_in_link: + :type sign_in_link: str + :param token_exchange_resource: + :type token_exchange_resource: + ~botframework.tokenapi.models.TokenExchangeResource + """ + + _attribute_map = { + "sign_in_link": {"key": "signInLink", "type": "str"}, + "token_exchange_resource": { + "key": "tokenExchangeResource", + "type": "TokenExchangeResource", + }, + } + + def __init__( + self, *, sign_in_link: str = None, token_exchange_resource=None, **kwargs + ) -> None: + super(SignInUrlResponse, self).__init__(**kwargs) + self.sign_in_link = sign_in_link + self.token_exchange_resource = token_exchange_resource + + +class TokenExchangeRequest(Model): + """TokenExchangeRequest. + + :param uri: + :type uri: str + :param token: + :type token: str + """ + + _attribute_map = { + "uri": {"key": "uri", "type": "str"}, + "token": {"key": "token", "type": "str"}, + } + + def __init__(self, *, uri: str = None, token: str = None, **kwargs) -> None: + super(TokenExchangeRequest, self).__init__(**kwargs) + self.uri = uri + self.token = token + + +class TokenExchangeResource(Model): + """TokenExchangeResource. + + :param id: + :type id: str + :param uri: + :type uri: str + :param provider_id: + :type provider_id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "uri": {"key": "uri", "type": "str"}, + "provider_id": {"key": "providerId", "type": "str"}, + } + + def __init__( + self, *, id: str = None, uri: str = None, provider_id: str = None, **kwargs + ) -> None: + super(TokenExchangeResource, self).__init__(**kwargs) + self.id = id + self.uri = uri + self.provider_id = provider_id + + class TokenResponse(Model): """TokenResponse. diff --git a/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py b/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py index f4c45037d..a768a3afc 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py +++ b/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py @@ -18,8 +18,8 @@ class BotSignInOperations: """BotSignInOperations operations. - You should not instantiate directly this class, but create a Client instance that will create it for you and attach - it as attribute. + You should not instantiate directly this class, but create a Client instance that will create it for you and + attach it as attribute. :param client: Client for service requests. :param config: Configuration of service client. @@ -115,3 +115,78 @@ def get_sign_in_url( return deserialized get_sign_in_url.metadata = {"url": "/api/botsignin/GetSignInUrl"} + + def get_sign_in_resource( + self, + state, + code_challenge=None, + emulator_url=None, + final_redirect=None, + custom_headers=None, + raw=False, + **operation_config + ): + """ + + :param state: + :type state: str + :param code_challenge: + :type code_challenge: str + :param emulator_url: + :type emulator_url: str + :param final_redirect: + :type final_redirect: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: SignInUrlResponse or ClientRawResponse if raw=true + :rtype: ~botframework.tokenapi.models.SignInUrlResponse or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + # Construct URL + url = self.get_sign_in_resource.metadata["url"] + + # Construct parameters + query_parameters = {} + query_parameters["state"] = self._serialize.query("state", state, "str") + if code_challenge is not None: + query_parameters["code_challenge"] = self._serialize.query( + "code_challenge", code_challenge, "str" + ) + if emulator_url is not None: + query_parameters["emulatorUrl"] = self._serialize.query( + "emulator_url", emulator_url, "str" + ) + if final_redirect is not None: + query_parameters["finalRedirect"] = self._serialize.query( + "final_redirect", final_redirect, "str" + ) + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("SignInUrlResponse", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + get_sign_in_resource.metadata = {"url": "/api/botsignin/GetSignInResource"} diff --git a/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py b/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py index f154c7cd2..0d0a66ad7 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py +++ b/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py @@ -18,7 +18,7 @@ class UserTokenOperations: """UserTokenOperations operations. You should not instantiate directly this class, but create a Client instance that will create it for you and attach - it as attribute. + it as attribute. :param client: Client for service requests. :param config: Configuration of service client. @@ -336,3 +336,86 @@ def get_token_status( return deserialized get_token_status.metadata = {"url": "/api/usertoken/GetTokenStatus"} + + def exchange_async( + self, + user_id, + connection_name, + channel_id, + uri=None, + token=None, + custom_headers=None, + raw=False, + **operation_config + ): + """ + + :param user_id: + :type user_id: str + :param connection_name: + :type connection_name: str + :param channel_id: + :type channel_id: str + :param uri: + :type uri: str + :param token: + :type token: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: object or ClientRawResponse if raw=true + :rtype: object or ~msrest.pipeline.ClientRawResponse + :raises: + :class:`ErrorResponseException` + """ + exchange_request = models.TokenExchangeRequest(uri=uri, token=token) + + # Construct URL + url = self.exchange_async.metadata["url"] + + # Construct parameters + query_parameters = {} + query_parameters["userId"] = self._serialize.query("user_id", user_id, "str") + query_parameters["connectionName"] = self._serialize.query( + "connection_name", connection_name, "str" + ) + query_parameters["channelId"] = self._serialize.query( + "channel_id", channel_id, "str" + ) + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + header_parameters["Content-Type"] = "application/json; charset=utf-8" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct body + body_content = self._serialize.body(exchange_request, "TokenExchangeRequest") + + # Construct and send request + request = self._client.post( + url, query_parameters, header_parameters, body_content + ) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200, 400, 404]: + raise models.ErrorResponseException(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("TokenResponse", response) + if response.status_code == 400: + deserialized = self._deserialize("ErrorResponse", response) + if response.status_code == 404: + deserialized = self._deserialize("TokenResponse", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + exchange_async.metadata = {"url": "/api/usertoken/exchange"} diff --git a/libraries/swagger/TokenAPI.json b/libraries/swagger/TokenAPI.json index 76f1dc0bb..8e848793c 100644 --- a/libraries/swagger/TokenAPI.json +++ b/libraries/swagger/TokenAPI.json @@ -60,7 +60,7 @@ ], "responses": { "200": { - "description": "", + "description": "The operation succeeded.", "schema": { "type": "string" } @@ -68,6 +68,55 @@ } } }, + "/api/botsignin/GetSignInResource": { + "get": { + "tags": [ + "BotSignIn" + ], + "operationId": "BotSignIn_GetSignInResource", + "consumes": [], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "state", + "in": "query", + "required": true, + "type": "string" + }, + { + "name": "code_challenge", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "emulatorUrl", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "finalRedirect", + "in": "query", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SignInUrlResponse" + } + } + } + } + }, "/api/usertoken/GetToken": { "get": { "tags": [ @@ -293,9 +342,109 @@ } } } + }, + "/api/usertoken/exchange": { + "post": { + "tags": [ + "UserToken" + ], + "operationId": "UserToken_ExchangeAsync", + "consumes": [ + "application/json", + "text/json", + "application/xml", + "text/xml", + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json", + "text/json", + "application/xml", + "text/xml" + ], + "parameters": [ + { + "name": "userId", + "in": "query", + "required": true, + "type": "string" + }, + { + "name": "connectionName", + "in": "query", + "required": true, + "type": "string" + }, + { + "name": "channelId", + "in": "query", + "required": true, + "type": "string" + }, + { + "name": "exchangeRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TokenExchangeRequest" + } + } + ], + "responses": { + "200": { + "description": "A Token Response object will be returned\r\n", + "schema": { + "$ref": "#/definitions/TokenResponse" + } + }, + "400": { + "description": "", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Resource was not found\r\n", + "schema": { + "$ref": "#/definitions/TokenResponse" + } + }, + "default": { + "description": "The operation failed and the response is an error object describing the status code and failure.", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } } }, "definitions": { + "SignInUrlResponse": { + "type": "object", + "properties": { + "signInLink": { + "type": "string" + }, + "tokenExchangeResource": { + "$ref": "#/definitions/TokenExchangeResource" + } + } + }, + "TokenExchangeResource": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "providerId": { + "type": "string" + } + } + }, "TokenResponse": { "type": "object", "properties": { @@ -383,6 +532,17 @@ "type": "string" } } + }, + "TokenExchangeRequest": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/samples/experimental/sso/child/adapter_with_error_handler.py b/samples/experimental/sso/child/adapter_with_error_handler.py new file mode 100644 index 000000000..6eb8e230b --- /dev/null +++ b/samples/experimental/sso/child/adapter_with_error_handler.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from datetime import datetime + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + UserState, + TurnContext, +) +from botbuilder.schema import ActivityTypes, Activity + + +class AdapterWithErrorHandler(BotFrameworkAdapter): + def __init__( + self, + settings: BotFrameworkAdapterSettings, + conversation_state: ConversationState, + user_state: UserState, + ): + super().__init__(settings) + self._conversation_state = conversation_state + self._user_state = user_state + + # Catch-all for errors. + async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log + # 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) + traceback.print_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) + + # Clear out state + nonlocal self + await self._conversation_state.delete(context) + + self.on_turn_error = on_error + + async def send_activities(self, context, activities): + await self._conversation_state.save_changes(context) + await self._user_state.save_changes(context) + return await super().send_activities(context, activities) \ No newline at end of file diff --git a/samples/experimental/sso/child/app.py b/samples/experimental/sso/child/app.py new file mode 100755 index 000000000..3d8277d68 --- /dev/null +++ b/samples/experimental/sso/child/app.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from adapter_with_error_handler import AdapterWithErrorHandler +from bots import ChildBot +from dialogs import MainDialog +from config import DefaultConfig + +CONFIG = DefaultConfig() + +STORAGE = MemoryStorage() + +CONVERSATION_STATE = ConversationState(STORAGE) +USER_STATE = UserState(STORAGE) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = AdapterWithErrorHandler(SETTINGS, CONVERSATION_STATE, USER_STATE) + + +# 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) + traceback.print_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 + +DIALOG = MainDialog(CONFIG) + +# Create the Bot +BOT = ChildBot(DIALOG, USER_STATE, CONVERSATION_STATE, CONFIG) + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + +"""async def options(req: Request) -> Response: + return Response(status=200)""" + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + logging.basicConfig(level=logging.DEBUG) + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/sso/child/bots/__init__.py b/samples/experimental/sso/child/bots/__init__.py new file mode 100755 index 000000000..aa82cac78 --- /dev/null +++ b/samples/experimental/sso/child/bots/__init__.py @@ -0,0 +1,4 @@ +from .child_bot import ChildBot + + +__all__ = ["ChildBot"] diff --git a/samples/experimental/sso/child/bots/child_bot.py b/samples/experimental/sso/child/bots/child_bot.py new file mode 100755 index 000000000..df60e6fe1 --- /dev/null +++ b/samples/experimental/sso/child/bots/child_bot.py @@ -0,0 +1,73 @@ +from typing import List + +from botbuilder.core import ( + ActivityHandler, + BotFrameworkAdapter, + ConversationState, + UserState, + MessageFactory, + TurnContext, +) +from botbuilder.dialogs import DialogState +from botframework.connector.auth import MicrosoftAppCredentials + +from config import DefaultConfig +from helpers.dialog_helper import DialogHelper +from dialogs import MainDialog + + +class ChildBot(ActivityHandler): + def __init__( + self, + dialog: MainDialog, + user_state: UserState, + conversation_state: ConversationState, + config: DefaultConfig, + ): + self._user_state = user_state + self._conversation_state = conversation_state + self._dialog = dialog + self._connection_name = config.CONNECTION_NAME + self._config = config + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + await self._conversation_state.save_changes(turn_context) + await self._user_state.save_changes(turn_context) + + async def on_sign_in_invoke( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + await self._conversation_state.load(turn_context, True) + await self._user_state.load(turn_context, True) + await DialogHelper.run_dialog( + self._dialog, + turn_context, + self._conversation_state.create_property(DialogState.__name__) + ) + + async def on_message_activity(self, turn_context: TurnContext): + if turn_context.activity.channel_id != "emulator": + if "skill login" in turn_context.activity.text: + await self._conversation_state.load(turn_context, True) + await self._user_state.load(turn_context, True) + await DialogHelper.run_dialog( + self._dialog, + turn_context, + self._conversation_state.create_property(DialogState.__name__) + ) + return + elif "skill logout" in turn_context.activity.text: + adapter: BotFrameworkAdapter = turn_context.adapter + await adapter.sign_out_user( + turn_context, + self._connection_name, + turn_context.activity.from_property.id, + MicrosoftAppCredentials(self._config.APP_ID, self._config.APP_PASSWORD)) + await turn_context.send_activity(MessageFactory.text("logout from child bot successful")) + else: + await turn_context.send_activity(MessageFactory.text("child: activity (1)")) + await turn_context.send_activity(MessageFactory.text("child: activity (2)")) + await turn_context.send_activity(MessageFactory.text("child: activity (3)")) + await turn_context.send_activity(MessageFactory.text(f"child: {turn_context.activity.text}")) diff --git a/samples/experimental/sso/child/config.py b/samples/experimental/sso/child/config.py new file mode 100755 index 000000000..97a5625bf --- /dev/null +++ b/samples/experimental/sso/child/config.py @@ -0,0 +1,16 @@ +#!/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", "") + CONNECTION_NAME = "" diff --git a/samples/experimental/sso/child/dialogs/__init__.py b/samples/experimental/sso/child/dialogs/__init__.py new file mode 100755 index 000000000..9a834bd37 --- /dev/null +++ b/samples/experimental/sso/child/dialogs/__init__.py @@ -0,0 +1,5 @@ +from .main_dialog import MainDialog + +__all__ = [ + "MainDialog" +] diff --git a/samples/experimental/sso/child/dialogs/main_dialog.py b/samples/experimental/sso/child/dialogs/main_dialog.py new file mode 100755 index 000000000..d3f070ed5 --- /dev/null +++ b/samples/experimental/sso/child/dialogs/main_dialog.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogTurnResult, + OAuthPrompt, + OAuthPromptSettings, + WaterfallDialog, + WaterfallStepContext +) +from botbuilder.schema import TokenResponse +from botbuilder.core import MessageFactory +from botframework.connector.auth import MicrosoftAppCredentials + +from config import DefaultConfig + + +class MainDialog(ComponentDialog): + def __init__(self, config: DefaultConfig): + super(MainDialog, self).__init__(MainDialog.__name__) + + self.connection_name = config.CONNECTION_NAME + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, + [self.sign_in_step, self.show_token_response] + ) + ) + self.add_dialog( + OAuthPrompt( + OAuthPrompt.__name__, + OAuthPromptSettings( + connection_name=self.connection_name, + text="Sign In to AAD", + title="Sign In", + oauth_app_credentials=MicrosoftAppCredentials( + app_id=config.APP_ID, + password=config.APP_PASSWORD + ) + ) + ) + ) + + async def sign_in_step(self, context: WaterfallStepContext) -> DialogTurnResult: + return await context.begin_dialog(OAuthPrompt.__name__) + + async def show_token_response(self, context: WaterfallStepContext) -> DialogTurnResult: + result: TokenResponse = context.result + if not result: + await context.context.send_activity(MessageFactory.text("Skill: No token response from OAuthPrompt")) + else: + await context.context.send_activity(MessageFactory.text(f"Skill: Your token is {result.token}")) + + return await context.end_dialog() diff --git a/samples/experimental/sso/child/helpers/__init__.py b/samples/experimental/sso/child/helpers/__init__.py new file mode 100755 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/experimental/sso/child/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/experimental/sso/child/helpers/dialog_helper.py b/samples/experimental/sso/child/helpers/dialog_helper.py new file mode 100755 index 000000000..6b2646b0b --- /dev/null +++ b/samples/experimental/sso/child/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/experimental/sso/parent/ReadMeForSSOTesting.md b/samples/experimental/sso/parent/ReadMeForSSOTesting.md new file mode 100644 index 000000000..a5009494d --- /dev/null +++ b/samples/experimental/sso/parent/ReadMeForSSOTesting.md @@ -0,0 +1,37 @@ +This guide documents how to configure and test SSO by using the parent and child bot projects. +## SetUp +- Go to [App registrations page on Azure Portal](https://ms.portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) +- You need to create 2 AAD apps (one for the parent bot and one for the skill) +### Parent bot AAD app +- Click "New Registration" +- Enter name, set "supported account types" as Single Tenant, Redirect URI as https://token.botframework.com/.auth/web/redirect +- Go to "Expose an API". Click "Add a Scope". Enter a scope name (like "scope1"), set "who can consent" to Admins and users, display name, description and click "Add Scope" . Copy the value of the scope that you just added (should be like "api://{clientId}/scopename") +- Go to "Manifest" tab and set `accessTokenAcceptedVersion` to 2 +- Go to "Certificates and secrets" , click "new client secret" and store the generated secret. + +### Configuring the Parent Bot Channel Registration +- Create a new Bot Channel Registration. You can leave the messaging endpoint empty and later fill an ngrok endpoint for it. +- Go to settings tab, click "Add Setting" and enter a name, set Service Provider to "Azure Active Directory v2". +- Fill in ClientId, TenantId from the parent bot AAD app you created (look at the overview tab for these values) +- Fill in the secret from the parent bot AAD app. +- Fill in the scope that you copied earlier ("api://{clientId}/scopename") and enter it for "Scopes" on the OAuth connection. Click Save. + +### Child bot AAD app and BCR +- Follow the steps in the [documentation](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-authentication?view=azure-bot-service-4.0&tabs=csharp) for creating an Azure AD v2 app and filling those values in a Bot Channel Registration. +- Go to the Azure AD app that you created in the step above. +- Go to "Manifest" tab and set `accessTokenAcceptedVersion` to 2 +- Go to "Expose an API". Click "Add a client application". Enter the clientId of the parent bot AAD app. +- Go to "Expose an API". Click "Add a Scope". Enter a scope name (like "scope1"), set "who can consent" to Admins and users, display name, description and click "Add Scope" . Copy the value of the scope that you just added (should be like "api://{clientId}/scopename") +- Go back to your BCR that you created for the child bot. Go to Auth Connections in the settings blade and click on the auth connection that you created earlier. For the "Token Exchange Uri" , set the scope value that you copied in the step above. + +### Running and Testing +- Configure appid, passoword and connection names in the appsettings.json files for both parent and child bots. Run both the projects. +- Set up ngrok to expose the url for the parent bot. (Child bot can run just locally, as long as it's on the same machine as the parent bot.) +- Configure the messaging endpoint for the parent bot channel registration with the ngrok url and go to "test in webchat" tab. +- Run the following commands and look at the outputs + - login - shows an oauth card. Click the oauth card to login into the parent bot. + - type "login" again - shows your JWT token. + - skill login - should do nothing (no oauth card shown). + - type "skill login" again - should show you a message from the skill with the token. + - logout - should give a message that you have been logged out from the parent bot. + - skill logout - should give a message that you have been logged out from the child bot. diff --git a/samples/experimental/sso/parent/app.py b/samples/experimental/sso/parent/app.py new file mode 100755 index 000000000..aea35bc34 --- /dev/null +++ b/samples/experimental/sso/parent/app.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + BotFrameworkHttpClient, + ConversationState, + MemoryStorage, + TurnContext, + UserState, + BotFrameworkAdapter, +) +from botbuilder.core.integration import ( + aiohttp_error_middleware, +) +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ( + SimpleCredentialProvider, +) +from bots import ParentBot +from config import DefaultConfig +from dialogs import MainDialog + +CONFIG = DefaultConfig() + +STORAGE = MemoryStorage() + +CONVERSATION_STATE = ConversationState(STORAGE) +USER_STATE = UserState(STORAGE) + +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = BotFrameworkHttpClient(CREDENTIAL_PROVIDER) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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) + traceback.print_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 + +DIALOG = MainDialog(CONFIG) +# Create the Bot +BOT = ParentBot(CLIENT, CONFIG, DIALOG, CONVERSATION_STATE, USER_STATE) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + logging.basicConfig(level=logging.DEBUG) + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/sso/parent/bots/__init__.py b/samples/experimental/sso/parent/bots/__init__.py new file mode 100755 index 000000000..ab6c7b715 --- /dev/null +++ b/samples/experimental/sso/parent/bots/__init__.py @@ -0,0 +1,4 @@ +from .parent_bot import ParentBot + + +__all__ = ["ParentBot"] diff --git a/samples/experimental/sso/parent/bots/parent_bot.py b/samples/experimental/sso/parent/bots/parent_bot.py new file mode 100755 index 000000000..9374d2cce --- /dev/null +++ b/samples/experimental/sso/parent/bots/parent_bot.py @@ -0,0 +1,221 @@ +from uuid import uuid4 + +from datetime import datetime +from http import HTTPStatus +from typing import List + +from botbuilder.core import ( + ActivityHandler, + BotFrameworkAdapter, + BotFrameworkHttpClient, + CardFactory, + ConversationState, + UserState, + MessageFactory, + TurnContext +) +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationAccount, + DeliveryModes, + ChannelAccount, + OAuthCard, + TokenExchangeInvokeRequest +) +from botframework.connector.token_api.models import TokenExchangeResource, TokenExchangeRequest + +from config import DefaultConfig +from helpers.dialog_helper import DialogHelper +from dialogs import MainDialog + + +class ParentBot(ActivityHandler): + def __init__( + self, + skill_client: BotFrameworkHttpClient, + config: DefaultConfig, + dialog: MainDialog, + conversation_state: ConversationState, + user_state: UserState, + ): + self._client = skill_client + self._conversation_state = conversation_state + self._user_state = user_state + self._dialog = dialog + self._from_bot_id = config.APP_ID + self._to_bot_id = config.SKILL_APP_ID + self._connection_name = config.CONNECTION_NAME + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + await self._conversation_state.save_changes(turn_context) + await self._user_state.save_changes(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + # for signin, just use an oauth prompt to get the exchangeable token + # also ensure that the channelId is not emulator + if turn_context.activity.type != "emulator": + if turn_context.activity.text == "login" or turn_context.activity.text.isdigit(): + await self._conversation_state.load(turn_context, True) + await self._user_state.load(turn_context, True) + await DialogHelper.run_dialog( + self._dialog, + turn_context, + self._conversation_state.create_property("DialogState"), + ) + elif turn_context.activity.text == "logout": + bot_adapter = turn_context.adapter + await bot_adapter.sign_out_user(turn_context, self._connection_name) + await turn_context.send_activity(MessageFactory.text("You have been signed out.")) + elif turn_context.activity.text in ("skill login", "skill logout"): + # incoming activity needs to be cloned for buffered replies + clone_activity = MessageFactory.text(turn_context.activity.text) + + TurnContext.apply_conversation_reference( + clone_activity, + TurnContext.get_conversation_reference(turn_context.activity), + True + ) + + clone_activity.delivery_mode = DeliveryModes.expect_replies + + response_1 = await self._client.post_activity( + self._from_bot_id, + self._to_bot_id, + "http://localhost:2303/api/messages", + "http://tempuri.org/whatever", + turn_context.activity.conversation.id, + clone_activity, + ) + + if response_1.status == int(HTTPStatus.OK): + if not await self._intercept_oauth_cards(response_1.body, turn_context): + await turn_context.send_activities(response_1.body) + + return + + await turn_context.send_activity(MessageFactory.text("parent: before child")) + + activity = MessageFactory.text("parent: before child") + TurnContext.apply_conversation_reference( + activity, + TurnContext.get_conversation_reference(turn_context.activity), + True + ) + activity.delivery_mode = DeliveryModes.expect_replies + + response = await self._client.post_activity( + self._from_bot_id, + self._to_bot_id, + "http://localhost:2303/api/messages", + "http://tempuri.org/whatever", + str(uuid4()), + activity + ) + + if response.status == int(HTTPStatus.OK): + await turn_context.send_activities(response.body) + + await turn_context.send_activity(MessageFactory.text("parent: after child")) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + MessageFactory.text("Hello and welcome!") + ) + + async def _intercept_oauth_cards( + self, + activities: List[Activity], + turn_context: TurnContext, + ) -> bool: + if not activities: + return False + activity = activities[0] + + if activity.attachments: + for attachment in filter(lambda att: att.content_type == CardFactory.content_types.oauth_card, + activity.attachments): + oauth_card: OAuthCard = OAuthCard().from_dict(attachment.content) + oauth_card.token_exchange_resource: TokenExchangeResource = TokenExchangeResource().from_dict( + oauth_card.token_exchange_resource) + if oauth_card.token_exchange_resource: + token_exchange_provider: BotFrameworkAdapter = turn_context.adapter + + result = await token_exchange_provider.exchange_token( + turn_context, + self._connection_name, + turn_context.activity.from_property.id, + TokenExchangeRequest(uri=oauth_card.token_exchange_resource.uri) + ) + + if result.token: + return await self._send_token_exchange_invoke_to_skill( + turn_context, + activity, + oauth_card.token_exchange_resource.id, + result.token + ) + return False + + async def _send_token_exchange_invoke_to_skill( + self, + turn_context: TurnContext, + incoming_activity: Activity, + identifier: str, + token: str + ) -> bool: + activity = self._create_reply(incoming_activity) + activity.type = ActivityTypes.invoke + activity.name = "signin/tokenExchange" + activity.value = TokenExchangeInvokeRequest( + id=identifier, + token=token, + ) + + # route the activity to the skill + response = await self._client.post_activity( + self._from_bot_id, + self._to_bot_id, + "http://localhost:2303/api/messages", + "http://tempuri.org/whatever", + incoming_activity.conversation.id, + activity + ) + + # Check response status: true if success, false if failure + is_success = int(HTTPStatus.OK) <= response.status <= 299 + message = "Skill token exchange successful" if is_success else "Skill token exchange failed" + + await turn_context.send_activity(MessageFactory.text( + message + )) + + return is_success + + def _create_reply(self, activity) -> Activity: + 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="", + locale=activity.locale, + ) diff --git a/samples/experimental/sso/parent/config.py b/samples/experimental/sso/parent/config.py new file mode 100755 index 000000000..88bbf313c --- /dev/null +++ b/samples/experimental/sso/parent/config.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from typing import Dict +from botbuilder.core.skills import BotFrameworkSkill + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3978 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + CONNECTION_NAME = "" + SKILL_MICROSOFT_APP_ID = "" diff --git a/samples/experimental/sso/parent/dialogs/__init__.py b/samples/experimental/sso/parent/dialogs/__init__.py new file mode 100755 index 000000000..9a834bd37 --- /dev/null +++ b/samples/experimental/sso/parent/dialogs/__init__.py @@ -0,0 +1,5 @@ +from .main_dialog import MainDialog + +__all__ = [ + "MainDialog" +] diff --git a/samples/experimental/sso/parent/dialogs/main_dialog.py b/samples/experimental/sso/parent/dialogs/main_dialog.py new file mode 100644 index 000000000..58787e0bd --- /dev/null +++ b/samples/experimental/sso/parent/dialogs/main_dialog.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogTurnResult, + WaterfallDialog, + WaterfallStepContext +) +from botbuilder.dialogs.prompts import ( + OAuthPrompt, + OAuthPromptSettings +) +from botbuilder.schema import TokenResponse +from botbuilder.core import MessageFactory + +from config import DefaultConfig + + +class MainDialog(ComponentDialog): + def __init__(self, configuration: DefaultConfig): + super().__init__(MainDialog.__name__) + + self._connection_name = configuration.CONNECTION_NAME + + self.add_dialog( + OAuthPrompt( + OAuthPrompt.__name__, + OAuthPromptSettings( + connection_name=self._connection_name, + text=f"Sign In to AAD", + title="Sign In", + ), + ) + ) + + self.add_dialog( + WaterfallDialog( + WaterfallDialog.__name__, [self._sign_in_step, self._show_token_response] + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def _sign_in_step(self, context: WaterfallStepContext) -> DialogTurnResult: + return await context.begin_dialog(OAuthPrompt.__name__) + + async def _show_token_response(self, context: WaterfallStepContext) -> DialogTurnResult: + result: TokenResponse = context.result + + if not result: + await context.context.send_activity(MessageFactory.text("No token response from OAuthPrompt")) + else: + await context.context.send_activity(MessageFactory.text(f"Your token is {result.token}")) + + return await context.end_dialog() diff --git a/samples/experimental/sso/parent/helpers/__init__.py b/samples/experimental/sso/parent/helpers/__init__.py new file mode 100755 index 000000000..a824eb8f4 --- /dev/null +++ b/samples/experimental/sso/parent/helpers/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/experimental/sso/parent/helpers/dialog_helper.py b/samples/experimental/sso/parent/helpers/dialog_helper.py new file mode 100755 index 000000000..6b2646b0b --- /dev/null +++ b/samples/experimental/sso/parent/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/experimental/sso/parent/skill_client.py b/samples/experimental/sso/parent/skill_client.py new file mode 100644 index 000000000..ae43cc339 --- /dev/null +++ b/samples/experimental/sso/parent/skill_client.py @@ -0,0 +1,30 @@ +from botbuilder.core import BotFrameworkHttpClient, InvokeResponse, TurnContext +from botbuilder.core.skills import BotFrameworkSkill, ConversationIdFactoryBase +from botbuilder.schema import Activity + + +class SkillHttpClient(BotFrameworkHttpClient): + def __init__(self, credential_provider, conversation_id_factory, channel_provider=None): + super().__init__(credential_provider, channel_provider) + + self._conversation_id_factory: ConversationIdFactoryBase = conversation_id_factory + + async def post_activity_to_skill( + self, + from_bot_id: str, + to_skill: BotFrameworkSkill, + callback_url: str, + activity: Activity, + ) -> InvokeResponse: + skill_conversation_id = await self._conversation_id_factory.create_skill_conversation_id( + TurnContext.get_conversation_reference(activity) + ) + + return await self.post_activity( + from_bot_id, + to_skill.app_id, + to_skill.skill_endpoint, + callback_url, + skill_conversation_id, + activity + ) From faf4eed121c331ca0e4d373d05e19aa34d8d2877 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 10 Mar 2020 15:52:37 -0700 Subject: [PATCH 351/616] Updates to sso sample (#851) --- samples/experimental/sso/child/app.py | 5 +- samples/experimental/sso/child/config.py | 2 +- .../sso/parent/bots/parent_bot.py | 86 +++++++++++-------- 3 files changed, 53 insertions(+), 40 deletions(-) diff --git a/samples/experimental/sso/child/app.py b/samples/experimental/sso/child/app.py index 3d8277d68..03774b27a 100755 --- a/samples/experimental/sso/child/app.py +++ b/samples/experimental/sso/child/app.py @@ -8,6 +8,7 @@ from aiohttp import web from aiohttp.web import Request, Response +from aiohttp.web_response import json_response from botbuilder.core import ( BotFrameworkAdapterSettings, ConversationState, @@ -83,7 +84,9 @@ async def messages(req: Request) -> Response: auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" try: - await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) return Response(status=201) except Exception as exception: raise exception diff --git a/samples/experimental/sso/child/config.py b/samples/experimental/sso/child/config.py index 97a5625bf..e7e7e320f 100755 --- a/samples/experimental/sso/child/config.py +++ b/samples/experimental/sso/child/config.py @@ -10,7 +10,7 @@ class DefaultConfig: """ Bot Configuration """ - PORT = 3978 + PORT = 3979 APP_ID = os.environ.get("MicrosoftAppId", "") APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") CONNECTION_NAME = "" diff --git a/samples/experimental/sso/parent/bots/parent_bot.py b/samples/experimental/sso/parent/bots/parent_bot.py index 9374d2cce..fe9abbc2c 100755 --- a/samples/experimental/sso/parent/bots/parent_bot.py +++ b/samples/experimental/sso/parent/bots/parent_bot.py @@ -12,7 +12,7 @@ ConversationState, UserState, MessageFactory, - TurnContext + TurnContext, ) from botbuilder.schema import ( Activity, @@ -21,9 +21,12 @@ DeliveryModes, ChannelAccount, OAuthCard, - TokenExchangeInvokeRequest + TokenExchangeInvokeRequest, +) +from botframework.connector.token_api.models import ( + TokenExchangeResource, + TokenExchangeRequest, ) -from botframework.connector.token_api.models import TokenExchangeResource, TokenExchangeRequest from config import DefaultConfig from helpers.dialog_helper import DialogHelper @@ -44,7 +47,7 @@ def __init__( self._user_state = user_state self._dialog = dialog self._from_bot_id = config.APP_ID - self._to_bot_id = config.SKILL_APP_ID + self._to_bot_id = config.SKILL_MICROSOFT_APP_ID self._connection_name = config.CONNECTION_NAME async def on_turn(self, turn_context: TurnContext): @@ -57,7 +60,10 @@ async def on_message_activity(self, turn_context: TurnContext): # for signin, just use an oauth prompt to get the exchangeable token # also ensure that the channelId is not emulator if turn_context.activity.type != "emulator": - if turn_context.activity.text == "login" or turn_context.activity.text.isdigit(): + if ( + turn_context.activity.text == "login" + or turn_context.activity.text.isdigit() + ): await self._conversation_state.load(turn_context, True) await self._user_state.load(turn_context, True) await DialogHelper.run_dialog( @@ -68,7 +74,9 @@ async def on_message_activity(self, turn_context: TurnContext): elif turn_context.activity.text == "logout": bot_adapter = turn_context.adapter await bot_adapter.sign_out_user(turn_context, self._connection_name) - await turn_context.send_activity(MessageFactory.text("You have been signed out.")) + await turn_context.send_activity( + MessageFactory.text("You have been signed out.") + ) elif turn_context.activity.text in ("skill login", "skill logout"): # incoming activity needs to be cloned for buffered replies clone_activity = MessageFactory.text(turn_context.activity.text) @@ -76,23 +84,25 @@ async def on_message_activity(self, turn_context: TurnContext): TurnContext.apply_conversation_reference( clone_activity, TurnContext.get_conversation_reference(turn_context.activity), - True + True, ) clone_activity.delivery_mode = DeliveryModes.expect_replies - response_1 = await self._client.post_activity( + activities = await self._client.post_buffered_activity( self._from_bot_id, self._to_bot_id, - "http://localhost:2303/api/messages", + "http://localhost:3979/api/messages", "http://tempuri.org/whatever", turn_context.activity.conversation.id, clone_activity, ) - if response_1.status == int(HTTPStatus.OK): - if not await self._intercept_oauth_cards(response_1.body, turn_context): - await turn_context.send_activities(response_1.body) + if activities: + if not await self._intercept_oauth_cards( + activities, turn_context + ): + await turn_context.send_activities(activities) return @@ -102,22 +112,20 @@ async def on_message_activity(self, turn_context: TurnContext): TurnContext.apply_conversation_reference( activity, TurnContext.get_conversation_reference(turn_context.activity), - True + True, ) activity.delivery_mode = DeliveryModes.expect_replies - response = await self._client.post_activity( + activities = await self._client.post_buffered_activity( self._from_bot_id, self._to_bot_id, - "http://localhost:2303/api/messages", + "http://localhost:3979/api/messages", "http://tempuri.org/whatever", str(uuid4()), - activity + activity, ) - if response.status == int(HTTPStatus.OK): - await turn_context.send_activities(response.body) - + await turn_context.send_activities(activities) await turn_context.send_activity(MessageFactory.text("parent: after child")) async def on_members_added_activity( @@ -130,20 +138,21 @@ async def on_members_added_activity( ) async def _intercept_oauth_cards( - self, - activities: List[Activity], - turn_context: TurnContext, + self, activities: List[Activity], turn_context: TurnContext, ) -> bool: if not activities: return False activity = activities[0] if activity.attachments: - for attachment in filter(lambda att: att.content_type == CardFactory.content_types.oauth_card, - activity.attachments): + for attachment in filter( + lambda att: att.content_type == CardFactory.content_types.oauth_card, + activity.attachments, + ): oauth_card: OAuthCard = OAuthCard().from_dict(attachment.content) oauth_card.token_exchange_resource: TokenExchangeResource = TokenExchangeResource().from_dict( - oauth_card.token_exchange_resource) + oauth_card.token_exchange_resource + ) if oauth_card.token_exchange_resource: token_exchange_provider: BotFrameworkAdapter = turn_context.adapter @@ -151,7 +160,9 @@ async def _intercept_oauth_cards( turn_context, self._connection_name, turn_context.activity.from_property.id, - TokenExchangeRequest(uri=oauth_card.token_exchange_resource.uri) + TokenExchangeRequest( + uri=oauth_card.token_exchange_resource.uri + ), ) if result.token: @@ -159,7 +170,7 @@ async def _intercept_oauth_cards( turn_context, activity, oauth_card.token_exchange_resource.id, - result.token + result.token, ) return False @@ -168,33 +179,32 @@ async def _send_token_exchange_invoke_to_skill( turn_context: TurnContext, incoming_activity: Activity, identifier: str, - token: str + token: str, ) -> bool: activity = self._create_reply(incoming_activity) activity.type = ActivityTypes.invoke activity.name = "signin/tokenExchange" - activity.value = TokenExchangeInvokeRequest( - id=identifier, - token=token, - ) + activity.value = TokenExchangeInvokeRequest(id=identifier, token=token,) # route the activity to the skill response = await self._client.post_activity( self._from_bot_id, self._to_bot_id, - "http://localhost:2303/api/messages", + "http://localhost:3979/api/messages", "http://tempuri.org/whatever", incoming_activity.conversation.id, - activity + activity, ) # Check response status: true if success, false if failure is_success = int(HTTPStatus.OK) <= response.status <= 299 - message = "Skill token exchange successful" if is_success else "Skill token exchange failed" + message = ( + "Skill token exchange successful" + if is_success + else "Skill token exchange failed" + ) - await turn_context.send_activity(MessageFactory.text( - message - )) + await turn_context.send_activity(MessageFactory.text(message)) return is_success From d282ea386b74f8c36ce0873cf9b85fb7da2aaa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 11 Mar 2020 08:47:59 -0700 Subject: [PATCH 352/616] Axsuarez/skill dialog (#757) * initial changes for skill dialog * Skill dialog * Initial echo skill with dialog * pylint: Initial echo skill with dialog * made integration package for aiohttp, dialog root bot for testing * pylint: made integration package for aiohttp, dialog root bot for testing * pylint: made integration package for aiohttp, dialog root bot for testing * pylint: made integration package for aiohttp, dialog root bot for testing * Initial dialog skill bot * Changes to skill bot * Updates for dialog interruption and buffered response. Pending to move some classes to botbuilder.integration.aiohttp * Relates to in post_activity in BotFrameworkHttpClient * fix on BeginSkillDialogOptions * Moved SkillHttpClient to correct library with corresponding tests. Fix SkillDialog. * black: Moved SkillHttpClient to correct library with corresponding tests. Fix SkillDialog. * relative import fix * Removed unused import * Modified TurnContext.send_trace_activity to default args. * Removed argument checks that didn't exist in C#. Fixed bug in SkillDialog.begin_dialog * Added initial SkillDialog unit test. * Added remainder of SkillDialog unit tests * Updates on dialog-root-bot * Updated buffered_replies to expect_replies * Using HTTPStatus defines. * Skill OAuth only change card action for emulator * black * skill root bot updated * skill root bot updated * Removed old import in dialog root bot * Dialog-to-dialog work * Ummm... the actual dialog-to-dialog work * Corrected dialog-skill-bot AcitivyRouterDialog to actually have a WaterfallDialog step. * dialog-to-dialog test bot changes: dialog-echo-skill-bot, corrected missing async on ComponentDialog * dialog-to-dialog: Handling messages with values (serialization and whatnot) * Memory storage does not validate e_tag integrity anymore, following the same behavior as C# * pylint: Memory storage does not validate e_tag integrity anymore, following the same behavior as C# * pylint: Memory storage does not validate e_tag integrity anymore, following the same behavior as C# * Removing samples from product code PR Co-authored-by: tracyboehrer --- ci-pr-pipeline.yml | 1 + .../botbuilder/core/__init__.py | 2 - .../botbuilder/core/adapters/test_adapter.py | 4 +- .../botbuilder/core/bot_adapter.py | 2 +- .../botbuilder/core/memory_storage.py | 12 +- .../botbuilder/core/skills/__init__.py | 2 + .../core/skills/bot_framework_client.py | 20 ++ .../skill_conversation_id_factory_options.py | 20 -- .../skills/skill_conversation_reference.py | 8 - .../botbuilder/core/turn_context.py | 2 +- .../botbuilder/dialogs/__init__.py | 5 + .../botbuilder/dialogs/component_dialog.py | 4 +- .../botbuilder/dialogs/dialog_events.py | 13 + .../botbuilder/dialogs/dialog_extensions.py | 77 +++++ .../botbuilder/dialogs/skills/__init__.py | 17 ++ .../skills/begin_skill_dialog_options.py | 18 ++ .../botbuilder/dialogs/skills/skill_dialog.py | 233 ++++++++++++++++ .../dialogs/skills/skill_dialog_options.py | 27 ++ libraries/botbuilder-dialogs/setup.py | 1 + .../tests/test_skill_dialog.py | 262 ++++++++++++++++++ .../botbuilder-integration-aiohttp/README.rst | 83 ++++++ .../integration/aiohttp/__init__.py | 16 ++ .../botbuilder/integration/aiohttp/about.py | 14 + .../aiohttp/aiohttp_channel_service.py | 176 ++++++++++++ ...tp_channel_service_exception_middleware.py | 29 ++ .../aiohttp}/bot_framework_http_client.py | 13 +- .../integration/aiohttp/skills/__init__.py | 4 + .../aiohttp}/skills/skill_http_client.py | 9 +- .../requirements.txt | 4 + .../botbuilder-integration-aiohttp/setup.cfg | 2 + .../botbuilder-integration-aiohttp/setup.py | 54 ++++ .../tests/test_bot_framework_http_client.py | 2 +- .../botbuilder/testing/dialog_test_client.py | 7 +- .../botbuilder/testing/storage_base_tests.py | 27 +- .../skills-buffered/parent/app.py | 2 +- .../skills-buffered/parent/bots/parent_bot.py | 2 +- .../simple-bot-to-bot/simple-root-bot/app.py | 2 +- .../simple-root-bot/bots/root_bot.py | 2 +- samples/experimental/test-protocol/app.py | 3 +- 39 files changed, 1114 insertions(+), 67 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/bot_framework_client.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/skills/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py create mode 100644 libraries/botbuilder-dialogs/tests/test_skill_dialog.py create mode 100644 libraries/botbuilder-integration-aiohttp/README.rst create mode 100644 libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/__init__.py create mode 100644 libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py create mode 100644 libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service.py create mode 100644 libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service_exception_middleware.py rename libraries/{botbuilder-core/botbuilder/core => botbuilder-integration-aiohttp/botbuilder/integration/aiohttp}/bot_framework_http_client.py (95%) create mode 100644 libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py rename libraries/{botbuilder-core/botbuilder/core => botbuilder-integration-aiohttp/botbuilder/integration/aiohttp}/skills/skill_http_client.py (92%) create mode 100644 libraries/botbuilder-integration-aiohttp/requirements.txt create mode 100644 libraries/botbuilder-integration-aiohttp/setup.cfg create mode 100644 libraries/botbuilder-integration-aiohttp/setup.py rename libraries/{botbuilder-core => botbuilder-integration-aiohttp}/tests/test_bot_framework_http_client.py (77%) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index e70861290..9554439ba 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -53,6 +53,7 @@ jobs: pip install -e ./libraries/botbuilder-testing pip install -e ./libraries/botbuilder-integration-applicationinsights-aiohttp pip install -e ./libraries/botbuilder-adapters-slack + pip install -e ./libraries/botbuilder-integration-aiohttp pip install -r ./libraries/botframework-connector/tests/requirements.txt pip install -r ./libraries/botbuilder-core/tests/requirements.txt pip install coveralls diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index c65569f52..f9a846ea5 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -22,7 +22,6 @@ from .extended_user_token_provider import ExtendedUserTokenProvider from .intent_score import IntentScore from .invoke_response import InvokeResponse -from .bot_framework_http_client import BotFrameworkHttpClient from .memory_storage import MemoryStorage from .memory_transcript_store import MemoryTranscriptStore from .message_factory import MessageFactory @@ -63,7 +62,6 @@ "ExtendedUserTokenProvider", "IntentScore", "InvokeResponse", - "BotFrameworkHttpClient", "MemoryStorage", "MemoryTranscriptStore", "MessageFactory", diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index c731d6ada..95048695f 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -241,7 +241,9 @@ async def receive_activity(self, activity): return await self.run_pipeline(context, self.logic) def get_next_activity(self) -> Activity: - return self.activity_buffer.pop(0) + if len(self.activity_buffer) > 0: + return self.activity_buffer.pop(0) + return None async def send(self, user_says) -> object: """ diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index ca9a649bc..421e34ff2 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -14,7 +14,7 @@ class BotAdapter(ABC): BOT_IDENTITY_KEY = "BotIdentity" - BOT_OAUTH_SCOPE_KEY = "OAuthScope" + BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" BOT_CALLBACK_HANDLER_KEY = "BotCallbackHandler" diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index 482527853..c61b053c7 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -73,10 +73,14 @@ async def write(self, changes: Dict[str, StoreItem]): "Etag conflict.\nOriginal: %s\r\nCurrent: %s" % (new_value_etag, old_state_etag) ) - if isinstance(new_state, dict): - new_state["e_tag"] = str(self._e_tag) - else: - new_state.e_tag = str(self._e_tag) + + # If the original object didn't have an e_tag, don't set one (C# behavior) + if old_state_etag: + if isinstance(new_state, dict): + new_state["e_tag"] = str(self._e_tag) + else: + new_state.e_tag = str(self._e_tag) + self._e_tag += 1 self.memory[key] = deepcopy(new_state) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py index 116f9aeef..ce949b12a 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py @@ -6,6 +6,7 @@ # -------------------------------------------------------------------------- from .bot_framework_skill import BotFrameworkSkill +from .bot_framework_client import BotFrameworkClient from .conversation_id_factory import ConversationIdFactoryBase from .skill_handler import SkillHandler from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions @@ -13,6 +14,7 @@ __all__ = [ "BotFrameworkSkill", + "BotFrameworkClient", "ConversationIdFactoryBase", "SkillConversationIdFactoryOptions", "SkillConversationReference", diff --git a/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_client.py b/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_client.py new file mode 100644 index 000000000..5213aba70 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_client.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from botbuilder.schema import Activity +from botbuilder.core import InvokeResponse + + +class BotFrameworkClient(ABC): + def post_activity( + self, + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, + conversation_id: str, + activity: Activity, + ) -> InvokeResponse: + raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py index 9eae6ec75..43d19c600 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory_options.py @@ -13,26 +13,6 @@ def __init__( activity: Activity, bot_framework_skill: BotFrameworkSkill, ): - if from_bot_oauth_scope is None: - raise TypeError( - "SkillConversationIdFactoryOptions(): from_bot_oauth_scope cannot be None." - ) - - if from_bot_id is None: - raise TypeError( - "SkillConversationIdFactoryOptions(): from_bot_id cannot be None." - ) - - if activity is None: - raise TypeError( - "SkillConversationIdFactoryOptions(): activity cannot be None." - ) - - if bot_framework_skill is None: - raise TypeError( - "SkillConversationIdFactoryOptions(): bot_framework_skill cannot be None." - ) - self.from_bot_oauth_scope = from_bot_oauth_scope self.from_bot_id = from_bot_id self.activity = activity diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py index 877f83141..341fb8104 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_reference.py @@ -9,13 +9,5 @@ class SkillConversationReference: """ def __init__(self, conversation_reference: ConversationReference, oauth_scope: str): - if conversation_reference is None: - raise TypeError( - "SkillConversationReference(): conversation_reference cannot be None." - ) - - if oauth_scope is None: - raise TypeError("SkillConversationReference(): oauth_scope cannot be None.") - self.conversation_reference = conversation_reference self.oauth_scope = oauth_scope diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 00bdf5d43..e679907ba 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -295,7 +295,7 @@ async def next_handler(): return await logic async def send_trace_activity( - self, name: str, value: object, value_type: str, label: str + self, name: str, value: object = None, value_type: str = None, label: str = None ) -> ResourceResponse: trace_activity = Activity( type=ActivityTypes.trace, diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index 2d0447c3e..bf2c8ae32 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -8,6 +8,7 @@ from .about import __version__ from .component_dialog import ComponentDialog from .dialog_context import DialogContext +from .dialog_events import DialogEvents from .dialog_instance import DialogInstance from .dialog_reason import DialogReason from .dialog_set import DialogSet @@ -17,12 +18,15 @@ from .dialog import Dialog from .waterfall_dialog import WaterfallDialog from .waterfall_step_context import WaterfallStepContext +from .dialog_extensions import DialogExtensions from .prompts import * from .choices import * +from .skills import * __all__ = [ "ComponentDialog", "DialogContext", + "DialogEvents", "DialogInstance", "DialogReason", "DialogSet", @@ -43,5 +47,6 @@ "Prompt", "PromptOptions", "TextPrompt", + "DialogExtensions", "__version__", ] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index bda4b711f..1034896b6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -189,7 +189,7 @@ def add_dialog(self, dialog: Dialog) -> object: self.initial_dialog_id = dialog.id return self - def find_dialog(self, dialog_id: str) -> Dialog: + async def find_dialog(self, dialog_id: str) -> Dialog: """ Finds a dialog by ID. @@ -197,7 +197,7 @@ def find_dialog(self, dialog_id: str) -> Dialog: :return: The dialog; or None if there is not a match for the ID. :rtype: :class:`botbuilder.dialogs.Dialog` """ - return self._dialogs.find(dialog_id) + return await self._dialogs.find(dialog_id) async def on_begin_dialog( self, inner_dc: DialogContext, options: object diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py new file mode 100644 index 000000000..0c28a7e02 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class DialogEvents(str, Enum): + + begin_dialog = "beginDialog" + reprompt_dialog = "repromptDialog" + cancel_dialog = "cancelDialog" + activity_received = "activityReceived" + error = "error" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py new file mode 100644 index 000000000..a6682dd13 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import BotAdapter, StatePropertyAccessor, TurnContext +from botbuilder.dialogs import ( + Dialog, + DialogEvents, + DialogSet, + DialogTurnStatus, +) +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ClaimsIdentity, SkillValidation + + +class DialogExtensions: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + + claims = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) + if isinstance(claims, ClaimsIdentity) and SkillValidation.is_skill_claim( + claims.claims + ): + # The bot is running as a skill. + if ( + turn_context.activity.type == ActivityTypes.end_of_conversation + and dialog_context.stack + ): + await dialog_context.cancel_all_dialogs() + else: + # Process a reprompt event sent from the parent. + if ( + turn_context.activity.type == ActivityTypes.event + and turn_context.activity.name == DialogEvents.reprompt_dialog + and dialog_context.stack + ): + await dialog_context.reprompt_dialog() + return + + # Run the Dialog with the new message Activity and capture the results + # so we can send end of conversation if needed. + result = await dialog_context.continue_dialog() + if result.status == DialogTurnStatus.Empty: + start_message_text = f"Starting {dialog.id}" + await turn_context.send_trace_activity( + f"Extension {Dialog.__name__}.run_dialog", + label=start_message_text, + ) + result = await dialog_context.begin_dialog(dialog.id) + + # Send end of conversation if it is completed or cancelled. + if ( + result.status == DialogTurnStatus.Complete + or result.status == DialogTurnStatus.Cancelled + ): + end_message_text = f"Dialog {dialog.id} has **completed**. Sending EndOfConversation." + await turn_context.send_trace_activity( + f"Extension {Dialog.__name__}.run_dialog", + label=end_message_text, + value=result.result, + ) + + activity = Activity( + type=ActivityTypes.end_of_conversation, value=result.result + ) + await turn_context.send_activity(activity) + + else: + # The bot is running as a standard bot. + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/__init__.py new file mode 100644 index 000000000..9a804f378 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/__init__.py @@ -0,0 +1,17 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .begin_skill_dialog_options import BeginSkillDialogOptions +from .skill_dialog_options import SkillDialogOptions +from .skill_dialog import SkillDialog + + +__all__ = [ + "BeginSkillDialogOptions", + "SkillDialogOptions", + "SkillDialog", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py new file mode 100644 index 000000000..62a02ab2e --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import Activity + + +class BeginSkillDialogOptions: + def __init__(self, activity: Activity): # pylint: disable=unused-argument + self.activity = activity + + @staticmethod + def from_object(obj: object) -> "BeginSkillDialogOptions": + if isinstance(obj, dict) and "activity" in obj: + return BeginSkillDialogOptions(obj["activity"]) + if hasattr(obj, "activity"): + return BeginSkillDialogOptions(obj.activity) + + return None diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py new file mode 100644 index 000000000..58c3857e0 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -0,0 +1,233 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import deepcopy +from typing import List + +from botbuilder.schema import Activity, ActivityTypes, ExpectedReplies, DeliveryModes +from botbuilder.core import ( + BotAdapter, + TurnContext, +) +from botbuilder.core.skills import SkillConversationIdFactoryOptions + +from botbuilder.dialogs import ( + Dialog, + DialogContext, + DialogEvents, + DialogReason, + DialogInstance, +) + +from .begin_skill_dialog_options import BeginSkillDialogOptions +from .skill_dialog_options import SkillDialogOptions + + +class SkillDialog(Dialog): + def __init__(self, dialog_options: SkillDialogOptions, dialog_id: str): + super().__init__(dialog_id) + if not dialog_options: + raise TypeError("SkillDialog.__init__(): dialog_options cannot be None.") + + self.dialog_options = dialog_options + self._deliver_mode_state_key = "deliverymode" + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + """ + Method called when a new dialog has been pushed onto the stack and is being activated. + :param dialog_context: The dialog context for the current turn of conversation. + :param options: (Optional) additional argument(s) to pass to the dialog being started. + """ + dialog_args = SkillDialog._validate_begin_dialog_args(options) + + await dialog_context.context.send_trace_activity( + f"{SkillDialog.__name__}.BeginDialogAsync()", + label=f"Using activity of type: {dialog_args.activity.type}", + ) + + # Create deep clone of the original activity to avoid altering it before forwarding it. + skill_activity: Activity = deepcopy(dialog_args.activity) + + # Apply conversation reference and common properties from incoming activity before sending. + TurnContext.apply_conversation_reference( + skill_activity, + TurnContext.get_conversation_reference(dialog_context.context.activity), + is_incoming=True, + ) + + dialog_context.active_dialog.state[ + self._deliver_mode_state_key + ] = dialog_args.activity.delivery_mode + + # Send the activity to the skill. + eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity) + if eoc_activity: + return await dialog_context.end_dialog(eoc_activity.value) + + return self.end_of_turn + + async def continue_dialog(self, dialog_context: DialogContext): + await dialog_context.context.send_trace_activity( + f"{SkillDialog.__name__}.continue_dialog()", + label=f"ActivityType: {dialog_context.context.activity.type}", + ) + + # Handle EndOfConversation from the skill (this will be sent to the this dialog by the SkillHandler if + # received from the Skill) + if dialog_context.context.activity.type == ActivityTypes.end_of_conversation: + await dialog_context.context.send_trace_activity( + f"{SkillDialog.__name__}.continue_dialog()", + label=f"Got {ActivityTypes.end_of_conversation}", + ) + + return await dialog_context.end_dialog( + dialog_context.context.activity.value + ) + + # Forward only Message and Event activities to the skill + if ( + dialog_context.context.activity.type == ActivityTypes.message + or dialog_context.context.activity.type == ActivityTypes.event + ): + # Create deep clone of the original activity to avoid altering it before forwarding it. + skill_activity = deepcopy(dialog_context.context.activity) + skill_activity.delivery_mode = dialog_context.active_dialog.state[ + self._deliver_mode_state_key + ] + + # Just forward to the remote skill + eoc_activity = await self._send_to_skill( + dialog_context.context, skill_activity + ) + if eoc_activity: + return await dialog_context.end_dialog(eoc_activity.value) + + return self.end_of_turn + + async def reprompt_dialog( # pylint: disable=unused-argument + self, context: TurnContext, instance: DialogInstance + ): + # Create and send an event to the skill so it can resume the dialog. + reprompt_event = Activity( + type=ActivityTypes.event, name=DialogEvents.reprompt_dialog + ) + + # Apply conversation reference and common properties from incoming activity before sending. + TurnContext.apply_conversation_reference( + reprompt_event, + TurnContext.get_conversation_reference(context.activity), + is_incoming=True, + ) + + await self._send_to_skill(context, reprompt_event) + + async def resume_dialog( # pylint: disable=unused-argument + self, dialog_context: "DialogContext", reason: DialogReason, result: object + ): + await self.reprompt_dialog(dialog_context.context, dialog_context.active_dialog) + return self.end_of_turn + + async def end_dialog( + self, context: TurnContext, instance: DialogInstance, reason: DialogReason + ): + # Send of of conversation to the skill if the dialog has been cancelled. + if reason in (DialogReason.CancelCalled, DialogReason.ReplaceCalled): + await context.send_trace_activity( + f"{SkillDialog.__name__}.end_dialog()", + label=f"ActivityType: {context.activity.type}", + ) + activity = Activity(type=ActivityTypes.end_of_conversation) + + # Apply conversation reference and common properties from incoming activity before sending. + TurnContext.apply_conversation_reference( + activity, + TurnContext.get_conversation_reference(context.activity), + is_incoming=True, + ) + activity.channel_data = context.activity.channel_data + activity.additional_properties = context.activity.additional_properties + + await self._send_to_skill(context, activity) + + await super().end_dialog(context, instance, reason) + + @staticmethod + def _validate_begin_dialog_args(options: object) -> BeginSkillDialogOptions: + if not options: + raise TypeError("options cannot be None.") + + dialog_args = BeginSkillDialogOptions.from_object(options) + + if not dialog_args: + raise TypeError( + "SkillDialog: options object not valid as BeginSkillDialogOptions." + ) + + if not dialog_args.activity: + raise TypeError( + "SkillDialog: activity object in options as BeginSkillDialogOptions cannot be None." + ) + + # Only accept Message or Event activities + if ( + dialog_args.activity.type != ActivityTypes.message + and dialog_args.activity.type != ActivityTypes.event + ): + raise TypeError( + f"Only {ActivityTypes.message} and {ActivityTypes.event} activities are supported." + f" Received activity of type {dialog_args.activity.type}." + ) + + return dialog_args + + async def _send_to_skill( + self, context: TurnContext, activity: Activity, + ) -> Activity: + # Create a conversationId to interact with the skill and send the activity + conversation_id_factory_options = SkillConversationIdFactoryOptions( + from_bot_oauth_scope=context.turn_state.get(BotAdapter.BOT_OAUTH_SCOPE_KEY), + from_bot_id=self.dialog_options.bot_id, + activity=activity, + bot_framework_skill=self.dialog_options.skill, + ) + + skill_conversation_id = await self.dialog_options.conversation_id_factory.create_skill_conversation_id( + conversation_id_factory_options + ) + + # Always save state before forwarding + # (the dialog stack won't get updated with the skillDialog and things won't work if you don't) + skill_info = self.dialog_options.skill + await self.dialog_options.conversation_state.save_changes(context, True) + + response = await self.dialog_options.skill_client.post_activity( + self.dialog_options.bot_id, + skill_info.app_id, + skill_info.skill_endpoint, + self.dialog_options.skill_host_endpoint, + skill_conversation_id, + activity, + ) + + # Inspect the skill response status + if not 200 <= response.status <= 299: + raise Exception( + f'Error invoking the skill id: "{skill_info.id}" at "{skill_info.skill_endpoint}"' + f" (status is {response.status}). \r\n {response.body}" + ) + + eoc_activity: Activity = None + if activity.delivery_mode == DeliveryModes.expect_replies and response.body: + # Process replies in the response.Body. + response.body: List[Activity] + response.body = ExpectedReplies().deserialize(response.body).activities + + for from_skill_activity in response.body: + if from_skill_activity.type == ActivityTypes.end_of_conversation: + # Capture the EndOfConversation activity if it was sent from skill + eoc_activity = from_skill_activity + else: + # Send the response back to the channel. + await context.send_activity(from_skill_activity) + + return eoc_activity diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py new file mode 100644 index 000000000..53d56f72e --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ConversationState +from botbuilder.core.skills import ( + BotFrameworkClient, + BotFrameworkSkill, + ConversationIdFactoryBase, +) + + +class SkillDialogOptions: + def __init__( + self, + bot_id: str = None, + skill_client: BotFrameworkClient = None, + skill_host_endpoint: str = None, + skill: BotFrameworkSkill = None, + conversation_id_factory: ConversationIdFactoryBase = None, + conversation_state: ConversationState = None, + ): + self.bot_id = bot_id + self.skill_client = skill_client + self.skill_host_endpoint = skill_host_endpoint + self.skill = skill + self.conversation_id_factory = conversation_id_factory + self.conversation_state = conversation_state diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index ae24e3833..f242baec4 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -43,6 +43,7 @@ "botbuilder.dialogs", "botbuilder.dialogs.prompts", "botbuilder.dialogs.choices", + "botbuilder.dialogs.skills", ], install_requires=REQUIRES + TEST_REQUIRES, tests_require=TEST_REQUIRES, diff --git a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py new file mode 100644 index 000000000..cafa17c88 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import uuid +from typing import Callable, Union +from unittest.mock import Mock + +import aiounittest +from botbuilder.core import ( + ConversationState, + MemoryStorage, + InvokeResponse, + TurnContext, + MessageFactory, +) +from botbuilder.core.skills import ( + BotFrameworkSkill, + ConversationIdFactoryBase, + SkillConversationIdFactoryOptions, + SkillConversationReference, + BotFrameworkClient, +) +from botbuilder.schema import Activity, ActivityTypes, ConversationReference +from botbuilder.testing import DialogTestClient + +from botbuilder.dialogs import ( + SkillDialog, + SkillDialogOptions, + BeginSkillDialogOptions, + DialogTurnStatus, +) + + +class SimpleConversationIdFactory(ConversationIdFactoryBase): + def __init__(self): + self.conversation_refs = {} + + async def create_skill_conversation_id( + self, + options_or_conversation_reference: Union[ + SkillConversationIdFactoryOptions, ConversationReference + ], + ) -> str: + key = ( + options_or_conversation_reference.activity.conversation.id + + options_or_conversation_reference.activity.service_url + ) + if key not in self.conversation_refs: + self.conversation_refs[key] = SkillConversationReference( + conversation_reference=TurnContext.get_conversation_reference( + options_or_conversation_reference.activity + ), + oauth_scope=options_or_conversation_reference.from_bot_oauth_scope, + ) + return key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> Union[SkillConversationReference, ConversationReference]: + return self.conversation_refs[skill_conversation_id] + + async def delete_conversation_reference(self, skill_conversation_id: str): + raise NotImplementedError() + + +class SkillDialogTests(aiounittest.AsyncTestCase): + async def test_constructor_validation_test(self): + # missing dialog_id + with self.assertRaises(TypeError): + SkillDialog(SkillDialogOptions(), None) + + # missing dialog options + with self.assertRaises(TypeError): + SkillDialog(None, "dialog_id") + + async def test_begin_dialog_options_validation(self): + dialog_options = SkillDialogOptions() + sut = SkillDialog(dialog_options, dialog_id="dialog_id") + + # empty options should raise + client = DialogTestClient("test", sut) + with self.assertRaises(TypeError): + await client.send_activity("irrelevant") + + # non DialogArgs should raise + client = DialogTestClient("test", sut, {}) + with self.assertRaises(TypeError): + await client.send_activity("irrelevant") + + # Activity in DialogArgs should be set + client = DialogTestClient("test", sut, BeginSkillDialogOptions(None)) + with self.assertRaises(TypeError): + await client.send_activity("irrelevant") + + # Only Message and Event activities are supported + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(Activity(type=ActivityTypes.conversation_update)), + ) + with self.assertRaises(TypeError): + await client.send_activity("irrelevant") + + async def test_begin_dialog_calls_skill(self): + activity_sent = None + from_bot_id_sent = None + to_bot_id_sent = None + to_url_sent = None + + async def capture( + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, # pylint: disable=unused-argument + conversation_id: str, # pylint: disable=unused-argument + activity: Activity, + ): + nonlocal from_bot_id_sent, to_bot_id_sent, to_url_sent, activity_sent + from_bot_id_sent = from_bot_id + to_bot_id_sent = to_bot_id + to_url_sent = to_url + activity_sent = activity + + mock_skill_client = self._create_mock_skill_client(capture) + + conversation_state = ConversationState(MemoryStorage()) + dialog_options = self._create_skill_dialog_options( + conversation_state, mock_skill_client + ) + + sut = SkillDialog(dialog_options, "dialog_id") + activity_to_send = MessageFactory.text(str(uuid.uuid4())) + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity=activity_to_send), + conversation_state=conversation_state, + ) + + await client.send_activity(MessageFactory.text("irrelevant")) + + assert dialog_options.bot_id == from_bot_id_sent + assert dialog_options.skill.app_id == to_bot_id_sent + assert dialog_options.skill.skill_endpoint == to_url_sent + assert activity_to_send.text == activity_sent.text + assert DialogTurnStatus.Waiting == client.dialog_turn_result.status + + await client.send_activity(MessageFactory.text("Second message")) + + assert activity_sent.text == "Second message" + assert DialogTurnStatus.Waiting == client.dialog_turn_result.status + + await client.send_activity(Activity(type=ActivityTypes.end_of_conversation)) + + assert DialogTurnStatus.Complete == client.dialog_turn_result.status + + async def test_cancel_dialog_sends_eoc(self): + activity_sent = None + + async def capture( + from_bot_id: str, # pylint: disable=unused-argument + to_bot_id: str, # pylint: disable=unused-argument + to_url: str, # pylint: disable=unused-argument + service_url: str, # pylint: disable=unused-argument + conversation_id: str, # pylint: disable=unused-argument + activity: Activity, + ): + nonlocal activity_sent + activity_sent = activity + + mock_skill_client = self._create_mock_skill_client(capture) + + conversation_state = ConversationState(MemoryStorage()) + dialog_options = self._create_skill_dialog_options( + conversation_state, mock_skill_client + ) + + sut = SkillDialog(dialog_options, "dialog_id") + activity_to_send = MessageFactory.text(str(uuid.uuid4())) + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity=activity_to_send), + conversation_state=conversation_state, + ) + + # Send something to the dialog to start it + await client.send_activity(MessageFactory.text("irrelevant")) + + # Cancel the dialog so it sends an EoC to the skill + await client.dialog_context.cancel_all_dialogs() + + assert activity_sent + assert activity_sent.type == ActivityTypes.end_of_conversation + + async def test_should_throw_on_post_failure(self): + # This mock client will fail + mock_skill_client = self._create_mock_skill_client(None, 500) + + conversation_state = ConversationState(MemoryStorage()) + dialog_options = self._create_skill_dialog_options( + conversation_state, mock_skill_client + ) + + sut = SkillDialog(dialog_options, "dialog_id") + activity_to_send = MessageFactory.text(str(uuid.uuid4())) + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity=activity_to_send), + conversation_state=conversation_state, + ) + + # A send should raise an exception + with self.assertRaises(Exception): + await client.send_activity("irrelevant") + + def _create_skill_dialog_options( + self, conversation_state: ConversationState, skill_client: BotFrameworkClient + ): + return SkillDialogOptions( + bot_id=str(uuid.uuid4()), + skill_host_endpoint="http://test.contoso.com/skill/messages", + conversation_id_factory=SimpleConversationIdFactory(), + conversation_state=conversation_state, + skill_client=skill_client, + skill=BotFrameworkSkill( + app_id=str(uuid.uuid4()), + skill_endpoint="http://testskill.contoso.com/api/messages", + ), + ) + + def _create_mock_skill_client( + self, callback: Callable, return_status: int = 200 + ) -> BotFrameworkClient: + mock_client = Mock() + + async def mock_post_activity( + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, + conversation_id: str, + activity: Activity, + ): + nonlocal callback, return_status + if callback: + await callback( + from_bot_id, + to_bot_id, + to_url, + service_url, + conversation_id, + activity, + ) + return InvokeResponse(status=return_status) + + mock_client.post_activity.side_effect = mock_post_activity + + return mock_client diff --git a/libraries/botbuilder-integration-aiohttp/README.rst b/libraries/botbuilder-integration-aiohttp/README.rst new file mode 100644 index 000000000..f92429436 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/README.rst @@ -0,0 +1,83 @@ + +========================================= +BotBuilder-Integration-Aiohttp for Python +========================================= + +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-core.svg + :target: https://badge.fury.io/py/botbuilder-core + :alt: Latest PyPI package version + +Within the Bot Framework, This library enables you to integrate your bot within an aiohttp web application. + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-integration-aiohttp + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.7.0`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.7.0: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/__init__.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/__init__.py new file mode 100644 index 000000000..1bb31e665 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/__init__.py @@ -0,0 +1,16 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .aiohttp_channel_service import aiohttp_channel_service_routes +from .aiohttp_channel_service_exception_middleware import aiohttp_error_middleware +from .bot_framework_http_client import BotFrameworkHttpClient + +__all__ = [ + "aiohttp_channel_service_routes", + "aiohttp_error_middleware", + "BotFrameworkHttpClient", +] diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py new file mode 100644 index 000000000..c0bfc2c92 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-integration-aiohttp" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service.py new file mode 100644 index 000000000..af2545d89 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service.py @@ -0,0 +1,176 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import json +from typing import List, Union, Type + +from aiohttp.web import RouteTableDef, Request, Response +from msrest.serialization import Model + +from botbuilder.schema import ( + Activity, + AttachmentData, + ConversationParameters, + Transcript, +) + +from botbuilder.core import ChannelServiceHandler + + +async def deserialize_from_body( + request: Request, target_model: Type[Model] +) -> Activity: + if "application/json" in request.headers["Content-Type"]: + body = await request.json() + else: + return Response(status=415) + + return target_model().deserialize(body) + + +def get_serialized_response(model_or_list: Union[Model, List[Model]]) -> Response: + if isinstance(model_or_list, Model): + json_obj = model_or_list.serialize() + else: + json_obj = [model.serialize() for model in model_or_list] + + return Response(body=json.dumps(json_obj), content_type="application/json") + + +def aiohttp_channel_service_routes( + handler: ChannelServiceHandler, base_url: str = "" +) -> RouteTableDef: + # pylint: disable=unused-variable + routes = RouteTableDef() + + @routes.post(base_url + "/v3/conversations/{conversation_id}/activities") + async def send_to_conversation(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_send_to_conversation( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.post( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def reply_to_activity(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_reply_to_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.put( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def update_activity(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_update_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.delete( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def delete_activity(request: Request): + await handler.handle_delete_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + ) + + return Response() + + @routes.get( + base_url + + "/v3/conversations/{conversation_id}/activities/{activity_id}/members" + ) + async def get_activity_members(request: Request): + result = await handler.handle_get_activity_members( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/") + async def create_conversation(request: Request): + conversation_parameters = deserialize_from_body(request, ConversationParameters) + result = await handler.handle_create_conversation( + request.headers.get("Authorization"), conversation_parameters + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/") + async def get_conversation(request: Request): + # TODO: continuation token? + result = await handler.handle_get_conversations( + request.headers.get("Authorization") + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/v3/conversations/{conversation_id}/members") + async def get_conversation_members(request: Request): + result = await handler.handle_get_conversation_members( + request.headers.get("Authorization"), request.match_info["conversation_id"], + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/v3/conversations/{conversation_id}/pagedmembers") + async def get_conversation_paged_members(request: Request): + # TODO: continuation token? page size? + result = await handler.handle_get_conversation_paged_members( + request.headers.get("Authorization"), request.match_info["conversation_id"], + ) + + return get_serialized_response(result) + + @routes.delete(base_url + "/v3/conversations/{conversation_id}/members/{member_id}") + async def delete_conversation_member(request: Request): + result = await handler.handle_delete_conversation_member( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["member_id"], + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/v3/conversations/{conversation_id}/activities/history") + async def send_conversation_history(request: Request): + transcript = deserialize_from_body(request, Transcript) + result = await handler.handle_send_conversation_history( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + transcript, + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/v3/conversations/{conversation_id}/attachments") + async def upload_attachment(request: Request): + attachment_data = deserialize_from_body(request, AttachmentData) + result = await handler.handle_upload_attachment( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + attachment_data, + ) + + return get_serialized_response(result) + + return routes diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service_exception_middleware.py new file mode 100644 index 000000000..7c5091121 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/aiohttp_channel_service_exception_middleware.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp.web import ( + middleware, + HTTPNotImplemented, + HTTPUnauthorized, + HTTPNotFound, + HTTPInternalServerError, +) + +from botbuilder.core import BotActionNotImplementedError + + +@middleware +async def aiohttp_error_middleware(request, handler): + try: + response = await handler(request) + return response + except BotActionNotImplementedError: + raise HTTPNotImplemented() + except NotImplementedError: + raise HTTPNotImplemented() + except PermissionError: + raise HTTPUnauthorized() + except KeyError: + raise HTTPNotFound() + except Exception: + raise HTTPInternalServerError() diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py similarity index 95% rename from libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py rename to libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index ac015e80a..9af19718b 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -1,16 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +# pylint: disable=no-member import json from typing import Dict from logging import Logger -import aiohttp +import aiohttp +from botbuilder.core import InvokeResponse +from botbuilder.core.skills import BotFrameworkClient from botbuilder.schema import ( Activity, ExpectedReplies, - ConversationAccount, ConversationReference, + ConversationAccount, ) from botframework.connector.auth import ( ChannelProvider, @@ -19,10 +22,8 @@ MicrosoftAppCredentials, ) -from . import InvokeResponse - -class BotFrameworkHttpClient: +class BotFrameworkHttpClient(BotFrameworkClient): """ A skill host adapter implements API to forward activity to a skill and @@ -73,6 +74,7 @@ async def post_activity( original_conversation_id = activity.conversation.id original_service_url = activity.service_url original_caller_id = activity.caller_id + original_relates_to = activity.relates_to try: # TODO: The relato has to be ported to the adapter in the new integration library when @@ -121,6 +123,7 @@ async def post_activity( activity.conversation.id = original_conversation_id activity.service_url = original_service_url activity.caller_id = original_caller_id + activity.relates_to = original_relates_to async def post_buffered_activity( self, diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py new file mode 100644 index 000000000..71aaa71cf --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py @@ -0,0 +1,4 @@ +from .skill_http_client import SkillHttpClient + + +__all__ = ["SkillHttpClient"] diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py similarity index 92% rename from libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py rename to libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py index 8699c0ad8..df875f734 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py @@ -1,10 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.core import ( - BotFrameworkHttpClient, - InvokeResponse, -) +from logging import Logger + +from botbuilder.core import InvokeResponse +from botbuilder.integration.aiohttp import BotFrameworkHttpClient from botbuilder.core.skills import ( ConversationIdFactoryBase, SkillConversationIdFactoryOptions, @@ -25,6 +25,7 @@ def __init__( credential_provider: SimpleCredentialProvider, skill_conversation_id_factory: ConversationIdFactoryBase, channel_provider: ChannelProvider = None, + logger: Logger = None, ): if not skill_conversation_id_factory: raise TypeError( diff --git a/libraries/botbuilder-integration-aiohttp/requirements.txt b/libraries/botbuilder-integration-aiohttp/requirements.txt new file mode 100644 index 000000000..1d6f7ab31 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/requirements.txt @@ -0,0 +1,4 @@ +msrest==0.6.10 +botframework-connector>=4.7.1 +botbuilder-schema>=4.7.1 +aiohttp>=3.6.2 \ No newline at end of file diff --git a/libraries/botbuilder-integration-aiohttp/setup.cfg b/libraries/botbuilder-integration-aiohttp/setup.cfg new file mode 100644 index 000000000..68c61a226 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 \ No newline at end of file diff --git a/libraries/botbuilder-integration-aiohttp/setup.py b/libraries/botbuilder-integration-aiohttp/setup.py new file mode 100644 index 000000000..df1778810 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/setup.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +REQUIRES = [ + "botbuilder-schema>=4.7.1", + "botframework-connector>=4.7.1", + "botbuilder-core>=4.7.1", + "aiohttp==3.6.2", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "botbuilder", "integration", "aiohttp", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords=[ + "BotBuilderIntegrationAiohttp", + "bots", + "ai", + "botframework", + "botbuilder", + ], + long_description=long_description, + long_description_content_type="text/x-rst", + license=package_info["__license__"], + packages=[ + "botbuilder.integration.aiohttp", + "botbuilder.integration.aiohttp.skills", + ], + install_requires=REQUIRES, + classifiers=[ + "Programming Language :: Python :: 3.7", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py similarity index 77% rename from libraries/botbuilder-core/tests/test_bot_framework_http_client.py rename to libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py index b2b5894d2..7e01f390b 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py @@ -1,5 +1,5 @@ import aiounittest -from botbuilder.core import BotFrameworkHttpClient +from botbuilder.integration.aiohttp import BotFrameworkHttpClient class TestBotFrameworkHttpClient(aiounittest.AsyncTestCase): diff --git a/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py index 2ca60fa07..3e284b5c9 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py +++ b/libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py @@ -54,6 +54,7 @@ def __init__( :type conversation_state: ConversationState """ self.dialog_turn_result: DialogTurnResult = None + self.dialog_context = None self.conversation_state: ConversationState = ( ConversationState(MemoryStorage()) if conversation_state is None @@ -108,10 +109,10 @@ async def default_callback(turn_context: TurnContext) -> None: dialog_set = DialogSet(dialog_state) dialog_set.add(target_dialog) - dialog_context = await dialog_set.create_context(turn_context) - self.dialog_turn_result = await dialog_context.continue_dialog() + self.dialog_context = await dialog_set.create_context(turn_context) + self.dialog_turn_result = await self.dialog_context.continue_dialog() if self.dialog_turn_result.status == DialogTurnStatus.Empty: - self.dialog_turn_result = await dialog_context.begin_dialog( + self.dialog_turn_result = await self.dialog_context.begin_dialog( target_dialog.id, initial_dialog_options ) diff --git a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py index afd17b905..96044f9de 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py +++ b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py @@ -38,6 +38,7 @@ async def test_handle_null_keys_when_reading(self): class StorageBaseTests: + # pylint: disable=pointless-string-statement @staticmethod async def return_empty_object_when_reading_unknown_key(storage) -> bool: result = await storage.read(["unknown"]) @@ -94,8 +95,11 @@ async def create_object(storage) -> bool: store_items["createPocoStoreItem"]["id"] == read_store_items["createPocoStoreItem"]["id"] ) + """ + If decided to validate e_tag integrity aagain, uncomment this code assert read_store_items["createPoco"]["e_tag"] is not None assert read_store_items["createPocoStoreItem"]["e_tag"] is not None + """ return True @@ -127,9 +131,9 @@ async def update_object(storage) -> bool: loaded_store_items = await storage.read(["pocoItem", "pocoStoreItem"]) update_poco_item = loaded_store_items["pocoItem"] - update_poco_item["e_tag"] = None + # update_poco_item["e_tag"] = None update_poco_store_item = loaded_store_items["pocoStoreItem"] - assert update_poco_store_item["e_tag"] is not None + # assert update_poco_store_item["e_tag"] is not None # 2nd write should work update_poco_item["count"] += 1 @@ -142,10 +146,6 @@ async def update_object(storage) -> bool: reloaded_update_poco_item = reloaded_store_items["pocoItem"] reloaded_update_poco_store_item = reloaded_store_items["pocoStoreItem"] - assert reloaded_update_poco_item["e_tag"] is not None - assert ( - update_poco_store_item["e_tag"] != reloaded_update_poco_store_item["e_tag"] - ) assert reloaded_update_poco_item["count"] == 2 assert reloaded_update_poco_store_item["count"] == 2 @@ -153,17 +153,20 @@ async def update_object(storage) -> bool: update_poco_item["count"] = 123 await storage.write({"pocoItem": update_poco_item}) + """ + If decided to validate e_tag integrity aagain, uncomment this code # Write with old eTag should FAIL for storeItem update_poco_store_item["count"] = 123 with pytest.raises(Exception) as err: await storage.write({"pocoStoreItem": update_poco_store_item}) assert err.value is not None + """ reloaded_store_items2 = await storage.read(["pocoItem", "pocoStoreItem"]) reloaded_poco_item2 = reloaded_store_items2["pocoItem"] - reloaded_poco_item2["e_tag"] = None + # reloaded_poco_item2["e_tag"] = None reloaded_poco_store_item2 = reloaded_store_items2["pocoStoreItem"] assert reloaded_poco_item2["count"] == 123 @@ -172,7 +175,7 @@ async def update_object(storage) -> bool: # write with wildcard etag should work reloaded_poco_item2["count"] = 100 reloaded_poco_store_item2["count"] = 100 - reloaded_poco_store_item2["e_tag"] = "*" + # reloaded_poco_store_item2["e_tag"] = "*" wildcard_etag_dict = { "pocoItem": reloaded_poco_item2, @@ -192,12 +195,15 @@ async def update_object(storage) -> bool: assert reloaded_store_item4 is not None + """ + If decided to validate e_tag integrity aagain, uncomment this code reloaded_store_item4["e_tag"] = "" dict2 = {"pocoStoreItem": reloaded_store_item4} with pytest.raises(Exception) as err: await storage.write(dict2) assert err.value is not None + """ final_store_items = await storage.read(["pocoItem", "pocoStoreItem"]) assert final_store_items["pocoItem"]["count"] == 100 @@ -213,7 +219,7 @@ async def delete_object(storage) -> bool: read_store_items = await storage.read(["delete1"]) - assert read_store_items["delete1"]["e_tag"] + # assert read_store_items["delete1"]["e_tag"] assert read_store_items["delete1"]["count"] == 1 await storage.delete(["delete1"]) @@ -248,9 +254,12 @@ async def perform_batch_operations(storage) -> bool: assert result["batch1"]["count"] == 10 assert result["batch2"]["count"] == 20 assert result["batch3"]["count"] == 30 + """ + If decided to validate e_tag integrity aagain, uncomment this code assert result["batch1"].get("e_tag", None) is not None assert result["batch2"].get("e_tag", None) is not None assert result["batch3"].get("e_tag", None) is not None + """ await storage.delete(["batch1", "batch2", "batch3"]) diff --git a/samples/experimental/skills-buffered/parent/app.py b/samples/experimental/skills-buffered/parent/app.py index 585a6873f..d1e9fbc0a 100644 --- a/samples/experimental/skills-buffered/parent/app.py +++ b/samples/experimental/skills-buffered/parent/app.py @@ -12,11 +12,11 @@ MemoryStorage, TurnContext, BotFrameworkAdapter, - BotFrameworkHttpClient, ) from botbuilder.core.integration import ( aiohttp_channel_service_routes, aiohttp_error_middleware, + BotFrameworkHttpClient ) from botbuilder.core.skills import SkillHandler from botbuilder.schema import Activity diff --git a/samples/experimental/skills-buffered/parent/bots/parent_bot.py b/samples/experimental/skills-buffered/parent/bots/parent_bot.py index 1aa077624..a94ce696d 100644 --- a/samples/experimental/skills-buffered/parent/bots/parent_bot.py +++ b/samples/experimental/skills-buffered/parent/bots/parent_bot.py @@ -6,9 +6,9 @@ from botbuilder.core import ( ActivityHandler, TurnContext, - BotFrameworkHttpClient, MessageFactory, ) +from botbuilder.integration import BotFrameworkHttpClient from botbuilder.schema import DeliveryModes diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py index d3c0aafd1..2915c0d47 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py @@ -9,7 +9,6 @@ from aiohttp.web import Request, Response from botbuilder.core import ( BotFrameworkAdapterSettings, - BotFrameworkHttpClient, ConversationState, MemoryStorage, TurnContext, @@ -18,6 +17,7 @@ from botbuilder.core.integration import ( aiohttp_channel_service_routes, aiohttp_error_middleware, + BotFrameworkHttpClient, ) from botbuilder.core.skills import SkillConversationIdFactory, SkillHandler from botbuilder.schema import Activity, ActivityTypes diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py index 78ca44ed4..c271904fd 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py @@ -2,12 +2,12 @@ from botbuilder.core import ( ActivityHandler, - BotFrameworkHttpClient, ConversationState, MessageFactory, TurnContext, ) from botbuilder.core.skills import SkillConversationIdFactory +from botbuilder.integration.aiohttp import BotFrameworkHttpClient from botbuilder.schema import ActivityTypes, ChannelAccount diff --git a/samples/experimental/test-protocol/app.py b/samples/experimental/test-protocol/app.py index ed7625cbc..e890718e7 100644 --- a/samples/experimental/test-protocol/app.py +++ b/samples/experimental/test-protocol/app.py @@ -5,8 +5,7 @@ from aiohttp.web import Request, Response from botframework.connector.auth import AuthenticationConfiguration, SimpleCredentialProvider -from botbuilder.core import BotFrameworkHttpClient -from botbuilder.core.integration import aiohttp_channel_service_routes +from botbuilder.core.integration import aiohttp_channel_service_routes, BotFrameworkHttpClient from botbuilder.schema import Activity from config import DefaultConfig From 7e2fbadc920a2edb0b91fef2be5ac54da7702fb7 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 12 Mar 2020 11:43:11 -0500 Subject: [PATCH 353/616] Skills: Cancel dialogs only if EOC is coming from parent. (#860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skills: Cancel dialogs only if EOC is coming from parent. * Updating __is_eoc_coming_from_parent condition * modify activity.caller_id in bfhttpclient Co-authored-by: Axel Suárez --- .../botbuilder/dialogs/dialog_extensions.py | 12 ++++++++++++ .../integration/aiohttp/bot_framework_http_client.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py index a6682dd13..808bf2b1a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py @@ -30,7 +30,13 @@ async def run_dialog( if ( turn_context.activity.type == ActivityTypes.end_of_conversation and dialog_context.stack + and DialogExtensions.__is_eoc_coming_from_parent(turn_context) ): + remote_cancel_text = "Skill was canceled through an EndOfConversation activity from the parent." + await turn_context.send_trace_activity( + f"Extension {Dialog.__name__}.run_dialog", label=remote_cancel_text, + ) + await dialog_context.cancel_all_dialogs() else: # Process a reprompt event sent from the parent. @@ -75,3 +81,9 @@ async def run_dialog( results = await dialog_context.continue_dialog() if results.status == DialogTurnStatus.Empty: await dialog_context.begin_dialog(dialog.id) + + @staticmethod + def __is_eoc_coming_from_parent(turn_context: TurnContext) -> bool: + # To determine the direction we check callerId property which is set to the parent bot + # by the BotFrameworkHttpClient on outgoing requests. + return bool(turn_context.activity.caller_id) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 9af19718b..3fa2f448d 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -97,7 +97,7 @@ async def post_activity( ) activity.conversation.id = conversation_id activity.service_url = service_url - activity.caller_id = from_bot_id + activity.caller_id = f"urn:botframework:aadappid:{from_bot_id}" headers_dict = { "Content-type": "application/json; charset=utf-8", From 5ea1a8e7185b9eb4c3728bcfe15a10f4b742a331 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Thu, 12 Mar 2020 17:18:07 -0700 Subject: [PATCH 354/616] Rename coveralls token var to PythonCoverallsToken (#854) --- ci-pr-pipeline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci-pr-pipeline.yml b/ci-pr-pipeline.yml index 9554439ba..22f4edbb8 100644 --- a/ci-pr-pipeline.yml +++ b/ci-pr-pipeline.yml @@ -9,7 +9,7 @@ variables: python.36: 3.6.10 python.37: 3.7.6 python.38: 3.8.2 - + # PythonCoverallsToken: get this from Azure jobs: # Build and publish container @@ -80,7 +80,7 @@ jobs: - script: 'pylint --rcfile=.pylintrc libraries' displayName: Pylint - - script: 'COVERALLS_REPO_TOKEN=$(COVERALLS_TOKEN) coveralls' + - script: 'COVERALLS_REPO_TOKEN=$(PythonCoverallsToken) coveralls' displayName: 'Push test results to coveralls https://coveralls.io/github/microsoft/botbuilder-python' continueOnError: true From 083f76e123a790f451cc2167c6603e4e4288eddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Sat, 14 Mar 2020 14:05:15 -0700 Subject: [PATCH 355/616] set signInLink to empty when OAuthAppCredentials is set (#862) (#863) --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index f04799c9d..b18d76497 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -311,9 +311,18 @@ async def _send_oauth_card( link = sign_in_resource.sign_in_link bot_identity: ClaimsIdentity = context.turn_state.get("BotIdentity") + # use the SignInLink when + # in speech channel or + # bot is a skill or + # an extra OAuthAppCredentials is being passed in if ( - bot_identity and SkillValidation.is_skill_claim(bot_identity.claims) - ) or not context.activity.service_url.startswith("http"): + ( + bot_identity + and SkillValidation.is_skill_claim(bot_identity.claims) + ) + or not context.activity.service_url.startswith("http") + or self._settings.oath_app_credentials + ): if context.activity.channel_id == Channels.emulator: card_action_type = ActionTypes.open_url else: From 01fcbe9e4d60bfe1bd314e4481834e453284ca41 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Mon, 16 Mar 2020 13:27:15 -0700 Subject: [PATCH 356/616] Convert functional test pipeline to using key vault secrets (#865) * Convert to key vault vars, relocate .yml files * New line added at EOF --- .../botbuilder-python-ci.yml | 0 ...otbuilder-python-functional-test-linux.yml | 30 ++++++++----------- 2 files changed, 13 insertions(+), 17 deletions(-) rename ci-pr-pipeline.yml => pipelines/botbuilder-python-ci.yml (100%) rename azure-pipelines.yml => pipelines/botbuilder-python-functional-test-linux.yml (75%) diff --git a/ci-pr-pipeline.yml b/pipelines/botbuilder-python-ci.yml similarity index 100% rename from ci-pr-pipeline.yml rename to pipelines/botbuilder-python-ci.yml diff --git a/azure-pipelines.yml b/pipelines/botbuilder-python-functional-test-linux.yml similarity index 75% rename from azure-pipelines.yml rename to pipelines/botbuilder-python-functional-test-linux.yml index c424c7f01..e9b13f27a 100644 --- a/azure-pipelines.yml +++ b/pipelines/botbuilder-python-functional-test-linux.yml @@ -1,9 +1,15 @@ -trigger: +# +# Run functional test on bot deployed to a Docker Linux environment in Azure. +# +pool: + vmImage: 'Ubuntu-16.04' + +trigger: # ci trigger branches: include: - - daveta-python-functional - exclude: - - master + - master + +pr: none # no pr trigger variables: # Container registry service connection established during pipeline creation @@ -14,15 +20,11 @@ variables: webAppName: 'e2epython' containerRegistry: 'nightlye2etest.azurecr.io' imageRepository: 'functionaltestpy' - - - + # LinuxTestBotAppId: get this from azure + # LinuxTestBotAppSecret: get this from Azure jobs: -# Build and publish container - job: Build - pool: - vmImage: 'Ubuntu-16.04' displayName: Build and push bot image continueOnError: false steps: @@ -35,12 +37,8 @@ jobs: containerRegistry: $(dockerRegistryServiceConnection) tags: $(buildIdTag) - - - job: Deploy displayName: Provision bot container - pool: - vmImage: 'Ubuntu-16.04' dependsOn: - Build steps: @@ -54,8 +52,6 @@ jobs: DockerNamespace: $(containerRegistry) DockerRepository: $(imageRepository) DockerImageTag: $(buildIdTag) - AppSettings: '-MicrosoftAppId $(botAppId) -MicrosoftAppPassword $(botAppPassword) -FLASK_APP /functionaltestbot/app.py -FLASK_DEBUG 1' + AppSettings: '-MicrosoftAppId $(LinuxTestBotAppId) -MicrosoftAppPassword $(LinuxTestBotAppSecret) -FLASK_APP /functionaltestbot/app.py -FLASK_DEBUG 1' #StartupCommand: 'flask run --host=0.0.0.0 --port=3978' - - From 50eb0feb5903bf3880cb018d5e73a4e1ecafc709 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 24 Mar 2020 09:10:36 -0500 Subject: [PATCH 357/616] Added ActivityHandler.on_typing_activity and tests. --- .../botbuilder/core/activity_handler.py | 15 ++++++++ .../teams/test_teams_activity_handler.py | 32 +++++++++++++++++ .../tests/test_activity_handler.py | 34 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index c41ba7e1d..f88012ede 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -80,6 +80,8 @@ async def on_turn(self, turn_context: TurnContext): ) elif turn_context.activity.type == ActivityTypes.end_of_conversation: await self.on_end_of_conversation_activity(turn_context) + elif turn_context.activity.type == ActivityTypes.typing: + await self.on_typing_activity(turn_context) else: await self.on_unrecognized_activity_type(turn_context) @@ -346,6 +348,19 @@ async def on_end_of_conversation_activity( # pylint: disable=unused-argument """ return + async def on_typing_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + """ + Override this in a derived class to provide logic specific to + ActivityTypes.typing activities, such as the conversational logic. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + :returns: A task that represents the work queued to execute + """ + return + async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 38c4e2c14..f2dc9f0ce 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -57,6 +57,14 @@ async def on_event(self, turn_context: TurnContext): self.record.append("on_event") return await super().on_event(turn_context) + async def on_end_of_conversation_activity(self, turn_context: TurnContext): + self.record.append("on_end_of_conversation_activity") + return await super().on_end_of_conversation_activity(turn_context) + + async def on_typing_activity(self, turn_context: TurnContext): + self.record.append("on_typing_activity") + return await super().on_typing_activity(turn_context) + async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) @@ -724,3 +732,27 @@ async def test_on_teams_task_module_submit(self): assert len(bot.record) == 2 assert bot.record[0] == "on_invoke_activity" assert bot.record[1] == "on_teams_task_module_submit" + + async def test_on_end_of_conversation_activity(self): + activity = Activity(type=ActivityTypes.end_of_conversation) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_end_of_conversation_activity" + + async def test_typing_activity(self): + activity = Activity(type=ActivityTypes.typing) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_typing_activity" diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index ab738b791..20d9386e0 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -59,6 +59,14 @@ async def on_event(self, turn_context: TurnContext): self.record.append("on_event") return await super().on_event(turn_context) + async def on_end_of_conversation_activity(self, turn_context: TurnContext): + self.record.append("on_end_of_conversation_activity") + return await super().on_end_of_conversation_activity(turn_context) + + async def on_typing_activity(self, turn_context: TurnContext): + self.record.append("on_typing_activity") + return await super().on_typing_activity(turn_context) + async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) @@ -172,3 +180,29 @@ async def test_invoke_should_not_match(self): assert len(bot.record) == 1 assert bot.record[0] == "on_invoke_activity" assert adapter.activity.value.status == int(HTTPStatus.NOT_IMPLEMENTED) + + async def test_on_end_of_conversation_activity(self): + activity = Activity(type=ActivityTypes.end_of_conversation) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_end_of_conversation_activity" + + async def test_typing_activity(self): + activity = Activity(type=ActivityTypes.typing) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_typing_activity" From ae48aaa4590d59a28047e06545a233713fa4e051 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 24 Mar 2020 11:03:41 -0500 Subject: [PATCH 358/616] TaskModuleContinueResponse and TaskModuleMessageResponse corrections. --- .../botbuilder/schema/teams/_models_py3.py | 56 +++++++++---------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index b18d58a95..181cbd367 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -132,7 +132,7 @@ class FileConsentCardResponse(Model): :param action: The action the user took. Possible values include: 'accept', 'decline' - :type action: str or ~botframework.connector.teams.models.enum + :type action: str :param context: The context associated with the action. :type context: object :param upload_info: If the user accepted the file, contains information @@ -346,7 +346,7 @@ class MessageActionsPayloadBody(Model): :param content_type: Type of the content. Possible values include: 'html', 'text' - :type content_type: str or ~botframework.connector.teams.models.enum + :type content_type: str :param content: The content of the body. :type content: str """ @@ -463,7 +463,7 @@ class MessageActionsPayloadReaction(Model): :param reaction_type: The type of reaction given to the message. Possible values include: 'like', 'heart', 'laugh', 'surprised', 'sad', 'angry' - :type reaction_type: str or ~botframework.connector.teams.models.enum + :type reaction_type: str :param created_date_time: Timestamp of when the user reacted to the message. :type created_date_time: str @@ -491,7 +491,7 @@ class MessageActionsPayloadUser(Model): :param user_identity_type: The identity type of the user. Possible values include: 'aadUser', 'onPremiseAadUser', 'anonymousGuest', 'federatedUser' - :type user_identity_type: str or ~botframework.connector.teams.models.enum + :type user_identity_type: str :param id: The id of the user. :type id: str :param display_name: The plaintext display name of the user. @@ -528,7 +528,7 @@ class MessageActionsPayload(Model): :type reply_to_id: str :param message_type: Type of message - automatically set to message. Possible values include: 'message' - :type message_type: str or ~botframework.connector.teams.models.enum + :type message_type: str :param created_date_time: Timestamp of when the message was created. :type created_date_time: str :param last_modified_date_time: Timestamp of when the message was edited @@ -543,7 +543,7 @@ class MessageActionsPayload(Model): :type summary: str :param importance: The importance of the message. Possible values include: 'normal', 'high', 'urgent' - :type importance: str or ~botframework.connector.teams.models.enum + :type importance: str :param locale: Locale of the message set by the client. :type locale: str :param from_property: Sender of the message. @@ -639,7 +639,7 @@ class MessagingExtensionAction(TaskModuleRequest): :type command_id: str :param command_context: The context from which the command originates. Possible values include: 'message', 'compose', 'commandbox' - :type command_context: str or ~botframework.connector.teams.models.enum + :type command_context: str :param bot_message_preview_action: Bot message preview action taken by user. Possible values include: 'edit', 'send' :type bot_message_preview_action: str or @@ -864,10 +864,10 @@ class MessagingExtensionResult(Model): :param attachment_layout: Hint for how to deal with multiple attachments. Possible values include: 'list', 'grid' - :type attachment_layout: str or ~botframework.connector.teams.models.enum + :type attachment_layout: str :param type: The type of the result. Possible values include: 'result', 'auth', 'config', 'message', 'botMessagePreview' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param attachments: (Only when type is result) Attachments :type attachments: list[~botframework.connector.teams.models.MessagingExtensionAttachment] @@ -1002,7 +1002,7 @@ class O365ConnectorCardInputBase(Model): :param type: Input type name. Possible values include: 'textInput', 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param id: Input Id. It must be unique per entire O365 connector card. :type id: str :param is_required: Define if this input is a required field. Default @@ -1045,7 +1045,7 @@ class O365ConnectorCardActionBase(Model): :param type: Type of the action. Possible values include: 'ViewAction', 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param name: Name of the action that will be used as button title :type name: str :param id: Action Id @@ -1072,7 +1072,7 @@ class O365ConnectorCardActionCard(O365ConnectorCardActionBase): :param type: Type of the action. Possible values include: 'ViewAction', 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param name: Name of the action that will be used as button title :type name: str :param id: Action Id @@ -1141,7 +1141,7 @@ class O365ConnectorCardDateInput(O365ConnectorCardInputBase): :param type: Input type name. Possible values include: 'textInput', 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param id: Input Id. It must be unique per entire O365 connector card. :type id: str :param is_required: Define if this input is a required field. Default @@ -1212,7 +1212,7 @@ class O365ConnectorCardHttpPOST(O365ConnectorCardActionBase): :param type: Type of the action. Possible values include: 'ViewAction', 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param name: Name of the action that will be used as button title :type name: str :param id: Action Id @@ -1262,7 +1262,7 @@ class O365ConnectorCardMultichoiceInput(O365ConnectorCardInputBase): :param type: Input type name. Possible values include: 'textInput', 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param id: Input Id. It must be unique per entire O365 connector card. :type id: str :param is_required: Define if this input is a required field. Default @@ -1278,7 +1278,7 @@ class O365ConnectorCardMultichoiceInput(O365ConnectorCardInputBase): list[~botframework.connector.teams.models.O365ConnectorCardMultichoiceInputChoice] :param style: Choice item rendering style. Default value is 'compact'. Possible values include: 'compact', 'expanded' - :type style: str or ~botframework.connector.teams.models.enum + :type style: str :param is_multi_select: Define if this input field allows multiple selections. Default value is false. :type is_multi_select: bool @@ -1349,7 +1349,7 @@ class O365ConnectorCardOpenUri(O365ConnectorCardActionBase): :param type: Type of the action. Possible values include: 'ViewAction', 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param name: Name of the action that will be used as button title :type name: str :param id: Action Id @@ -1380,7 +1380,7 @@ class O365ConnectorCardOpenUriTarget(Model): :param os: Target operating system. Possible values include: 'default', 'iOS', 'android', 'windows' - :type os: str or ~botframework.connector.teams.models.enum + :type os: str :param uri: Target url :type uri: str """ @@ -1481,7 +1481,7 @@ class O365ConnectorCardTextInput(O365ConnectorCardInputBase): :param type: Input type name. Possible values include: 'textInput', 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param id: Input Id. It must be unique per entire O365 connector card. :type id: str :param is_required: Define if this input is a required field. Default @@ -1538,7 +1538,7 @@ class O365ConnectorCardViewAction(O365ConnectorCardActionBase): :param type: Type of the action. Possible values include: 'ViewAction', 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str :param name: Name of the action that will be used as button title :type name: str :param id: Action Id @@ -1586,7 +1586,7 @@ class TaskModuleResponseBase(Model): :param type: Choice of action options when responding to the task/submit message. Possible values include: 'message', 'continue' - :type type: str or ~botframework.connector.teams.models.enum + :type type: str """ _attribute_map = { @@ -1601,9 +1601,6 @@ def __init__(self, *, type=None, **kwargs) -> None: class TaskModuleContinueResponse(TaskModuleResponseBase): """Task Module Response with continue action. - :param type: Choice of action options when responding to the task/submit - message. Possible values include: 'message', 'continue' - :type type: str or ~botframework.connector.teams.models.enum :param value: The JSON for the Adaptive card to appear in the task module. :type value: ~botframework.connector.teams.models.TaskModuleTaskInfo """ @@ -1613,17 +1610,14 @@ class TaskModuleContinueResponse(TaskModuleResponseBase): "value": {"key": "value", "type": "TaskModuleTaskInfo"}, } - def __init__(self, *, type=None, value=None, **kwargs) -> None: - super(TaskModuleContinueResponse, self).__init__(type=type, **kwargs) + def __init__(self, *, value=None, **kwargs) -> None: + super(TaskModuleContinueResponse, self).__init__(type="continue", **kwargs) self.value = value class TaskModuleMessageResponse(TaskModuleResponseBase): """Task Module response with message action. - :param type: Choice of action options when responding to the task/submit - message. Possible values include: 'message', 'continue' - :type type: str or ~botframework.connector.teams.models.enum :param value: Teams will display the value of value in a popup message box. :type value: str @@ -1634,8 +1628,8 @@ class TaskModuleMessageResponse(TaskModuleResponseBase): "value": {"key": "value", "type": "str"}, } - def __init__(self, *, type=None, value: str = None, **kwargs) -> None: - super(TaskModuleMessageResponse, self).__init__(type=type, **kwargs) + def __init__(self, *, value: str = None, **kwargs) -> None: + super(TaskModuleMessageResponse, self).__init__(type="message", **kwargs) self.value = value From 90425d360f943848dc002d5397d5b7148490224e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 24 Mar 2020 11:14:36 -0500 Subject: [PATCH 359/616] ShowTypingMiddleware fixes (switch to asyncio.ensure_future & sleep) (#888) --- .../botbuilder/core/show_typing_middleware.py | 82 +++++------ .../tests/test_show_typing_middleware.py | 135 +++++++++--------- 2 files changed, 108 insertions(+), 109 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py index 6002fbcc7..a659cd8bf 100644 --- a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - -import time -from functools import wraps +import asyncio from typing import Awaitable, Callable from botbuilder.schema import Activity, ActivityTypes @@ -11,38 +9,38 @@ from .turn_context import TurnContext -def delay(span=0.0): - def wrap(func): - @wraps(func) - async def delayed(): - time.sleep(span) - await func() - - return delayed - - return wrap - - class Timer: clear_timer = False - async def set_timeout(self, func, time): - is_invocation_cancelled = False - - @delay(time) + def set_timeout(self, func, span): async def some_fn(): # pylint: disable=function-redefined + await asyncio.sleep(span) if not self.clear_timer: await func() - await some_fn() - return is_invocation_cancelled + asyncio.ensure_future(some_fn()) def set_clear_timer(self): self.clear_timer = True class ShowTypingMiddleware(Middleware): + """ + When added, this middleware will send typing activities back to the user when a Message activity + is received to let them know that the bot has received the message and is working on the response. + You can specify a delay before the first typing activity is sent and then a frequency, which + determines how often another typing activity is sent. Typing activities will continue to be sent + until your bot sends another message back to the user. + """ + def __init__(self, delay: float = 0.5, period: float = 2.0): + """ + Initializes the middleware. + + :param delay: Delay in seconds for the first typing indicator to be sent. + :param period: Delay in seconds for subsequent typing indicators. + """ + if delay < 0: raise ValueError("Delay must be greater than or equal to zero") @@ -55,41 +53,43 @@ def __init__(self, delay: float = 0.5, period: float = 2.0): async def on_turn( self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] ): - finished = False timer = Timer() - async def start_interval(context: TurnContext, delay: int, period: int): + def start_interval(context: TurnContext, delay, period): async def aux(): - if not finished: - typing_activity = Activity( - type=ActivityTypes.typing, - relates_to=context.activity.relates_to, - ) + typing_activity = Activity( + type=ActivityTypes.typing, relates_to=context.activity.relates_to, + ) - conversation_reference = TurnContext.get_conversation_reference( - context.activity - ) + conversation_reference = TurnContext.get_conversation_reference( + context.activity + ) - typing_activity = TurnContext.apply_conversation_reference( - typing_activity, conversation_reference - ) + typing_activity = TurnContext.apply_conversation_reference( + typing_activity, conversation_reference + ) - await context.adapter.send_activities(context, [typing_activity]) + asyncio.ensure_future( + context.adapter.send_activities(context, [typing_activity]) + ) - start_interval(context, period, period) + # restart the timer, with the 'period' value for the delay + timer.set_timeout(aux, period) - await timer.set_timeout(aux, delay) + # first time through we use the 'delay' value for the timer. + timer.set_timeout(aux, delay) def stop_interval(): - nonlocal finished - finished = True timer.set_clear_timer() + # if it's a message, start sending typing activities until the + # bot logic is done. if context.activity.type == ActivityTypes.message: - finished = False - await start_interval(context, self._delay, self._period) + start_interval(context, self._delay, self._period) + # call the bot logic result = await logic() + stop_interval() return result diff --git a/libraries/botbuilder-core/tests/test_show_typing_middleware.py b/libraries/botbuilder-core/tests/test_show_typing_middleware.py index 0e1aff56e..b3b10a13d 100644 --- a/libraries/botbuilder-core/tests/test_show_typing_middleware.py +++ b/libraries/botbuilder-core/tests/test_show_typing_middleware.py @@ -1,68 +1,67 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import time -import aiounittest - -from botbuilder.core import ShowTypingMiddleware -from botbuilder.core.adapters import TestAdapter -from botbuilder.schema import ActivityTypes - - -class TestShowTypingMiddleware(aiounittest.AsyncTestCase): - async def test_should_automatically_send_a_typing_indicator(self): - async def aux(context): - time.sleep(0.600) - await context.send_activity(f"echo:{context.activity.text}") - - def assert_is_typing(activity, description): # pylint: disable=unused-argument - assert activity.type == ActivityTypes.typing - - adapter = TestAdapter(aux) - adapter.use(ShowTypingMiddleware()) - - step1 = await adapter.send("foo") - step2 = await step1.assert_reply(assert_is_typing) - step3 = await step2.assert_reply("echo:foo") - step4 = await step3.send("bar") - step5 = await step4.assert_reply(assert_is_typing) - await step5.assert_reply("echo:bar") - - async def test_should_not_automatically_send_a_typing_indicator_if_no_middleware( - self, - ): - async def aux(context): - await context.send_activity(f"echo:{context.activity.text}") - - adapter = TestAdapter(aux) - - step1 = await adapter.send("foo") - await step1.assert_reply("echo:foo") - - async def test_should_not_immediately_respond_with_message(self): - async def aux(context): - time.sleep(0.600) - await context.send_activity(f"echo:{context.activity.text}") - - def assert_is_not_message( - activity, description - ): # pylint: disable=unused-argument - assert activity.type != ActivityTypes.message - - adapter = TestAdapter(aux) - adapter.use(ShowTypingMiddleware()) - - step1 = await adapter.send("foo") - await step1.assert_reply(assert_is_not_message) - - async def test_should_immediately_respond_with_message_if_no_middleware(self): - async def aux(context): - await context.send_activity(f"echo:{context.activity.text}") - - def assert_is_message(activity, description): # pylint: disable=unused-argument - assert activity.type == ActivityTypes.message - - adapter = TestAdapter(aux) - - step1 = await adapter.send("foo") - await step1.assert_reply(assert_is_message) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import asyncio +import aiounittest + +from botbuilder.core import ShowTypingMiddleware +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import ActivityTypes + + +class TestShowTypingMiddleware(aiounittest.AsyncTestCase): + async def test_should_automatically_send_a_typing_indicator(self): + async def aux(context): + await asyncio.sleep(0.600) + await context.send_activity(f"echo:{context.activity.text}") + + def assert_is_typing(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.typing + + adapter = TestAdapter(aux) + adapter.use(ShowTypingMiddleware()) + + step1 = await adapter.send("foo") + step2 = await step1.assert_reply(assert_is_typing) + step3 = await step2.assert_reply("echo:foo") + step4 = await step3.send("bar") + step5 = await step4.assert_reply(assert_is_typing) + await step5.assert_reply("echo:bar") + + async def test_should_not_automatically_send_a_typing_indicator_if_no_middleware( + self, + ): + async def aux(context): + await context.send_activity(f"echo:{context.activity.text}") + + adapter = TestAdapter(aux) + + step1 = await adapter.send("foo") + await step1.assert_reply("echo:foo") + + async def test_should_not_immediately_respond_with_message(self): + async def aux(context): + await asyncio.sleep(0.600) + await context.send_activity(f"echo:{context.activity.text}") + + def assert_is_not_message( + activity, description + ): # pylint: disable=unused-argument + assert activity.type != ActivityTypes.message + + adapter = TestAdapter(aux) + adapter.use(ShowTypingMiddleware()) + + step1 = await adapter.send("foo") + await step1.assert_reply(assert_is_not_message) + + async def test_should_immediately_respond_with_message_if_no_middleware(self): + async def aux(context): + await context.send_activity(f"echo:{context.activity.text}") + + def assert_is_message(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.message + + adapter = TestAdapter(aux) + + step1 = await adapter.send("foo") + await step1.assert_reply(assert_is_message) From 3840f9943d42feceb38a15507e6a116f79f9ac7d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 25 Mar 2020 09:53:04 -0700 Subject: [PATCH 360/616] Ref doc comment fix - bug 190353 (#884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed typo causing bug 190353 Co-authored-by: Emily Olshefski Co-authored-by: Axel Suárez --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index b18d76497..718aa2427 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -266,7 +266,7 @@ async def sign_out_user(self, context: TurnContext): :type context: :class:`TurnContext` :return: A task representing the work queued to execute - .. reamarks:: + .. remarks:: If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ From 2561c4d8392c5a725ec66775af86d082f58c0dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 27 Mar 2020 15:20:12 -0700 Subject: [PATCH 361/616] Update Build Badge in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3736d6e58..e4347845d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ### [Click here to find out what's new with Bot Framework](https://docs.microsoft.com/en-us/azure/bot-service/what-is-new?view=azure-bot-service-4.0) # Bot Framework SDK v4 for Python -[![Build status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=431) +[![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=master) [![roadmap badge](https://img.shields.io/badge/visit%20the-roadmap-blue.svg)](https://github.com/Microsoft/botbuilder-python/wiki/Roadmap) [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) From cdd00e1ea8f932da009c1a5932e380f221494006 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Mon, 30 Mar 2020 14:30:23 -0700 Subject: [PATCH 362/616] Fixed build error in Project 194592 (#898) https://ceapex.visualstudio.com/Onboarding/_workitems/edit/194592?src=WorkItemMention&src-action=artifact_link --- .../botbuilder/dialogs/component_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 1034896b6..34c84fbff 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -87,14 +87,14 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu If this method is *not* overriden the component dialog calls the :meth:`botbuilder.dialogs.DialogContext.continue_dialog` method on it's inner dialog context. If the inner dialog stack is empty, the component dialog ends, - and if a :var:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog + and if a :class:`botbuilder.dialogs.DialogTurnResult.result` is available, the component dialog uses that as it's return value. :param dialog_context: The parent dialog context for the current turn of the conversation. :type dialog_context: :class:`botbuilder.dialogs.DialogContext` :return: Signals the end of the turn - :rtype: :var:`botbuilder.dialogs.Dialog.end_of_turn` + :rtype: :class:`botbuilder.dialogs.Dialog.end_of_turn` """ if dialog_context is None: raise TypeError("ComponentDialog.begin_dialog(): outer_dc cannot be None.") @@ -271,6 +271,6 @@ async def end_component( :param result: Optional, value to return from the dialog component to the parent context. :type result: object :return: Value to return. - :rtype: :var:`botbuilder.dialogs.DialogTurnResult.result` + :rtype: :class:`botbuilder.dialogs.DialogTurnResult.result` """ return await outer_dc.end_dialog(result) From d0e6d78bc74667f33d1ce7b8c9dc54857b029bf4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 31 Mar 2020 10:00:09 -0500 Subject: [PATCH 363/616] Added QnAMakerDialog --- .../botbuilder/ai/qna/__init__.py | 80 +-- .../botbuilder/ai/qna/dialogs/__init__.py | 14 + .../ai/qna/dialogs/qnamaker_dialog.py | 373 ++++++++++++ .../ai/qna/dialogs/qnamaker_dialog_options.py | 18 + .../ai/qna/models/qna_request_context.py | 62 +- .../ai/qna/qna_dialog_response_options.py | 18 + .../botbuilder/ai/qna/qnamaker.py | 545 +++++++++--------- .../botbuilder/ai/qna/qnamaker_options.py | 54 +- .../botbuilder/ai/qna/utils/__init__.py | 42 +- .../ai/qna/utils/http_request_utils.py | 193 ++++--- .../ai/qna/utils/qna_card_builder.py | 80 +++ libraries/botbuilder-ai/tests/qna/test_qna.py | 2 +- .../botbuilder/dialogs/__init__.py | 2 + .../botbuilder/dialogs/object_path.py | 304 ++++++++++ libraries/botbuilder-dialogs/requirements.txt | 2 +- .../tests/test_object_path.py | 254 ++++++++ 16 files changed, 1558 insertions(+), 485 deletions(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/__init__.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog_options.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qna_dialog_response_options.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py create mode 100644 libraries/botbuilder-dialogs/tests/test_object_path.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py index dc3f3ccba..938010a71 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py @@ -1,39 +1,41 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .qnamaker import QnAMaker -from .qnamaker_endpoint import QnAMakerEndpoint -from .qnamaker_options import QnAMakerOptions -from .qnamaker_telemetry_client import QnAMakerTelemetryClient -from .utils import ( - ActiveLearningUtils, - GenerateAnswerUtils, - HttpRequestUtils, - QnATelemetryConstants, -) - -from .models import ( - FeedbackRecord, - FeedbackRecords, - Metadata, - QnAMakerTraceInfo, - QueryResult, - QueryResults, -) - -__all__ = [ - "ActiveLearningUtils", - "FeedbackRecord", - "FeedbackRecords", - "GenerateAnswerUtils", - "HttpRequestUtils", - "Metadata", - "QueryResult", - "QueryResults", - "QnAMaker", - "QnAMakerEndpoint", - "QnAMakerOptions", - "QnAMakerTelemetryClient", - "QnAMakerTraceInfo", - "QnATelemetryConstants", -] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .qnamaker import QnAMaker +from .qnamaker_endpoint import QnAMakerEndpoint +from .qnamaker_options import QnAMakerOptions +from .qnamaker_telemetry_client import QnAMakerTelemetryClient +from .qna_dialog_response_options import QnADialogResponseOptions +from .utils import ( + ActiveLearningUtils, + GenerateAnswerUtils, + HttpRequestUtils, + QnATelemetryConstants, +) + +from .models import ( + FeedbackRecord, + FeedbackRecords, + Metadata, + QnAMakerTraceInfo, + QueryResult, + QueryResults, +) + +__all__ = [ + "ActiveLearningUtils", + "FeedbackRecord", + "FeedbackRecords", + "GenerateAnswerUtils", + "HttpRequestUtils", + "Metadata", + "QueryResult", + "QueryResults", + "QnAMaker", + "QnAMakerEndpoint", + "QnAMakerOptions", + "QnAMakerTelemetryClient", + "QnAMakerTraceInfo", + "QnATelemetryConstants", + "QnADialogResponseOptions", +] diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/__init__.py new file mode 100644 index 000000000..a6fb238d1 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/__init__.py @@ -0,0 +1,14 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .qnamaker_dialog import QnAMakerDialog +from .qnamaker_dialog_options import QnAMakerDialogOptions + +__all__ = [ + "QnAMakerDialogOptions", + "QnAMakerDialog", +] diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py new file mode 100644 index 000000000..c5cfd3c10 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -0,0 +1,373 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import List + +from botbuilder.dialogs import ( + WaterfallDialog, + WaterfallStepContext, + DialogContext, + DialogTurnResult, + Dialog, + ObjectPath, + DialogTurnStatus, + DialogReason, +) +from botbuilder.schema import Activity, ActivityTypes + +from .qnamaker_dialog_options import QnAMakerDialogOptions +from .. import ( + QnAMakerOptions, + QnADialogResponseOptions, + QnAMaker, + QnAMakerEndpoint, +) +from ..models import QnARequestContext, Metadata, QueryResult, FeedbackRecord +from ..models.ranker_types import RankerTypes +from ..utils import QnACardBuilder + + +class QnAMakerDialog(WaterfallDialog): + KEY_QNA_CONTEXT_DATA = "qnaContextData" + KEY_PREVIOUS_QNA_ID = "prevQnAId" + KEY_OPTIONS = "options" + + # Dialog Options parameters + DEFAULT_THRESHOLD = 0.3 + DEFAULT_TOP_N = 3 + DEFAULT_NO_ANSWER = "No QnAMaker answers found." + + # Card parameters + DEFAULT_CARD_TITLE = "Did you mean:" + DEFAULT_CARD_NO_MATCH_TEXT = "None of the above." + DEFAULT_CARD_NO_MATCH_RESPONSE = "Thanks for the feedback." + + # Value Properties + PROPERTY_CURRENT_QUERY = "currentQuery" + PROPERTY_QNA_DATA = "qnaData" + + def __init__( + self, + knowledgebase_id: str, + endpoint_key: str, + hostname: str, + no_answer: Activity = None, + threshold: float = DEFAULT_THRESHOLD, + active_learning_card_title: str = DEFAULT_CARD_TITLE, + card_no_match_text: str = DEFAULT_CARD_NO_MATCH_TEXT, + top: int = DEFAULT_TOP_N, + card_no_match_response: Activity = None, + strict_filters: [Metadata] = None, + dialog_id: str = "QnAMakerDialog", + ): + super().__init__(dialog_id) + + self.knowledgebase_id = knowledgebase_id + self.endpoint_key = endpoint_key + self.hostname = hostname + self.no_answer = no_answer + self.threshold = threshold + self.active_learning_card_title = active_learning_card_title + self.card_no_match_text = card_no_match_text + self.top = top + self.card_no_match_response = card_no_match_response + self.strict_filters = strict_filters + + self.maximum_score_for_low_score_variation = 0.99 + + self.add_step(self.__call_generate_answer) + self.add_step(self.__call_train) + self.add_step(self.__check_for_multiturn_prompt) + self.add_step(self.__display_qna_result) + + async def begin_dialog( + self, dialog_context: DialogContext, options: object = None + ) -> DialogTurnResult: + if not dialog_context: + raise TypeError("DialogContext is required") + + if ( + dialog_context.context + and dialog_context.context.activity + and dialog_context.context.activity.type != ActivityTypes.message + ): + return Dialog.end_of_turn + + dialog_options = QnAMakerDialogOptions( + options=self._get_qnamaker_options(dialog_context), + response_options=self._get_qna_response_options(dialog_context), + ) + + if options: + dialog_options = ObjectPath.assign(dialog_options, options) + + ObjectPath.set_path_value( + dialog_context.active_dialog.state, + QnAMakerDialog.KEY_OPTIONS, + dialog_options, + ) + + return await super().begin_dialog(dialog_context, dialog_options) + + def _get_qnamaker_client(self, dialog_context: DialogContext) -> QnAMaker: + endpoint = QnAMakerEndpoint( + endpoint_key=self.endpoint_key, + host=self.hostname, + knowledge_base_id=self.knowledgebase_id, + ) + + options = self._get_qnamaker_options(dialog_context) + + return QnAMaker(endpoint, options) + + def _get_qnamaker_options( # pylint: disable=unused-argument + self, dialog_context: DialogContext + ) -> QnAMakerOptions: + return QnAMakerOptions( + score_threshold=self.threshold, + strict_filters=self.strict_filters, + top=self.top, + context=QnARequestContext(), + qna_id=0, + ranker_type=RankerTypes.DEFAULT, + is_test=False, + ) + + def _get_qna_response_options( # pylint: disable=unused-argument + self, dialog_context: DialogContext + ) -> QnADialogResponseOptions: + return QnADialogResponseOptions( + no_answer=self.no_answer, + active_learning_card_title=self.active_learning_card_title + or QnAMakerDialog.DEFAULT_CARD_TITLE, + card_no_match_text=self.card_no_match_text + or QnAMakerDialog.DEFAULT_CARD_NO_MATCH_TEXT, + card_no_match_response=self.card_no_match_response, + ) + + async def __call_generate_answer(self, step_context: WaterfallStepContext): + dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS + ) + + # Resetting context and QnAId + dialog_options.options.qna_id = 0 + dialog_options.options.context = QnARequestContext() + + # Storing the context info + step_context.values[ + QnAMakerDialog.PROPERTY_CURRENT_QUERY + ] = step_context.context.activity.text + + # -Check if previous context is present, if yes then put it with the query + # -Check for id if query is present in reverse index. + previous_context_data = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_QNA_CONTEXT_DATA, {} + ) + previous_qna_id = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_PREVIOUS_QNA_ID, 0 + ) + + if previous_qna_id > 0: + dialog_options.options.context = QnARequestContext( + previous_qna_id=previous_qna_id + ) + + current_qna_id = previous_context_data.get( + step_context.context.activity.text + ) + if current_qna_id: + dialog_options.options.qna_id = current_qna_id + + # Calling QnAMaker to get response. + qna_client = self._get_qnamaker_client(step_context) + response = await qna_client.get_answers_raw( + step_context.context, dialog_options.options + ) + + is_active_learning_enabled = response.active_learning_enabled + step_context.values[QnAMakerDialog.PROPERTY_QNA_DATA] = response.answers + + # Resetting previous query. + previous_qna_id = -1 + ObjectPath.set_path_value( + step_context.active_dialog.state, + QnAMakerDialog.KEY_PREVIOUS_QNA_ID, + previous_qna_id, + ) + + # Check if active learning is enabled and send card + # maximum_score_for_low_score_variation is the score above which no need to check for feedback. + if ( + is_active_learning_enabled + and response.answers + and response.answers[0].score <= self.maximum_score_for_low_score_variation + ): + # Get filtered list of the response that support low score variation criteria. + response.answers = qna_client.get_low_score_variation(response.answers) + if response.answers: + suggested_questions = [qna.questions[0] for qna in response.answers] + message = QnACardBuilder.get_suggestions_card( + suggested_questions, + dialog_options.response_options.active_learning_card_title, + dialog_options.response_options.card_no_match_text, + ) + await step_context.context.send_activity(message) + + ObjectPath.set_path_value( + step_context.active_dialog.state, + QnAMakerDialog.KEY_OPTIONS, + dialog_options, + ) + + await qna_client.close() + + return DialogTurnResult(DialogTurnStatus.Waiting) + + # If card is not shown, move to next step with top qna response. + result = [response.answers[0]] if response.answers else [] + step_context.values[QnAMakerDialog.PROPERTY_QNA_DATA] = result + ObjectPath.set_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS, dialog_options + ) + + await qna_client.close() + + return await step_context.next(result) + + async def __call_train(self, step_context: WaterfallStepContext): + dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS + ) + train_responses: [QueryResult] = step_context.values[ + QnAMakerDialog.PROPERTY_QNA_DATA + ] + current_query = step_context.values[QnAMakerDialog.PROPERTY_CURRENT_QUERY] + + reply = step_context.context.activity.text + + if len(train_responses) > 1: + qna_results = [ + result for result in train_responses if result.questions[0] == reply + ] + + if qna_results: + qna_result = qna_results[0] + step_context.values[QnAMakerDialog.PROPERTY_QNA_DATA] = [qna_result] + + feedback_records = [ + FeedbackRecord( + user_id=step_context.context.activity.id, + user_question=current_query, + qna_id=qna_result.id, + ) + ] + + # Call Active Learning Train API + qna_client = self._get_qnamaker_client(step_context) + await qna_client.call_train(feedback_records) + await qna_client.close() + + return await step_context.next([qna_result]) + + if ( + reply.lower() + == dialog_options.response_options.card_no_match_text.lower() + ): + activity = dialog_options.response_options.card_no_match_response + if not activity: + await step_context.context.send_activity( + QnAMakerDialog.DEFAULT_CARD_NO_MATCH_RESPONSE + ) + else: + await step_context.context.send_activity(activity) + + return await step_context.end_dialog() + + return await super().run_step( + step_context, index=0, reason=DialogReason.BeginCalled, result=None + ) + + return await step_context.next(step_context.result) + + async def __check_for_multiturn_prompt(self, step_context: WaterfallStepContext): + dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS + ) + + response = step_context.result + if response and isinstance(response, List): + answer = response[0] + if answer.context and answer.context.prompts: + previous_context_data = ObjectPath.get_path_value( + step_context.active_dialog.state, + QnAMakerDialog.KEY_QNA_CONTEXT_DATA, + ) + for prompt in answer.context.prompts: + previous_context_data[prompt.display_text] = prompt.qna_id + + ObjectPath.set_path_value( + step_context.active_dialog.state, + QnAMakerDialog.KEY_QNA_CONTEXT_DATA, + previous_context_data, + ) + ObjectPath.set_path_value( + step_context.active_dialog.state, + QnAMakerDialog.KEY_PREVIOUS_QNA_ID, + answer.id, + ) + ObjectPath.set_path_value( + step_context.active_dialog.state, + QnAMakerDialog.KEY_OPTIONS, + dialog_options, + ) + + # Get multi-turn prompts card activity. + message = QnACardBuilder.get_qna_prompts_card( + answer, dialog_options.response_options.card_no_match_text + ) + await step_context.context.send_activity(message) + + return DialogTurnResult(DialogTurnStatus.Waiting) + + return await step_context.next(step_context.result) + + async def __display_qna_result(self, step_context: WaterfallStepContext): + dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS + ) + + reply = step_context.context.activity.text + if reply.lower() == dialog_options.response_options.card_no_match_text.lower(): + activity = dialog_options.response_options.card_no_match_response + if not activity: + await step_context.context.send_activity( + QnAMakerDialog.DEFAULT_CARD_NO_MATCH_RESPONSE + ) + else: + await step_context.context.send_activity(activity) + + return await step_context.end_dialog() + + # If previous QnAId is present, replace the dialog + previous_qna_id = ObjectPath.get_path_value( + step_context.active_dialog.state, QnAMakerDialog.KEY_PREVIOUS_QNA_ID + ) + if previous_qna_id > 0: + return await super().run_step( + step_context, index=0, reason=DialogReason.BeginCalled, result=None + ) + + # If response is present then show that response, else default answer. + response = step_context.result + if response and isinstance(response, List): + await step_context.context.send_activity(response[0].answer) + else: + activity = dialog_options.response_options.no_answer + if not activity: + await step_context.context.send_activity( + QnAMakerDialog.DEFAULT_NO_ANSWER + ) + else: + await step_context.context.send_activity(activity) + + return await step_context.end_dialog() diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog_options.py new file mode 100644 index 000000000..99d0e15cf --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog_options.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .. import QnAMakerOptions, QnADialogResponseOptions + + +class QnAMakerDialogOptions: + """ + Defines Dialog Options for QnAMakerDialog. + """ + + def __init__( + self, + options: QnAMakerOptions = None, + response_options: QnADialogResponseOptions = None, + ): + self.options = options or QnAMakerOptions() + self.response_options = response_options or QnADialogResponseOptions() diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py index ae3342a76..5292b6d33 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py @@ -1,31 +1,31 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from msrest.serialization import Model - - -class QnARequestContext(Model): - """ - The context associated with QnA. - Used to mark if the current prompt is relevant with a previous question or not. - """ - - _attribute_map = { - "previous_qna_id": {"key": "previousQnAId", "type": "int"}, - "prvious_user_query": {"key": "previousUserQuery", "type": "string"}, - } - - def __init__(self, previous_qna_id: int, prvious_user_query: str, **kwargs): - """ - Parameters: - ----------- - - previous_qna_id: The previous QnA Id that was returned. - - prvious_user_query: The previous user query/question. - """ - - super().__init__(**kwargs) - - self.previous_qna_id = previous_qna_id - self.prvious_user_query = prvious_user_query +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from msrest.serialization import Model + + +class QnARequestContext(Model): + """ + The context associated with QnA. + Used to mark if the current prompt is relevant with a previous question or not. + """ + + _attribute_map = { + "previous_qna_id": {"key": "previousQnAId", "type": "int"}, + "previous_user_query": {"key": "previousUserQuery", "type": "string"}, + } + + def __init__(self, previous_qna_id: int = None, previous_user_query: str = None, **kwargs): + """ + Parameters: + ----------- + + previous_qna_id: The previous QnA Id that was returned. + + previous_user_query: The previous user query/question. + """ + + super().__init__(**kwargs) + + self.previous_qna_id = previous_qna_id + self.previous_user_query = previous_user_query diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qna_dialog_response_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qna_dialog_response_options.py new file mode 100644 index 000000000..5490f7727 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qna_dialog_response_options.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import Activity + + +class QnADialogResponseOptions: + def __init__( + self, + active_learning_card_title: str = None, + card_no_match_text: str = None, + no_answer: Activity = None, + card_no_match_response: Activity = None, + ): + self.active_learning_card_title = active_learning_card_title + self.card_no_match_text = card_no_match_text + self.no_answer = no_answer + self.card_no_match_response = card_no_match_response diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index f01aeb453..4c4f7cfba 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -1,271 +1,274 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -from typing import Dict, List, NamedTuple, Union -from aiohttp import ClientSession, ClientTimeout - -from botbuilder.schema import Activity -from botbuilder.core import BotTelemetryClient, NullTelemetryClient, TurnContext - -from .models import FeedbackRecord, QueryResult, QueryResults -from .utils import ( - ActiveLearningUtils, - GenerateAnswerUtils, - QnATelemetryConstants, - TrainUtils, -) -from .qnamaker_endpoint import QnAMakerEndpoint -from .qnamaker_options import QnAMakerOptions -from .qnamaker_telemetry_client import QnAMakerTelemetryClient - -from .. import __title__, __version__ - - -class EventData(NamedTuple): - properties: Dict[str, str] - metrics: Dict[str, float] - - -class QnAMaker(QnAMakerTelemetryClient): - """ - Class used to query a QnA Maker knowledge base for answers. - """ - - def __init__( - self, - endpoint: QnAMakerEndpoint, - options: QnAMakerOptions = None, - http_client: ClientSession = None, - telemetry_client: BotTelemetryClient = None, - log_personal_information: bool = None, - ): - super().__init__(log_personal_information, telemetry_client) - - if not isinstance(endpoint, QnAMakerEndpoint): - raise TypeError( - "QnAMaker.__init__(): endpoint is not an instance of QnAMakerEndpoint" - ) - - self._endpoint: str = endpoint - - opt = options or QnAMakerOptions() - self._validate_options(opt) - - instance_timeout = ClientTimeout(total=opt.timeout / 1000) - self._http_client = http_client or ClientSession(timeout=instance_timeout) - - self.telemetry_client: Union[ - BotTelemetryClient, NullTelemetryClient - ] = telemetry_client or NullTelemetryClient() - - self.log_personal_information = log_personal_information or False - - self._generate_answer_helper = GenerateAnswerUtils( - self.telemetry_client, self._endpoint, options, self._http_client - ) - self._active_learning_train_helper = TrainUtils( - self._endpoint, self._http_client - ) - - async def get_answers( - self, - context: TurnContext, - options: QnAMakerOptions = None, - telemetry_properties: Dict[str, str] = None, - telemetry_metrics: Dict[str, int] = None, - ) -> [QueryResult]: - """ - Generates answers from the knowledge base. - - return: - ------- - A list of answers for the user's query, sorted in decreasing order of ranking score. - - rtype: - ------ - List[QueryResult] - """ - result = await self.get_answers_raw( - context, options, telemetry_properties, telemetry_metrics - ) - - return result.answers - - async def get_answers_raw( - self, - context: TurnContext, - options: QnAMakerOptions = None, - telemetry_properties: Dict[str, str] = None, - telemetry_metrics: Dict[str, int] = None, - ) -> QueryResults: - """ - Generates raw answers from the knowledge base. - - return: - ------- - A list of answers for the user's query, sorted in decreasing order of ranking score. - - rtype: - ------ - QueryResults - """ - if not context: - raise TypeError("QnAMaker.get_answers(): context cannot be None.") - - if not isinstance(context.activity, Activity): - raise TypeError( - "QnAMaker.get_answers(): TurnContext's activity must be an Activity instance." - ) - - result = await self._generate_answer_helper.get_answers_raw(context, options) - - await self.on_qna_result( - result.answers, context, telemetry_properties, telemetry_metrics - ) - - return result - - def get_low_score_variation(self, query_result: QueryResult) -> List[QueryResult]: - """ - Filters the ambiguous question for active learning. - - Parameters: - ----------- - query_result: User query output. - - Return: - ------- - Filtered aray of ambigous questions. - """ - return ActiveLearningUtils.get_low_score_variation(query_result) - - async def call_train(self, feedback_records: List[FeedbackRecord]): - """ - Sends feedback to the knowledge base. - - Parameters: - ----------- - feedback_records - """ - return await self._active_learning_train_helper.call_train(feedback_records) - - async def on_qna_result( - self, - query_results: [QueryResult], - turn_context: TurnContext, - telemetry_properties: Dict[str, str] = None, - telemetry_metrics: Dict[str, float] = None, - ): - event_data = await self.fill_qna_event( - query_results, turn_context, telemetry_properties, telemetry_metrics - ) - - # Track the event - self.telemetry_client.track_event( - name=QnATelemetryConstants.qna_message_event, - properties=event_data.properties, - measurements=event_data.metrics, - ) - - async def fill_qna_event( - self, - query_results: [QueryResult], - turn_context: TurnContext, - telemetry_properties: Dict[str, str] = None, - telemetry_metrics: Dict[str, float] = None, - ) -> EventData: - """ - Fills the event properties and metrics for the QnaMessage event for telemetry. - - return: - ------- - A tuple of event data properties and metrics that will be sent to the - BotTelemetryClient.track_event() method for the QnAMessage event. - The properties and metrics returned the standard properties logged - with any properties passed from the get_answers() method. - - rtype: - ------ - EventData - """ - - properties: Dict[str, str] = dict() - metrics: Dict[str, float] = dict() - - properties[ - QnATelemetryConstants.knowledge_base_id_property - ] = self._endpoint.knowledge_base_id - - text: str = turn_context.activity.text - user_name: str = turn_context.activity.from_property.name - - # Use the LogPersonalInformation flag to toggle logging PII data; text and username are common examples. - if self.log_personal_information: - if text: - properties[QnATelemetryConstants.question_property] = text - - if user_name: - properties[QnATelemetryConstants.username_property] = user_name - - # Fill in Qna Results (found or not). - if self._has_matched_answer_in_kb(query_results): - query_result = query_results[0] - - result_properties = { - QnATelemetryConstants.matched_question_property: json.dumps( - query_result.questions - ), - QnATelemetryConstants.question_id_property: str(query_result.id), - QnATelemetryConstants.answer_property: query_result.answer, - QnATelemetryConstants.article_found_property: "true", - } - properties.update(result_properties) - - metrics[QnATelemetryConstants.score_metric] = query_result.score - else: - no_match_properties = { - QnATelemetryConstants.matched_question_property: "No Qna Question matched", - QnATelemetryConstants.question_id_property: "No Qna Question Id matched", - QnATelemetryConstants.answer_property: "No Qna Answer matched", - QnATelemetryConstants.article_found_property: "false", - } - - properties.update(no_match_properties) - - # Additional Properties can override "stock" properties. - if telemetry_properties: - properties.update(telemetry_properties) - - # Additional Metrics can override "stock" metrics. - if telemetry_metrics: - metrics.update(telemetry_metrics) - - return EventData(properties=properties, metrics=metrics) - - def _validate_options(self, options: QnAMakerOptions): - if not options.score_threshold: - options.score_threshold = 0.3 - - if not options.top: - options.top = 1 - - if options.score_threshold < 0 or options.score_threshold > 1: - raise ValueError("Score threshold should be a value between 0 and 1") - - if options.top < 1: - raise ValueError("QnAMakerOptions.top should be an integer greater than 0") - - if not options.strict_filters: - options.strict_filters = [] - - if not options.timeout: - options.timeout = 100000 - - def _has_matched_answer_in_kb(self, query_results: [QueryResult]) -> bool: - if query_results: - if query_results[0].id != -1: - - return True - - return False +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +from typing import Dict, List, NamedTuple, Union +from aiohttp import ClientSession, ClientTimeout + +from botbuilder.schema import Activity +from botbuilder.core import BotTelemetryClient, NullTelemetryClient, TurnContext + +from .models import FeedbackRecord, QueryResult, QueryResults +from .utils import ( + ActiveLearningUtils, + GenerateAnswerUtils, + QnATelemetryConstants, + TrainUtils, +) +from .qnamaker_endpoint import QnAMakerEndpoint +from .qnamaker_options import QnAMakerOptions +from .qnamaker_telemetry_client import QnAMakerTelemetryClient + +from .. import __title__, __version__ + + +class EventData(NamedTuple): + properties: Dict[str, str] + metrics: Dict[str, float] + + +class QnAMaker(QnAMakerTelemetryClient): + """ + Class used to query a QnA Maker knowledge base for answers. + """ + + def __init__( + self, + endpoint: QnAMakerEndpoint, + options: QnAMakerOptions = None, + http_client: ClientSession = None, + telemetry_client: BotTelemetryClient = None, + log_personal_information: bool = None, + ): + super().__init__(log_personal_information, telemetry_client) + + if not isinstance(endpoint, QnAMakerEndpoint): + raise TypeError( + "QnAMaker.__init__(): endpoint is not an instance of QnAMakerEndpoint" + ) + + self._endpoint: str = endpoint + + opt = options or QnAMakerOptions() + self._validate_options(opt) + + instance_timeout = ClientTimeout(total=opt.timeout / 1000) + self._http_client = http_client or ClientSession(timeout=instance_timeout) + + self.telemetry_client: Union[ + BotTelemetryClient, NullTelemetryClient + ] = telemetry_client or NullTelemetryClient() + + self.log_personal_information = log_personal_information or False + + self._generate_answer_helper = GenerateAnswerUtils( + self.telemetry_client, self._endpoint, options, self._http_client + ) + self._active_learning_train_helper = TrainUtils( + self._endpoint, self._http_client + ) + + async def close(self): + await self._http_client.close() + + async def get_answers( + self, + context: TurnContext, + options: QnAMakerOptions = None, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, int] = None, + ) -> [QueryResult]: + """ + Generates answers from the knowledge base. + + return: + ------- + A list of answers for the user's query, sorted in decreasing order of ranking score. + + rtype: + ------ + List[QueryResult] + """ + result = await self.get_answers_raw( + context, options, telemetry_properties, telemetry_metrics + ) + + return result.answers + + async def get_answers_raw( + self, + context: TurnContext, + options: QnAMakerOptions = None, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, int] = None, + ) -> QueryResults: + """ + Generates raw answers from the knowledge base. + + return: + ------- + A list of answers for the user's query, sorted in decreasing order of ranking score. + + rtype: + ------ + QueryResults + """ + if not context: + raise TypeError("QnAMaker.get_answers(): context cannot be None.") + + if not isinstance(context.activity, Activity): + raise TypeError( + "QnAMaker.get_answers(): TurnContext's activity must be an Activity instance." + ) + + result = await self._generate_answer_helper.get_answers_raw(context, options) + + await self.on_qna_result( + result.answers, context, telemetry_properties, telemetry_metrics + ) + + return result + + def get_low_score_variation(self, query_result: QueryResult) -> List[QueryResult]: + """ + Filters the ambiguous question for active learning. + + Parameters: + ----------- + query_result: User query output. + + Return: + ------- + Filtered aray of ambigous questions. + """ + return ActiveLearningUtils.get_low_score_variation(query_result) + + async def call_train(self, feedback_records: List[FeedbackRecord]): + """ + Sends feedback to the knowledge base. + + Parameters: + ----------- + feedback_records + """ + return await self._active_learning_train_helper.call_train(feedback_records) + + async def on_qna_result( + self, + query_results: [QueryResult], + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + ): + event_data = await self.fill_qna_event( + query_results, turn_context, telemetry_properties, telemetry_metrics + ) + + # Track the event + self.telemetry_client.track_event( + name=QnATelemetryConstants.qna_message_event, + properties=event_data.properties, + measurements=event_data.metrics, + ) + + async def fill_qna_event( + self, + query_results: [QueryResult], + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + ) -> EventData: + """ + Fills the event properties and metrics for the QnaMessage event for telemetry. + + return: + ------- + A tuple of event data properties and metrics that will be sent to the + BotTelemetryClient.track_event() method for the QnAMessage event. + The properties and metrics returned the standard properties logged + with any properties passed from the get_answers() method. + + rtype: + ------ + EventData + """ + + properties: Dict[str, str] = dict() + metrics: Dict[str, float] = dict() + + properties[ + QnATelemetryConstants.knowledge_base_id_property + ] = self._endpoint.knowledge_base_id + + text: str = turn_context.activity.text + user_name: str = turn_context.activity.from_property.name + + # Use the LogPersonalInformation flag to toggle logging PII data; text and username are common examples. + if self.log_personal_information: + if text: + properties[QnATelemetryConstants.question_property] = text + + if user_name: + properties[QnATelemetryConstants.username_property] = user_name + + # Fill in Qna Results (found or not). + if self._has_matched_answer_in_kb(query_results): + query_result = query_results[0] + + result_properties = { + QnATelemetryConstants.matched_question_property: json.dumps( + query_result.questions + ), + QnATelemetryConstants.question_id_property: str(query_result.id), + QnATelemetryConstants.answer_property: query_result.answer, + QnATelemetryConstants.article_found_property: "true", + } + properties.update(result_properties) + + metrics[QnATelemetryConstants.score_metric] = query_result.score + else: + no_match_properties = { + QnATelemetryConstants.matched_question_property: "No Qna Question matched", + QnATelemetryConstants.question_id_property: "No Qna Question Id matched", + QnATelemetryConstants.answer_property: "No Qna Answer matched", + QnATelemetryConstants.article_found_property: "false", + } + + properties.update(no_match_properties) + + # Additional Properties can override "stock" properties. + if telemetry_properties: + properties.update(telemetry_properties) + + # Additional Metrics can override "stock" metrics. + if telemetry_metrics: + metrics.update(telemetry_metrics) + + return EventData(properties=properties, metrics=metrics) + + def _validate_options(self, options: QnAMakerOptions): + if not options.score_threshold: + options.score_threshold = 0.3 + + if not options.top: + options.top = 1 + + if options.score_threshold < 0 or options.score_threshold > 1: + raise ValueError("Score threshold should be a value between 0 and 1") + + if options.top < 1: + raise ValueError("QnAMakerOptions.top should be an integer greater than 0") + + if not options.strict_filters: + options.strict_filters = [] + + if not options.timeout: + options.timeout = 100000 + + def _has_matched_answer_in_kb(self, query_results: [QueryResult]) -> bool: + if query_results: + if query_results[0].id != -1: + + return True + + return False diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py index d93b1cd1f..95ae70b81 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -1,27 +1,27 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .models import Metadata, QnARequestContext -from .models.ranker_types import RankerTypes - -# figure out if 300 milliseconds is ok for python requests library...or 100000 -class QnAMakerOptions: - def __init__( - self, - score_threshold: float = 0.0, - timeout: int = 0, - top: int = 0, - strict_filters: [Metadata] = None, - context: [QnARequestContext] = None, - qna_id: int = None, - is_test: bool = False, - ranker_type: bool = RankerTypes.DEFAULT, - ): - self.score_threshold = score_threshold - self.timeout = timeout - self.top = top - self.strict_filters = strict_filters or [] - self.context = context - self.qna_id = qna_id - self.is_test = is_test - self.ranker_type = ranker_type +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .models import Metadata, QnARequestContext +from .models.ranker_types import RankerTypes + + +class QnAMakerOptions: + def __init__( + self, + score_threshold: float = 0.0, + timeout: int = 0, + top: int = 0, + strict_filters: [Metadata] = None, + context: [QnARequestContext] = None, + qna_id: int = None, + is_test: bool = False, + ranker_type: str = RankerTypes.DEFAULT, + ): + self.score_threshold = score_threshold + self.timeout = timeout + self.top = top + self.strict_filters = strict_filters or [] + self.context = context + self.qna_id = qna_id + self.is_test = is_test + self.ranker_type = ranker_type diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/__init__.py index 58d8575e0..e4669b2aa 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/__init__.py @@ -1,20 +1,22 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- - -from .active_learning_utils import ActiveLearningUtils -from .generate_answer_utils import GenerateAnswerUtils -from .http_request_utils import HttpRequestUtils -from .qna_telemetry_constants import QnATelemetryConstants -from .train_utils import TrainUtils - -__all__ = [ - "ActiveLearningUtils", - "GenerateAnswerUtils", - "HttpRequestUtils", - "QnATelemetryConstants", - "TrainUtils", -] +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .active_learning_utils import ActiveLearningUtils +from .generate_answer_utils import GenerateAnswerUtils +from .http_request_utils import HttpRequestUtils +from .qna_telemetry_constants import QnATelemetryConstants +from .train_utils import TrainUtils +from .qna_card_builder import QnACardBuilder + +__all__ = [ + "ActiveLearningUtils", + "GenerateAnswerUtils", + "HttpRequestUtils", + "QnATelemetryConstants", + "TrainUtils", + "QnACardBuilder", +] diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py index d550f8ad0..dcea385eb 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py @@ -1,95 +1,98 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import platform - -from aiohttp import ClientResponse, ClientSession, ClientTimeout - -from ... import __title__, __version__ - -from ..qnamaker_endpoint import QnAMakerEndpoint - - -class HttpRequestUtils: - """ HTTP request utils class. """ - - def __init__(self, http_client: ClientSession): - self._http_client = http_client - - async def execute_http_request( - self, - request_url: str, - payload_body: object, - endpoint: QnAMakerEndpoint, - timeout: float = None, - ) -> ClientResponse: - """ - Execute HTTP request. - - Parameters: - ----------- - - request_url: HTTP request URL. - - payload_body: HTTP request body. - - endpoint: QnA Maker endpoint details. - - timeout: Timeout for HTTP call (milliseconds). - """ - if not request_url: - raise TypeError( - "HttpRequestUtils.execute_http_request(): request_url cannot be None." - ) - - if not payload_body: - raise TypeError( - "HttpRequestUtils.execute_http_request(): question cannot be None." - ) - - if not endpoint: - raise TypeError( - "HttpRequestUtils.execute_http_request(): endpoint cannot be None." - ) - - serialized_payload_body = json.dumps(payload_body.serialize()) - - headers = self._get_headers(endpoint) - - if timeout: - # Convert miliseconds to seconds (as other BotBuilder SDKs accept timeout value in miliseconds) - # aiohttp.ClientSession units are in seconds - request_timeout = ClientTimeout(total=timeout / 1000) - - response: ClientResponse = await self._http_client.post( - request_url, - data=serialized_payload_body, - headers=headers, - timeout=request_timeout, - ) - else: - response: ClientResponse = await self._http_client.post( - request_url, data=serialized_payload_body, headers=headers - ) - - return response - - def _get_headers(self, endpoint: QnAMakerEndpoint): - headers = { - "Content-Type": "application/json", - "User-Agent": self._get_user_agent(), - "Authorization": f"EndpointKey {endpoint.endpoint_key}", - } - - return headers - - def _get_user_agent(self): - package_user_agent = f"{__title__}/{__version__}" - uname = platform.uname() - os_version = f"{uname.machine}-{uname.system}-{uname.version}" - py_version = f"Python,Version={platform.python_version()}" - platform_user_agent = f"({os_version}; {py_version})" - user_agent = f"{package_user_agent} {platform_user_agent}" - - return user_agent +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import platform + +from aiohttp import ClientResponse, ClientSession, ClientTimeout + +from ... import __title__, __version__ + +from ..qnamaker_endpoint import QnAMakerEndpoint + + +class HttpRequestUtils: + """ HTTP request utils class. """ + + def __init__(self, http_client: ClientSession): + self._http_client = http_client + + async def execute_http_request( + self, + request_url: str, + payload_body: object, + endpoint: QnAMakerEndpoint, + timeout: float = None, + ) -> ClientResponse: + """ + Execute HTTP request. + + Parameters: + ----------- + + request_url: HTTP request URL. + + payload_body: HTTP request body. + + endpoint: QnA Maker endpoint details. + + timeout: Timeout for HTTP call (milliseconds). + """ + if not request_url: + raise TypeError( + "HttpRequestUtils.execute_http_request(): request_url cannot be None." + ) + + if not payload_body: + raise TypeError( + "HttpRequestUtils.execute_http_request(): question cannot be None." + ) + + if not endpoint: + raise TypeError( + "HttpRequestUtils.execute_http_request(): endpoint cannot be None." + ) + + serialized_payload_body = json.dumps(payload_body.serialize()) + + # at least for call_train, QnAMaker didn't like values with a leading space. Odd. + serialized_payload_body = serialized_payload_body.replace(": ", ":") + + headers = self._get_headers(endpoint) + + if timeout: + # Convert miliseconds to seconds (as other BotBuilder SDKs accept timeout value in miliseconds) + # aiohttp.ClientSession units are in seconds + request_timeout = ClientTimeout(total=timeout / 1000) + + response: ClientResponse = await self._http_client.post( + request_url, + data=serialized_payload_body, + headers=headers, + timeout=request_timeout, + ) + else: + response: ClientResponse = await self._http_client.post( + request_url, data=serialized_payload_body, headers=headers + ) + + return response + + def _get_headers(self, endpoint: QnAMakerEndpoint): + headers = { + "Content-Type": "application/json", + "User-Agent": self._get_user_agent(), + "Authorization": f"EndpointKey {endpoint.endpoint_key}", + } + + return headers + + def _get_user_agent(self): + package_user_agent = f"{__title__}/{__version__}" + uname = platform.uname() + os_version = f"{uname.machine}-{uname.system}-{uname.version}" + py_version = f"Python,Version={platform.python_version()}" + platform_user_agent = f"({os_version}; {py_version})" + user_agent = f"{package_user_agent} {platform_user_agent}" + + return user_agent diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py new file mode 100644 index 000000000..e450b4ef2 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import CardFactory +from botbuilder.schema import Activity, ActivityTypes, CardAction, HeroCard + +from ..models import QueryResult + + +class QnACardBuilder: + """ + Message activity card builder for QnAMaker dialogs. + """ + + @staticmethod + def get_suggestions_card( + suggestions: [str], card_title: str, card_no_match: str + ) -> Activity: + """ + Get active learning suggestions card. + """ + + if not suggestions: + raise TypeError("suggestions list is required") + + if not card_title: + raise TypeError("card_title is required") + + if not card_no_match: + raise TypeError("card_no_match is required") + + # Add all suggestions + button_list = [ + CardAction(value=suggestion, type="imBack", title=suggestion) + for suggestion in suggestions + ] + + # Add No match text + button_list.append( + CardAction(value=card_no_match, type="imBack", title=card_no_match) + ) + + attachment = CardFactory.hero_card(HeroCard(buttons=button_list)) + + return Activity( + type=ActivityTypes.message, text=card_title, attachments=[attachment] + ) + + @staticmethod + def get_qna_prompts_card(result: QueryResult, card_no_match_text: str) -> Activity: + """ + Get active learning suggestions card. + """ + + if not result: + raise TypeError("result is required") + + if not card_no_match_text: + raise TypeError("card_no_match_text is required") + + # Add all prompts + button_list = [ + CardAction( + value=prompt.display_text, type="imBack", title=prompt.display_text, + ) + for prompt in result.context.prompts + ] + + # Add No match text + button_list.append( + CardAction( + value=card_no_match_text, type="imBack", title=card_no_match_text, + ) + ) + + attachment = CardFactory.hero_card(HeroCard(buttons=button_list)) + + return Activity( + type=ActivityTypes.message, text=result.answer, attachments=[attachment] + ) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index cae752861..e56111577 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -757,7 +757,7 @@ async def test_should_answer_with_high_score_provided_context(self): qna = QnAMaker(QnaApplicationTest.tests_endpoint) question: str = "where can I buy?" context = QnARequestContext( - previous_qna_id=5, prvious_user_query="how do I clean the stove?" + previous_qna_id=5, previous_user_query="how do I clean the stove?" ) options = QnAMakerOptions(top=2, qna_id=55, context=context) turn_context = QnaApplicationTest._get_context(question, TestAdapter()) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index bf2c8ae32..fd2a74a76 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -22,6 +22,7 @@ from .prompts import * from .choices import * from .skills import * +from .object_path import ObjectPath __all__ = [ "ComponentDialog", @@ -48,5 +49,6 @@ "PromptOptions", "TextPrompt", "DialogExtensions", + "ObjectPath", "__version__", ] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py new file mode 100644 index 000000000..6e6435582 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py @@ -0,0 +1,304 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import copy +from typing import Union, Callable + + +class ObjectPath: + """ + Helper methods for working with json objects. + """ + + @staticmethod + def assign(start_object, overlay_object, default: Union[Callable, object] = None): + """ + Creates a new object by overlaying values in start_object with non-null values from overlay_object. + + :param start_object: dict or typed object, the target object to set values on + :param overlay_object: dict or typed object, the item to overlay values form + :param default: Provides a default object if both source and overlay are None + :return: A copy of start_object, with values from overlay_object + """ + if start_object and overlay_object: + merged = copy.deepcopy(start_object) + + def merge(target: dict, source: dict): + key_set = set(target).union(set(source)) + + for key in key_set: + target_value = target.get(key) + source_value = source.get(key) + + # skip empty overlay items + if source_value: + if isinstance(source_value, dict): + # merge dictionaries + if not target_value: + target[key] = copy.deepcopy(source_value) + else: + merge(target_value, source_value) + elif not hasattr(source_value, "__dict__"): + # simple type. just copy it. + target[key] = copy.copy(source_value) + elif not target_value: + # the target doesn't have the value, but + # the overlay does. just copy it. + target[key] = copy.deepcopy(source_value) + else: + # recursive class copy + merge(target_value.__dict__, source_value.__dict__) + + target_dict = merged if isinstance(merged, dict) else merged.__dict__ + overlay_dict = ( + overlay_object + if isinstance(overlay_object, dict) + else overlay_object.__dict__ + ) + merge(target_dict, overlay_dict) + + return merged + + if overlay_object: + return copy.deepcopy(overlay_object) + + if start_object: + return start_object + if default: + return default() if callable(default) else copy.deepcopy(default) + return None + + @staticmethod + def set_path_value(obj, path: str, value: object): + """ + Given an object evaluate a path to set the value. + """ + + segments = ObjectPath.try_resolve_path(obj, path) + if not segments: + return + + current = obj + for i in range(len(segments) - 1): + segment = segments[i] + if ObjectPath.is_int(segment): + index = int(segment) + next_obj = current[index] + if not next_obj and len(current) <= index: + # Expand list to index + current += [None] * ((index + 1) - len(current)) + next_obj = current[index] + else: + next_obj = ObjectPath.__get_object_property(current, segment) + if not next_obj: + # Create object or list based on next segment + next_segment = segments[i + 1] + if not ObjectPath.is_int(next_segment): + ObjectPath.__set_object_segment(current, segment, {}) + else: + ObjectPath.__set_object_segment(current, segment, []) + + next_obj = ObjectPath.__get_object_property(current, segment) + + current = next_obj + + last_segment = segments[-1] + ObjectPath.__set_object_segment(current, last_segment, value) + + @staticmethod + def get_path_value( + obj, path: str, default: Union[Callable, object] = None + ) -> object: + """ + Get the value for a path relative to an object. + """ + + value = ObjectPath.try_get_path_value(obj, path) + if value: + return value + + if default is None: + raise KeyError(f"Key {path} not found") + return default() if callable(default) else copy.deepcopy(default) + + @staticmethod + def has_value(obj, path: str) -> bool: + """ + Does an object have a subpath. + """ + return ObjectPath.try_get_path_value(obj, path) is not None + + @staticmethod + def remove_path_value(obj, path: str): + """ + Remove path from object. + """ + + segments = ObjectPath.try_resolve_path(obj, path) + if not segments: + return + + current = obj + for i in range(len(segments) - 1): + segment = segments[i] + current = ObjectPath.__resolve_segment(current, segment) + if not current: + return + + if current: + last_segment = segments[-1] + if ObjectPath.is_int(last_segment): + current[int(last_segment)] = None + else: + current.pop(last_segment) + + @staticmethod + def try_get_path_value(obj, path: str) -> object: + """ + Get the value for a path relative to an object. + """ + + if not obj: + return None + + if path is None: + return None + + if not path: + return obj + + segments = ObjectPath.try_resolve_path(obj, path) + if not segments: + return None + + result = ObjectPath.__resolve_segments(obj, segments) + if not result: + return None + + return result + + @staticmethod + def __set_object_segment(obj, segment, value): + val = ObjectPath.__get_normalized_value(value) + + if ObjectPath.is_int(segment): + # the target is an list + index = int(segment) + + # size the list if needed + obj += [None] * ((index + 1) - len(obj)) + + obj[index] = val + return + + # the target is a dictionary + obj[segment] = val + + @staticmethod + def __get_normalized_value(value): + return value + + @staticmethod + def try_resolve_path(obj, property_path: str, evaluate: bool = False) -> []: + so_far = [] + first = property_path[0] if property_path else " " + if first in ("'", '"'): + if not property_path.endswith(first): + return None + + so_far.append(property_path[1 : len(property_path) - 2]) + elif ObjectPath.is_int(property_path): + so_far.append(int(property_path)) + else: + start = 0 + i = 0 + + def emit(): + nonlocal start, i + segment = property_path[start:i] + if segment: + so_far.append(segment) + start = i + 1 + + while i < len(property_path): + char = property_path[i] + if char in (".", "["): + emit() + + if char == "[": + nesting = 1 + i += 1 + while i < len(property_path): + char = property_path[i] + if char == "[": + nesting += 1 + elif char == "]": + nesting -= 1 + if nesting == 0: + break + i += 1 + + if nesting > 0: + return None + + expr = property_path[start:i] + start = i + 1 + indexer = ObjectPath.try_resolve_path(obj, expr, True) + if not indexer: + return None + + result = indexer[0] + if ObjectPath.is_int(result): + so_far.append(int(result)) + else: + so_far.append(result) + + i += 1 + + emit() + + if evaluate: + result = ObjectPath.__resolve_segments(obj, so_far) + if not result: + return None + + so_far.clear() + so_far.append(result) + + return so_far + + @staticmethod + def __resolve_segments(current, segments: []) -> object: + result = current + + for segment in segments: + result = ObjectPath.__resolve_segment(result, segment) + if not result: + return None + + return result + + @staticmethod + def __resolve_segment(current, segment) -> object: + if current: + if ObjectPath.is_int(segment): + current = current[int(segment)] + else: + current = ObjectPath.__get_object_property(current, segment) + + return current + + @staticmethod + def __get_object_property(obj, property_name: str): + # doing a case insensitive search + property_name_lower = property_name.lower() + matching = [obj[key] for key in obj if key.lower() == property_name_lower] + return matching[0] if matching else None + + @staticmethod + def is_int(value: str) -> bool: + try: + int(value) + return True + except ValueError: + return False diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index fec9928c2..e9dd4585d 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -5,4 +5,4 @@ botbuilder-core>=4.7.1 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8 -aiounittest==1.3.0 \ No newline at end of file +aiounittest==1.3.0 diff --git a/libraries/botbuilder-dialogs/tests/test_object_path.py b/libraries/botbuilder-dialogs/tests/test_object_path.py new file mode 100644 index 000000000..fe72be919 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_object_path.py @@ -0,0 +1,254 @@ +import aiounittest + +from botbuilder.dialogs import ObjectPath + + +class Location: + def __init__(self, lat: float = None, long: float = None): + self.lat = lat + self.long = long + + +class Options: + def __init__( + self, + first_name: str = None, + last_name: str = None, + age: int = None, + boolean: bool = None, + dictionary: dict = None, + location: Location = None, + ): + self.first_name = first_name + self.last_name = last_name + self.age = age + self.boolean = boolean + self.dictionary = dictionary + self.location = location + + +class ObjectPathTests(aiounittest.AsyncTestCase): + async def test_typed_only_default(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + ) + + overlay = Options() + + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == default_options.last_name + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + async def test_typed_only_overlay(self): + default_options = Options() + + overlay = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + ) + + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == overlay.first_name + assert result.age == overlay.age + assert result.boolean == overlay.boolean + assert result.location.lat == overlay.location.lat + assert result.location.long == overlay.location.long + + async def test_typed_full_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + dictionary={"one": 1, "two": 2} + ) + + overlay = Options( + last_name="Grant", + first_name="Eddit", + age=32, + location=Location( + lat=2.2312312, + long=2.234234, + ), + dictionary={"one": 99, "three": 3} + ) + + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == overlay.first_name + assert result.age == overlay.age + assert result.boolean == overlay.boolean + assert result.location.lat == overlay.location.lat + assert result.location.long == overlay.location.long + assert "one" in result.dictionary + assert 99 == result.dictionary["one"] + assert "two" in result.dictionary + assert "three" in result.dictionary + + async def test_typed_partial_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + ) + + overlay = Options( + last_name="Grant", + ) + + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + async def test_typed_no_target(self): + overlay = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + ) + + result = ObjectPath.assign(None, overlay) + assert result.last_name == overlay.last_name + assert result.first_name == overlay.first_name + assert result.age == overlay.age + assert result.boolean == overlay.boolean + assert result.location.lat == overlay.location.lat + assert result.location.long == overlay.location.long + + async def test_typed_no_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + ) + + result = ObjectPath.assign(default_options, None) + assert result.last_name == default_options.last_name + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + async def test_no_target_or_overlay(self): + result = ObjectPath.assign(None, None, Options) + assert result + + async def test_dict_partial_overlay(self): + default_options = { + "last_name": "Smith", + "first_name": "Fred", + "age": 22, + "location": Location( + lat=1.2312312, + long=3.234234, + ), + } + + overlay = { + "last_name": "Grant", + } + + result = ObjectPath.assign(default_options, overlay) + assert result["last_name"] == overlay["last_name"] + assert result["first_name"] == default_options["first_name"] + assert result["age"] == default_options["age"] + assert result["location"].lat == default_options["location"].lat + assert result["location"].long == default_options["location"].long + + async def test_dict_to_typed_overlay(self): + default_options = Options( + last_name="Smith", + first_name="Fred", + age=22, + location=Location( + lat=1.2312312, + long=3.234234, + ), + ) + + overlay = { + "last_name": "Grant", + } + + result = ObjectPath.assign(default_options, overlay) + assert result.last_name == overlay["last_name"] + assert result.first_name == default_options.first_name + assert result.age == default_options.age + assert result.boolean == default_options.boolean + assert result.location.lat == default_options.location.lat + assert result.location.long == default_options.location.long + + async def test_set_value(self): + test = {} + ObjectPath.set_path_value(test, "x.y.z", 15) + ObjectPath.set_path_value(test, "x.p", "hello") + ObjectPath.set_path_value(test, "foo", {"Bar": 15, "Blat": "yo"}) + ObjectPath.set_path_value(test, "x.a[1]", "yabba") + ObjectPath.set_path_value(test, "x.a[0]", "dabba") + ObjectPath.set_path_value(test, "null", None) + + assert 15 == ObjectPath.get_path_value(test, "x.y.z") + assert "hello" == ObjectPath.get_path_value(test, "x.p") + assert 15 == ObjectPath.get_path_value(test, "foo.bar") + + assert not ObjectPath.try_get_path_value(test, "foo.Blatxxx") + assert "yabba" == ObjectPath.try_get_path_value(test, "x.a[1]") + assert "dabba" == ObjectPath.try_get_path_value(test, "x.a[0]") + + assert not ObjectPath.try_get_path_value(test, "null") + + async def test_remove_path_value(self): + test = {} + ObjectPath.set_path_value(test, "x.y.z", 15) + ObjectPath.set_path_value(test, "x.p", "hello") + ObjectPath.set_path_value(test, "foo", {"Bar": 15, "Blat": "yo"}) + ObjectPath.set_path_value(test, "x.a[1]", "yabba") + ObjectPath.set_path_value(test, "x.a[0]", "dabba") + + ObjectPath.remove_path_value(test, "x.y.z") + with self.assertRaises(KeyError): + ObjectPath.get_path_value(test, "x.y.z") + + assert 99 == ObjectPath.get_path_value(test, "x.y.z", 99) + + ObjectPath.remove_path_value(test, "x.a[1]") + assert not ObjectPath.try_get_path_value(test, "x.a[1]") + + assert "dabba" == ObjectPath.try_get_path_value(test, "x.a[0]") From 1bc0f37ea340ae4af6b787294503ce970774977a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 31 Mar 2020 10:18:22 -0500 Subject: [PATCH 364/616] black and pylint corrections --- .../ai/qna/models/qna_request_context.py | 4 +- .../tests/test_object_path.py | 71 ++++++------------- 2 files changed, 24 insertions(+), 51 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py index 5292b6d33..dcac807a1 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py @@ -15,7 +15,9 @@ class QnARequestContext(Model): "previous_user_query": {"key": "previousUserQuery", "type": "string"}, } - def __init__(self, previous_qna_id: int = None, previous_user_query: str = None, **kwargs): + def __init__( + self, previous_qna_id: int = None, previous_user_query: str = None, **kwargs + ): """ Parameters: ----------- diff --git a/libraries/botbuilder-dialogs/tests/test_object_path.py b/libraries/botbuilder-dialogs/tests/test_object_path.py index fe72be919..447f52893 100644 --- a/libraries/botbuilder-dialogs/tests/test_object_path.py +++ b/libraries/botbuilder-dialogs/tests/test_object_path.py @@ -33,10 +33,7 @@ async def test_typed_only_default(self): last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), + location=Location(lat=1.2312312, long=3.234234,), ) overlay = Options() @@ -56,10 +53,7 @@ async def test_typed_only_overlay(self): last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), + location=Location(lat=1.2312312, long=3.234234,), ) result = ObjectPath.assign(default_options, overlay) @@ -75,22 +69,16 @@ async def test_typed_full_overlay(self): last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), - dictionary={"one": 1, "two": 2} + location=Location(lat=1.2312312, long=3.234234,), + dictionary={"one": 1, "two": 2}, ) overlay = Options( last_name="Grant", first_name="Eddit", age=32, - location=Location( - lat=2.2312312, - long=2.234234, - ), - dictionary={"one": 99, "three": 3} + location=Location(lat=2.2312312, long=2.234234,), + dictionary={"one": 99, "three": 3}, ) result = ObjectPath.assign(default_options, overlay) @@ -101,7 +89,7 @@ async def test_typed_full_overlay(self): assert result.location.lat == overlay.location.lat assert result.location.long == overlay.location.long assert "one" in result.dictionary - assert 99 == result.dictionary["one"] + assert result.dictionary["one"] == 99 assert "two" in result.dictionary assert "three" in result.dictionary @@ -110,15 +98,10 @@ async def test_typed_partial_overlay(self): last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), + location=Location(lat=1.2312312, long=3.234234,), ) - overlay = Options( - last_name="Grant", - ) + overlay = Options(last_name="Grant",) result = ObjectPath.assign(default_options, overlay) assert result.last_name == overlay.last_name @@ -133,10 +116,7 @@ async def test_typed_no_target(self): last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), + location=Location(lat=1.2312312, long=3.234234,), ) result = ObjectPath.assign(None, overlay) @@ -152,10 +132,7 @@ async def test_typed_no_overlay(self): last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), + location=Location(lat=1.2312312, long=3.234234,), ) result = ObjectPath.assign(default_options, None) @@ -175,10 +152,7 @@ async def test_dict_partial_overlay(self): "last_name": "Smith", "first_name": "Fred", "age": 22, - "location": Location( - lat=1.2312312, - long=3.234234, - ), + "location": Location(lat=1.2312312, long=3.234234,), } overlay = { @@ -191,16 +165,13 @@ async def test_dict_partial_overlay(self): assert result["age"] == default_options["age"] assert result["location"].lat == default_options["location"].lat assert result["location"].long == default_options["location"].long - + async def test_dict_to_typed_overlay(self): default_options = Options( last_name="Smith", first_name="Fred", age=22, - location=Location( - lat=1.2312312, - long=3.234234, - ), + location=Location(lat=1.2312312, long=3.234234,), ) overlay = { @@ -224,13 +195,13 @@ async def test_set_value(self): ObjectPath.set_path_value(test, "x.a[0]", "dabba") ObjectPath.set_path_value(test, "null", None) - assert 15 == ObjectPath.get_path_value(test, "x.y.z") - assert "hello" == ObjectPath.get_path_value(test, "x.p") - assert 15 == ObjectPath.get_path_value(test, "foo.bar") + assert ObjectPath.get_path_value(test, "x.y.z") == 15 + assert ObjectPath.get_path_value(test, "x.p") == "hello" + assert ObjectPath.get_path_value(test, "foo.bar") == 15 assert not ObjectPath.try_get_path_value(test, "foo.Blatxxx") - assert "yabba" == ObjectPath.try_get_path_value(test, "x.a[1]") - assert "dabba" == ObjectPath.try_get_path_value(test, "x.a[0]") + assert ObjectPath.try_get_path_value(test, "x.a[1]") == "yabba" + assert ObjectPath.try_get_path_value(test, "x.a[0]") == "dabba" assert not ObjectPath.try_get_path_value(test, "null") @@ -246,9 +217,9 @@ async def test_remove_path_value(self): with self.assertRaises(KeyError): ObjectPath.get_path_value(test, "x.y.z") - assert 99 == ObjectPath.get_path_value(test, "x.y.z", 99) + assert ObjectPath.get_path_value(test, "x.y.z", 99) == 99 ObjectPath.remove_path_value(test, "x.a[1]") assert not ObjectPath.try_get_path_value(test, "x.a[1]") - assert "dabba" == ObjectPath.try_get_path_value(test, "x.a[0]") + assert ObjectPath.try_get_path_value(test, "x.a[0]") == "dabba" From 412b5b64062c9c55e870c8e5d40cef19911f216f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 31 Mar 2020 11:10:07 -0500 Subject: [PATCH 365/616] Removed hack fix --- .../botbuilder/ai/qna/utils/http_request_utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py index dcea385eb..47290c491 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py @@ -55,9 +55,6 @@ async def execute_http_request( serialized_payload_body = json.dumps(payload_body.serialize()) - # at least for call_train, QnAMaker didn't like values with a leading space. Odd. - serialized_payload_body = serialized_payload_body.replace(": ", ":") - headers = self._get_headers(endpoint) if timeout: From e67a44319c6cfd4613486ecd9ea05eaeaabad05b Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 31 Mar 2020 12:33:44 -0500 Subject: [PATCH 366/616] Corrected low score variation handling --- .../botbuilder/ai/qna/dialogs/qnamaker_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py index c5cfd3c10..a5a7232d6 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -72,7 +72,7 @@ def __init__( self.card_no_match_response = card_no_match_response self.strict_filters = strict_filters - self.maximum_score_for_low_score_variation = 0.99 + self.maximum_score_for_low_score_variation = 0.95 self.add_step(self.__call_generate_answer) self.add_step(self.__call_train) @@ -204,7 +204,7 @@ async def __call_generate_answer(self, step_context: WaterfallStepContext): ): # Get filtered list of the response that support low score variation criteria. response.answers = qna_client.get_low_score_variation(response.answers) - if response.answers: + if len(response.answers) > 1: suggested_questions = [qna.questions[0] for qna in response.answers] message = QnACardBuilder.get_suggestions_card( suggested_questions, From b3f36f87aeb29950de627dc361600ad8a93670a5 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Apr 2020 07:45:41 -0500 Subject: [PATCH 367/616] Added docstr to QnAMakerDialog (from C#) --- .../ai/qna/dialogs/qnamaker_dialog.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py index a5a7232d6..ef2314177 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -27,19 +27,64 @@ class QnAMakerDialog(WaterfallDialog): + """ + A dialog that supports multi-step and adaptive-learning QnA Maker services. + + .. remarks:: + An instance of this class targets a specific QnA Maker knowledge base. + It supports knowledge bases that include follow-up prompt and active learning features. + """ + KEY_QNA_CONTEXT_DATA = "qnaContextData" + """ + The path for storing and retrieving QnA Maker context data. + + .. remarks: + This represents context about the current or previous call to QnA Maker. + It is stored within the current step's :class:'botbuilder.dialogs.WaterfallStepContext'. + It supports QnA Maker's follow-up prompt and active learning features. + """ + KEY_PREVIOUS_QNA_ID = "prevQnAId" + """ + The path for storing and retrieving the previous question ID. + + .. remarks: + This represents the QnA question ID from the previous turn. + It is stored within the current step's :class:'botbuilder.dialogs.WaterfallStepContext'. + It supports QnA Maker's follow-up prompt and active learning features. + """ + KEY_OPTIONS = "options" + """ + The path for storing and retrieving the options for this instance of the dialog. + + .. remarks: + This includes the options with which the dialog was started and options + expected by the QnA Maker service. + It is stored within the current step's :class:'botbuilder.dialogs.WaterfallStepContext'. + It supports QnA Maker and the dialog system. + """ # Dialog Options parameters DEFAULT_THRESHOLD = 0.3 + """ The default threshold for answers returned, based on score. """ + DEFAULT_TOP_N = 3 + """ The default maximum number of answers to be returned for the question. """ + DEFAULT_NO_ANSWER = "No QnAMaker answers found." + """ The default no answer text sent to the user. """ # Card parameters DEFAULT_CARD_TITLE = "Did you mean:" + """ The default active learning card title. """ + DEFAULT_CARD_NO_MATCH_TEXT = "None of the above." + """ The default active learning no match text. """ + DEFAULT_CARD_NO_MATCH_RESPONSE = "Thanks for the feedback." + """ The default active learning response text. """ # Value Properties PROPERTY_CURRENT_QUERY = "currentQuery" @@ -59,6 +104,26 @@ def __init__( strict_filters: [Metadata] = None, dialog_id: str = "QnAMakerDialog", ): + """ + Initializes a new instance of the QnAMakerDialog class. + + :param knowledgebase_id: The ID of the QnA Maker knowledge base to query. + :param endpoint_key: The QnA Maker endpoint key to use to query the knowledge base. + :param hostname: The QnA Maker host URL for the knowledge base, starting with "https://" and + ending with "/qnamaker". + :param no_answer: The activity to send the user when QnA Maker does not find an answer. + :param threshold: The threshold for answers returned, based on score. + :param active_learning_card_title: The card title to use when showing active learning options + to the user, if active learning is enabled. + :param card_no_match_text: The button text to use with active learning options, + allowing a user to indicate none of the options are applicable. + :param top: The maximum number of answers to return from the knowledge base. + :param card_no_match_response: The activity to send the user if they select the no match option + on an active learning card. + :param strict_filters: QnA Maker metadata with which to filter or boost queries to the + knowledge base; or null to apply none. + :param dialog_id: The ID of this dialog. + """ super().__init__(dialog_id) self.knowledgebase_id = knowledgebase_id @@ -82,6 +147,17 @@ def __init__( async def begin_dialog( self, dialog_context: DialogContext, options: object = None ) -> DialogTurnResult: + """ + Called when the dialog is started and pushed onto the dialog stack. + + .. remarks: + If the task is successful, the result indicates whether the dialog is still + active after the turn has been processed by the dialog. + + :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation. + :param options: Optional, initial information to pass to the dialog. + """ + if not dialog_context: raise TypeError("DialogContext is required") @@ -109,6 +185,12 @@ async def begin_dialog( return await super().begin_dialog(dialog_context, dialog_options) def _get_qnamaker_client(self, dialog_context: DialogContext) -> QnAMaker: + """ + Gets a :class:'botbuilder.ai.qna.QnAMaker' to use to access the QnA Maker knowledge base. + + :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation. + """ + endpoint = QnAMakerEndpoint( endpoint_key=self.endpoint_key, host=self.hostname, @@ -122,6 +204,12 @@ def _get_qnamaker_client(self, dialog_context: DialogContext) -> QnAMaker: def _get_qnamaker_options( # pylint: disable=unused-argument self, dialog_context: DialogContext ) -> QnAMakerOptions: + """ + Gets the options for the QnAMaker client that the dialog will use to query the knowledge base. + + :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation. + """ + return QnAMakerOptions( score_threshold=self.threshold, strict_filters=self.strict_filters, @@ -135,6 +223,12 @@ def _get_qnamaker_options( # pylint: disable=unused-argument def _get_qna_response_options( # pylint: disable=unused-argument self, dialog_context: DialogContext ) -> QnADialogResponseOptions: + """ + Gets the options the dialog will use to display query results to the user. + + :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation. + """ + return QnADialogResponseOptions( no_answer=self.no_answer, active_learning_card_title=self.active_learning_card_title From 5eda098945491b458714734e3c3cdf946bb4a78e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Apr 2020 08:05:23 -0500 Subject: [PATCH 368/616] Restructured manual tests to under the 'tests' folder. --- scenarios/link-unfurling/config.py | 13 - scenarios/mentions/README.md | 30 - scenarios/mentions/config.py | 13 - scenarios/message-reactions/README.md | 30 - scenarios/roster/config.py | 13 - .../config.py | 13 - .../101.corebot-bert-bidaf/Dockerfile_bot | 70 +- .../Dockerfile_model_runtime | 56 +- .../101.corebot-bert-bidaf/NOTICE.md | 0 .../101.corebot-bert-bidaf/README.md | 696 ++++++++-------- .../bot/bots/__init__.py | 16 +- .../bot/bots/dialog_and_welcome_bot.py | 0 .../bot/bots/dialog_bot.py | 0 .../bot/bots/resources/welcomeCard.json | 0 .../101.corebot-bert-bidaf/bot/config.py | 52 +- .../bot/dialogs/__init__.py | 0 .../bot/dialogs/booking_dialog.py | 0 .../bot/dialogs/cancel_and_help_dialog.py | 0 .../bot/dialogs/date_resolver_dialog.py | 0 .../bot/dialogs/main_dialog.py | 0 .../bot/helpers/__init__.py | 0 .../bot/helpers/activity_helper.py | 0 .../bot/helpers/dialog_helper.py | 0 .../101.corebot-bert-bidaf/bot/main.py | 0 .../bot}/requirements.txt | 0 .../docker/docker-compose.yml | 40 +- .../media/jupyter_lab_bert_complete.PNG | Bin .../media/jupyter_lab_bert_runtime.PNG | Bin .../media/jupyter_lab_bert_train.PNG | Bin .../media/jupyter_lab_bidaf_runtime.PNG | Bin .../media/jupyter_lab_model_nav.PNG | Bin .../media/jupyter_lab_run_all_cells.PNG | Bin .../media/jupyter_lab_select_kernel.PNG | Bin .../model/model_corebot101/about.py | 28 +- .../model_corebot101/bert/common/__init__.py | 16 +- .../model_corebot101/bert/common/bert_util.py | 312 ++++---- .../bert/common/input_example.py | 46 +- .../bert/common/input_features.py | 24 +- .../bert/model_runtime/__init__.py | 12 +- .../bert/model_runtime/bert_model_runtime.py | 244 +++--- .../model_corebot101/bert/requirements.txt | 6 +- .../model_corebot101/bert/train/__init__.py | 18 +- .../model/model_corebot101/bert/train/args.py | 116 +-- .../bert/train/bert_train_eval.py | 750 +++++++++--------- .../bert/train/flight_booking_processor.py | 102 +-- .../bert/training_data/FlightBooking.json | 480 +++++------ .../bidaf/model_runtime/__init__.py | 12 +- .../model_runtime/bidaf_model_runtime.py | 202 ++--- .../model_corebot101/bidaf/requirements.txt | 6 +- .../model/model_corebot101/booking_details.py | 0 .../model/model_corebot101/language_helper.py | 424 +++++----- .../101.corebot-bert-bidaf/model/setup.py | 102 +-- .../model_runtime_svc_corebot101/__init__.py | 12 +- .../model_runtime_svc_corebot101/about.py | 28 +- .../docker_init.py | 38 +- .../handlers/__init__.py | 4 +- .../handlers/model_handler.py | 104 +-- .../model_runtime_svc_corebot101/main.py | 218 ++--- .../model_cache.py | 134 ++-- .../model_runtime_svc/setup.py | 84 +- .../notebooks/bert_model_runtime.ipynb | 646 +++++++-------- .../notebooks/bert_train.ipynb | 562 ++++++------- .../notebooks/bidaf_model_runtime.ipynb | 456 +++++------ .../notebooks/model_runtime.ipynb | 412 +++++----- .../101.corebot-bert-bidaf}/requirements.txt | 82 +- .../sso/child/adapter_with_error_handler.py | 0 .../experimental/sso/child/app.py | 0 .../experimental/sso/child/bots/__init__.py | 0 .../experimental/sso/child/bots/child_bot.py | 0 .../experimental/sso/child/config.py | 0 .../sso/child/dialogs/__init__.py | 0 .../sso/child/dialogs/main_dialog.py | 0 .../sso/child}/helpers/__init__.py | 0 .../sso/child}/helpers/dialog_helper.py | 0 .../sso/parent/ReadMeForSSOTesting.md | 0 .../experimental/sso/parent/app.py | 0 .../experimental/sso/parent/bots/__init__.py | 0 .../sso/parent/bots/parent_bot.py | 0 .../experimental/sso/parent/config.py | 0 .../sso/parent/dialogs/__init__.py | 0 .../sso/parent/dialogs/main_dialog.py | 0 .../sso/parent}/helpers/__init__.py | 0 .../sso/parent}/helpers/dialog_helper.py | 0 .../experimental/sso/parent/skill_client.py | 0 .../experimental/test-protocol/app.py | 0 .../experimental/test-protocol/config.py | 36 +- .../test-protocol/routing_handler.py | 268 +++---- .../test-protocol/routing_id_factory.py | 44 +- .../skills}/skills-buffered/child/app.py | 0 .../skills-buffered/child/bots/__init__.py | 0 .../skills-buffered/child/bots/child_bot.py | 0 .../skills}/skills-buffered/child/config.py | 0 .../skills-buffered/child/requirements.txt | 0 .../skills}/skills-buffered/parent/app.py | 0 .../skills-buffered/parent/bots/__init__.py | 0 .../skills-buffered/parent/bots/parent_bot.py | 0 .../skills}/skills-buffered/parent/config.py | 0 .../skills-buffered/parent/requirements.txt | 0 .../parent/skill_conversation_id_factory.py | 0 .../authentication-bot}/README.md | 60 +- .../authentication-bot/app.py | 196 ++--- .../authentication-bot/bots/__init__.py | 12 +- .../authentication-bot/bots/auth_bot.py | 84 +- .../authentication-bot/bots/dialog_bot.py | 58 +- .../authentication-bot/config.py | 32 +- .../authentication-bot/dialogs/__init__.py | 14 +- .../dialogs/logout_dialog.py | 94 +-- .../authentication-bot/dialogs/main_dialog.py | 144 ++-- .../authentication-bot}/helpers/__init__.py | 12 +- .../helpers/dialog_helper.py | 38 +- .../authentication-bot/requirements.txt | 0 .../simple-child-bot}/README.md | 60 +- .../simple-bot-to-bot/simple-child-bot/app.py | 170 ++-- .../simple-child-bot/bots/__init__.py | 12 +- .../simple-child-bot/bots/echo_bot.py | 54 +- .../simple-child-bot/config.py | 30 +- .../simple-child-bot/requirements.txt | 0 .../simple-bot-to-bot/simple-root-bot/app.py | 0 .../simple-root-bot/bots/__init__.py | 8 +- .../simple-root-bot/bots/root_bot.py | 0 .../simple-root-bot/config.py | 64 +- .../simple-root-bot/middleware/__init__.py | 8 +- .../middleware/dummy_middleware.py | 64 +- .../app.py | 0 .../bots/__init__.py | 0 ...ased_messaging_extension_fetch_task_bot.py | 0 .../config.py | 0 .../example_data.py | 0 .../requirements.txt | 0 .../teams_app_manifest/icon-color.png | Bin .../teams_app_manifest/icon-outline.png | Bin .../teams_app_manifest/manifest.json | 0 .../action-based-messaging-extension/app.py | 178 ++--- .../bots/__init__.py | 12 +- .../teams_messaging_extensions_action_bot.py | 184 ++--- .../config.py | 26 +- .../requirements.txt | 0 .../teams_app_manifest/icon-color.png | Bin .../teams_app_manifest/icon-outline.png | Bin .../teams_app_manifest/manifest.json | 156 ++-- .../activity-update-and-delete/README.md | 60 +- .../activity-update-and-delete/app.py | 184 ++--- .../bots/__init__.py | 12 +- .../bots/activity_update_and_delete_bot.py | 66 +- .../activity-update-and-delete}/config.py | 26 +- .../requirements.txt | 0 .../teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 84 +- .../teams_app_manifest/outline.png | Bin .../scenarios/conversation-update}/README.md | 60 +- .../scenarios}/conversation-update/app.py | 184 ++--- .../conversation-update/bots/__init__.py | 12 +- .../bots/conversation_update_bot.py | 112 +-- .../scenarios}/conversation-update/config.py | 26 +- .../conversation-update/requirements.txt | 0 .../teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 84 +- .../teams_app_manifest/outline.png | Bin .../create-thread-in-channel}/README.md | 0 .../create-thread-in-channel/app.py | 0 .../create-thread-in-channel/bots/__init__.py | 0 .../bots/create_thread_in_teams_bot.py | 0 .../create-thread-in-channel}/config.py | 0 .../create-thread-in-channel/requirements.txt | 0 .../teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 0 .../teams_app_manifest/outline.png | Bin .../teams/scenarios}/file-upload/README.md | 238 +++--- .../teams/scenarios}/file-upload/app.py | 182 ++--- .../scenarios}/file-upload/bots/__init__.py | 12 +- .../file-upload/bots/teams_file_bot.py | 370 ++++----- .../teams/scenarios/file-upload}/config.py | 26 +- .../file-upload/files/teams-logo.png | Bin .../scenarios}/file-upload/requirements.txt | 0 .../file-upload/teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 74 +- .../teams_app_manifest/outline.png | Bin .../teams/scenarios}/link-unfurling/README.md | 60 +- .../teams/scenarios}/link-unfurling/app.py | 172 ++-- .../link-unfurling/bots/__init__.py | 12 +- .../link-unfurling/bots/link_unfurling_bot.py | 112 +-- .../teams/scenarios/link-unfurling/config.py | 13 + .../link-unfurling/requirements.txt | 0 .../teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 134 ++-- .../teams_app_manifest/manifest.zip | Bin .../teams_app_manifest/outline.png | Bin tests/teams/scenarios/mentions/README.md | 30 + .../teams/scenarios}/mentions/app.py | 184 ++--- .../scenarios}/mentions/bots/__init__.py | 12 +- .../scenarios}/mentions/bots/mention_bot.py | 42 +- tests/teams/scenarios/mentions/config.py | 13 + .../scenarios}/mentions/requirements.txt | 0 .../mentions/teams_app_manifest/color.png | Bin .../mentions/teams_app_manifest/manifest.json | 84 +- .../mentions/teams_app_manifest/outline.png | Bin .../scenarios/message-reactions/README.md | 30 + .../message-reactions/activity_log.py | 60 +- .../teams/scenarios}/message-reactions/app.py | 188 ++--- .../message-reactions/bots/__init__.py | 12 +- .../bots/message_reaction_bot.py | 120 +-- .../scenarios}/message-reactions/config.py | 26 +- .../message-reactions/requirements.txt | 0 .../teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 84 +- .../teams_app_manifest/outline.png | Bin .../message-reactions/threading_helper.py | 338 ++++---- .../teams/scenarios}/roster/README.md | 60 +- .../teams/scenarios}/roster/app.py | 184 ++--- .../teams/scenarios}/roster/bots/__init__.py | 12 +- .../scenarios}/roster/bots/roster_bot.py | 130 +-- tests/teams/scenarios/roster/config.py | 13 + .../teams/scenarios}/roster/requirements.txt | 0 .../roster/teams_app_manifest/color.png | Bin .../roster/teams_app_manifest/manifest.json | 82 +- .../roster/teams_app_manifest/outline.png | Bin .../README.md | 60 +- .../search-based-messaging-extension/app.py | 166 ++-- .../bots/__init__.py | 12 +- .../bots/search_based_messaging_extension.py | 348 ++++---- .../config.py | 13 + .../requirements.txt | 0 .../teams_app_manifest/color.png | Bin .../teams_app_manifest/manifest.json | 96 +-- .../teams_app_manifest/outline.png | Bin .../teams/scenarios}/task-module/app.py | 186 ++--- .../scenarios}/task-module/bots/__init__.py | 12 +- .../task-module/bots/teams_task_module_bot.py | 180 ++--- .../teams/scenarios}/task-module/config.py | 30 +- .../scenarios}/task-module/requirements.txt | 0 .../teams_app_manifest/icon-color.png | Bin .../teams_app_manifest/icon-outline.png | Bin .../teams_app_manifest/manifest.json | 82 +- 233 files changed, 7022 insertions(+), 7022 deletions(-) delete mode 100644 scenarios/link-unfurling/config.py delete mode 100644 scenarios/mentions/README.md delete mode 100644 scenarios/mentions/config.py delete mode 100644 scenarios/message-reactions/README.md delete mode 100644 scenarios/roster/config.py delete mode 100644 scenarios/search-based-messaging-extension/config.py rename {samples => tests}/experimental/101.corebot-bert-bidaf/Dockerfile_bot (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/NOTICE.md (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/README.md (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/bots/dialog_and_welcome_bot.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/bots/dialog_bot.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/bots/resources/welcomeCard.json (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/config.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/dialogs/__init__.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/dialogs/booking_dialog.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/dialogs/cancel_and_help_dialog.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/dialogs/date_resolver_dialog.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/dialogs/main_dialog.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/helpers/__init__.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/helpers/activity_helper.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/helpers/dialog_helper.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/bot/main.py (100%) rename {samples/experimental/101.corebot-bert-bidaf => tests/experimental/101.corebot-bert-bidaf/bot}/requirements.txt (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml (94%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_complete.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_runtime.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_train.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bidaf_runtime.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_model_nav.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_run_all_cells.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/media/jupyter_lab_select_kernel.PNG (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt (92%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json (94%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt (88%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/booking_details.py (100%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model/setup.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py (97%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb (95%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb (99%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb (96%) rename {samples => tests}/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb (95%) rename {samples/experimental/101.corebot-bert-bidaf/bot => tests/experimental/101.corebot-bert-bidaf}/requirements.txt (95%) rename {samples => tests}/experimental/sso/child/adapter_with_error_handler.py (100%) rename {samples => tests}/experimental/sso/child/app.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/child/bots/__init__.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/child/bots/child_bot.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/child/config.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/child/dialogs/__init__.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/child/dialogs/main_dialog.py (100%) mode change 100755 => 100644 rename {samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot => tests/experimental/sso/child}/helpers/__init__.py (100%) rename {samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot => tests/experimental/sso/child}/helpers/dialog_helper.py (100%) rename {samples => tests}/experimental/sso/parent/ReadMeForSSOTesting.md (100%) rename {samples => tests}/experimental/sso/parent/app.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/parent/bots/__init__.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/parent/bots/parent_bot.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/parent/config.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/parent/dialogs/__init__.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/parent/dialogs/main_dialog.py (100%) rename {samples/experimental/sso/child => tests/experimental/sso/parent}/helpers/__init__.py (100%) mode change 100755 => 100644 rename {samples/experimental/sso/child => tests/experimental/sso/parent}/helpers/dialog_helper.py (100%) mode change 100755 => 100644 rename {samples => tests}/experimental/sso/parent/skill_client.py (100%) rename {samples => tests}/experimental/test-protocol/app.py (100%) rename {samples => tests}/experimental/test-protocol/config.py (96%) rename {samples => tests}/experimental/test-protocol/routing_handler.py (97%) rename {samples => tests}/experimental/test-protocol/routing_id_factory.py (97%) rename {samples/experimental => tests/skills}/skills-buffered/child/app.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/child/bots/__init__.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/child/bots/child_bot.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/child/config.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/child/requirements.txt (100%) rename {samples/experimental => tests/skills}/skills-buffered/parent/app.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/parent/bots/__init__.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/parent/bots/parent_bot.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/parent/config.py (100%) rename {samples/experimental => tests/skills}/skills-buffered/parent/requirements.txt (100%) rename {samples/experimental => tests/skills}/skills-buffered/parent/skill_conversation_id_factory.py (100%) rename {samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot => tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot}/README.md (97%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/app.py (96%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py (97%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py (97%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py (97%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/config.py (95%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py (94%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py (97%) rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py (97%) rename {samples/experimental/sso/parent => tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot}/helpers/__init__.py (96%) mode change 100755 => 100644 rename {samples/experimental/sso/parent => tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot}/helpers/dialog_helper.py (97%) mode change 100755 => 100644 rename {samples/experimental => tests/skills}/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt (100%) rename {scenarios/conversation-update => tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot}/README.md (97%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py (96%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py (96%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py (97%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py (95%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt (100%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py (100%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py (93%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py (100%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py (96%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py (95%) rename {samples/experimental => tests/skills}/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py (96%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/app.py (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/bots/__init__.py (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/config.py (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/example_data.py (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-outline.png (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension-fetch-task/teams_app_manifest/manifest.json (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/app.py (97%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/bots/__init__.py (97%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py (97%) rename {scenarios/create-thread-in-channel => tests/teams/scenarios/action-based-messaging-extension}/config.py (95%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/teams_app_manifest/icon-color.png (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/teams_app_manifest/icon-outline.png (100%) rename {scenarios => tests/teams/scenarios}/action-based-messaging-extension/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/README.md (97%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/app.py (97%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/bots/__init__.py (97%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/bots/activity_update_and_delete_bot.py (97%) rename {scenarios/file-upload => tests/teams/scenarios/activity-update-and-delete}/config.py (95%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/activity-update-and-delete/teams_app_manifest/outline.png (100%) rename {scenarios/create-thread-in-channel => tests/teams/scenarios/conversation-update}/README.md (97%) rename {scenarios => tests/teams/scenarios}/conversation-update/app.py (97%) rename {scenarios => tests/teams/scenarios}/conversation-update/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/conversation-update/bots/conversation_update_bot.py (97%) rename {scenarios => tests/teams/scenarios}/conversation-update/config.py (95%) rename {scenarios => tests/teams/scenarios}/conversation-update/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/conversation-update/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/conversation-update/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/conversation-update/teams_app_manifest/outline.png (100%) rename {samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot => tests/teams/scenarios/create-thread-in-channel}/README.md (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/app.py (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/bots/__init__.py (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/bots/create_thread_in_teams_bot.py (100%) rename {scenarios/action-based-messaging-extension => tests/teams/scenarios/create-thread-in-channel}/config.py (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/teams_app_manifest/manifest.json (100%) rename {scenarios => tests/teams/scenarios}/create-thread-in-channel/teams_app_manifest/outline.png (100%) rename {scenarios => tests/teams/scenarios}/file-upload/README.md (97%) rename {scenarios => tests/teams/scenarios}/file-upload/app.py (97%) rename {scenarios => tests/teams/scenarios}/file-upload/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/file-upload/bots/teams_file_bot.py (97%) rename {scenarios/activity-update-and-delete => tests/teams/scenarios/file-upload}/config.py (95%) rename {scenarios => tests/teams/scenarios}/file-upload/files/teams-logo.png (100%) rename {scenarios => tests/teams/scenarios}/file-upload/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/file-upload/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/file-upload/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/file-upload/teams_app_manifest/outline.png (100%) rename {scenarios => tests/teams/scenarios}/link-unfurling/README.md (97%) rename {scenarios => tests/teams/scenarios}/link-unfurling/app.py (97%) rename {scenarios => tests/teams/scenarios}/link-unfurling/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/link-unfurling/bots/link_unfurling_bot.py (97%) create mode 100644 tests/teams/scenarios/link-unfurling/config.py rename {scenarios => tests/teams/scenarios}/link-unfurling/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/link-unfurling/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/link-unfurling/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/link-unfurling/teams_app_manifest/manifest.zip (100%) rename {scenarios => tests/teams/scenarios}/link-unfurling/teams_app_manifest/outline.png (100%) create mode 100644 tests/teams/scenarios/mentions/README.md rename {scenarios => tests/teams/scenarios}/mentions/app.py (97%) rename {scenarios => tests/teams/scenarios}/mentions/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/mentions/bots/mention_bot.py (97%) create mode 100644 tests/teams/scenarios/mentions/config.py rename {scenarios => tests/teams/scenarios}/mentions/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/mentions/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/mentions/teams_app_manifest/manifest.json (95%) rename {scenarios => tests/teams/scenarios}/mentions/teams_app_manifest/outline.png (100%) create mode 100644 tests/teams/scenarios/message-reactions/README.md rename {scenarios => tests/teams/scenarios}/message-reactions/activity_log.py (96%) rename {scenarios => tests/teams/scenarios}/message-reactions/app.py (97%) rename {scenarios => tests/teams/scenarios}/message-reactions/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/message-reactions/bots/message_reaction_bot.py (97%) rename {scenarios => tests/teams/scenarios}/message-reactions/config.py (96%) rename {scenarios => tests/teams/scenarios}/message-reactions/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/message-reactions/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/message-reactions/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/message-reactions/teams_app_manifest/outline.png (100%) rename {scenarios => tests/teams/scenarios}/message-reactions/threading_helper.py (96%) rename {scenarios => tests/teams/scenarios}/roster/README.md (97%) rename {scenarios => tests/teams/scenarios}/roster/app.py (97%) rename {scenarios => tests/teams/scenarios}/roster/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/roster/bots/roster_bot.py (97%) create mode 100644 tests/teams/scenarios/roster/config.py rename {scenarios => tests/teams/scenarios}/roster/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/roster/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/roster/teams_app_manifest/manifest.json (96%) rename {scenarios => tests/teams/scenarios}/roster/teams_app_manifest/outline.png (100%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/README.md (97%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/app.py (97%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/bots/__init__.py (97%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/bots/search_based_messaging_extension.py (97%) create mode 100644 tests/teams/scenarios/search-based-messaging-extension/config.py rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/teams_app_manifest/color.png (100%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/teams_app_manifest/manifest.json (97%) rename {scenarios => tests/teams/scenarios}/search-based-messaging-extension/teams_app_manifest/outline.png (100%) rename {scenarios => tests/teams/scenarios}/task-module/app.py (96%) rename {scenarios => tests/teams/scenarios}/task-module/bots/__init__.py (96%) rename {scenarios => tests/teams/scenarios}/task-module/bots/teams_task_module_bot.py (97%) rename {scenarios => tests/teams/scenarios}/task-module/config.py (95%) rename {scenarios => tests/teams/scenarios}/task-module/requirements.txt (100%) rename {scenarios => tests/teams/scenarios}/task-module/teams_app_manifest/icon-color.png (100%) rename {scenarios => tests/teams/scenarios}/task-module/teams_app_manifest/icon-outline.png (100%) rename {scenarios => tests/teams/scenarios}/task-module/teams_app_manifest/manifest.json (96%) diff --git a/scenarios/link-unfurling/config.py b/scenarios/link-unfurling/config.py deleted file mode 100644 index 6b5116fba..000000000 --- a/scenarios/link-unfurling/config.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/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/mentions/README.md b/scenarios/mentions/README.md deleted file mode 100644 index 40e84f525..000000000 --- a/scenarios/mentions/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) diff --git a/scenarios/mentions/config.py b/scenarios/mentions/config.py deleted file mode 100644 index 6b5116fba..000000000 --- a/scenarios/mentions/config.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/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/message-reactions/README.md b/scenarios/message-reactions/README.md deleted file mode 100644 index 40e84f525..000000000 --- a/scenarios/message-reactions/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) diff --git a/scenarios/roster/config.py b/scenarios/roster/config.py deleted file mode 100644 index 6b5116fba..000000000 --- a/scenarios/roster/config.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/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/search-based-messaging-extension/config.py b/scenarios/search-based-messaging-extension/config.py deleted file mode 100644 index 6b5116fba..000000000 --- a/scenarios/search-based-messaging-extension/config.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/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/samples/experimental/101.corebot-bert-bidaf/Dockerfile_bot b/tests/experimental/101.corebot-bert-bidaf/Dockerfile_bot similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/Dockerfile_bot rename to tests/experimental/101.corebot-bert-bidaf/Dockerfile_bot index 322372e67..1ce39d22e 100644 --- a/samples/experimental/101.corebot-bert-bidaf/Dockerfile_bot +++ b/tests/experimental/101.corebot-bert-bidaf/Dockerfile_bot @@ -1,35 +1,35 @@ -FROM tiangolo/uwsgi-nginx-flask:python3.6 - -# Setup for nginx -RUN mkdir -p /home/LogFiles \ - && apt update \ - && apt install -y --no-install-recommends vim - -EXPOSE 3978 - -COPY /model /model - -# Pytorch very large. Install from wheel. -RUN wget https://files.pythonhosted.org/packages/69/60/f685fb2cfb3088736bafbc9bdbb455327bdc8906b606da9c9a81bae1c81e/torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl -RUN pip3 install torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl - -RUN pip3 install -e /model/ - - -COPY ./bot /bot - -RUN pip3 install -r /bot/requirements.txt - -ENV FLASK_APP=/bot/main.py -ENV LANG=C.UTF-8 -ENV LC_ALL=C.UTF-8 -ENV PATH ${PATH}:/home/site/wwwroot - -WORKDIR bot -# Initialize models - - -# For Debugging, uncomment the following: -#ENTRYPOINT ["python3.6", "-c", "import time ; time.sleep(500000)"] -ENTRYPOINT [ "flask" ] -CMD [ "run", "--port", "3978", "--host", "0.0.0.0" ] +FROM tiangolo/uwsgi-nginx-flask:python3.6 + +# Setup for nginx +RUN mkdir -p /home/LogFiles \ + && apt update \ + && apt install -y --no-install-recommends vim + +EXPOSE 3978 + +COPY /model /model + +# Pytorch very large. Install from wheel. +RUN wget https://files.pythonhosted.org/packages/69/60/f685fb2cfb3088736bafbc9bdbb455327bdc8906b606da9c9a81bae1c81e/torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl +RUN pip3 install torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl + +RUN pip3 install -e /model/ + + +COPY ./bot /bot + +RUN pip3 install -r /bot/requirements.txt + +ENV FLASK_APP=/bot/main.py +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PATH ${PATH}:/home/site/wwwroot + +WORKDIR bot +# Initialize models + + +# For Debugging, uncomment the following: +#ENTRYPOINT ["python3.6", "-c", "import time ; time.sleep(500000)"] +ENTRYPOINT [ "flask" ] +CMD [ "run", "--port", "3978", "--host", "0.0.0.0" ] diff --git a/samples/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime b/tests/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime rename to tests/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime index c1a22217f..ed777a1d2 100644 --- a/samples/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime +++ b/tests/experimental/101.corebot-bert-bidaf/Dockerfile_model_runtime @@ -1,29 +1,29 @@ -# https://github.com/tornadoweb/tornado/blob/master/demos/blog/Dockerfile -FROM python:3.6 - -# Port the model runtime service will listen on. -EXPOSE 8880 - -# Make structure where the models will live. -RUN mkdir -p /cognitiveModels/bert -RUN mkdir -p /cognitiveModels/bidaf - -# Copy and install models. -COPY model /model/ -#RUN pip3 install --upgrade pip -#RUN pip3 install --upgrade nltk -RUN wget https://files.pythonhosted.org/packages/69/60/f685fb2cfb3088736bafbc9bdbb455327bdc8906b606da9c9a81bae1c81e/torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl -RUN pip3 install torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl -RUN pip3 install -e /model - -# Copy and install model runtime service api. -COPY model_runtime_svc /model_runtime_svc/ -RUN pip3 install -e /model_runtime_svc - -# One time initialization of the models. -RUN python3 /model_runtime_svc/model_runtime_svc_corebot101/docker_init.py -RUN rm /model_runtime_svc/model_runtime_svc_corebot101/docker_init.py - -WORKDIR /model_runtime_svc - +# https://github.com/tornadoweb/tornado/blob/master/demos/blog/Dockerfile +FROM python:3.6 + +# Port the model runtime service will listen on. +EXPOSE 8880 + +# Make structure where the models will live. +RUN mkdir -p /cognitiveModels/bert +RUN mkdir -p /cognitiveModels/bidaf + +# Copy and install models. +COPY model /model/ +#RUN pip3 install --upgrade pip +#RUN pip3 install --upgrade nltk +RUN wget https://files.pythonhosted.org/packages/69/60/f685fb2cfb3088736bafbc9bdbb455327bdc8906b606da9c9a81bae1c81e/torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl +RUN pip3 install torch-1.1.0-cp36-cp36m-manylinux1_x86_64.whl +RUN pip3 install -e /model + +# Copy and install model runtime service api. +COPY model_runtime_svc /model_runtime_svc/ +RUN pip3 install -e /model_runtime_svc + +# One time initialization of the models. +RUN python3 /model_runtime_svc/model_runtime_svc_corebot101/docker_init.py +RUN rm /model_runtime_svc/model_runtime_svc_corebot101/docker_init.py + +WORKDIR /model_runtime_svc + ENTRYPOINT ["python3", "./model_runtime_svc_corebot101/main.py"] \ No newline at end of file diff --git a/samples/experimental/101.corebot-bert-bidaf/NOTICE.md b/tests/experimental/101.corebot-bert-bidaf/NOTICE.md similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/NOTICE.md rename to tests/experimental/101.corebot-bert-bidaf/NOTICE.md diff --git a/samples/experimental/101.corebot-bert-bidaf/README.md b/tests/experimental/101.corebot-bert-bidaf/README.md similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/README.md rename to tests/experimental/101.corebot-bert-bidaf/README.md index 4d66d258b..501f8d600 100644 --- a/samples/experimental/101.corebot-bert-bidaf/README.md +++ b/tests/experimental/101.corebot-bert-bidaf/README.md @@ -1,349 +1,349 @@ -# CoreBot-bert-bidaf - -Bot Framework v4 core bot sample demonstrating using open source language models employing the BERT and BiDAF. This is for demonstration purposes only. - -## Table of Contents -- [Overview](#overview) -- [Terminology](#terminology) -- [Setup](#setup) -- [Model Development](#model-development) -- [Model Runtime Options](#model-runtime-options) - - [In-Process](#in-process) - - [Out-of-process to local service](#out-of-process-to-local-service) - - [Using Docker Containers](#using-docker-containers) - - -## Overview -This bot has been created using [Bot Framework](https://dev.botframework.com). It demonstrates the following: -- Train a one layer logistic regression classifier on top of the pretrained BERT model to infer intents. -- Use locally the ONNX BiDAF pre-trained model to infer entities for the scenario, in this case simple flight booking. -- Run the bot with the model runtime in-process to the bot. -- Run the bot with the model runtime external to the bot. - -## Terminology -This document uses the following terminology. -**Model Development**: Model Development broadly covers gathering data, data processing, training/validation/evaluation and testing. This can also be thought of as model preparation or authoring time. -**Model Runtime**: The built model which can be used to perform inferences against bot utterances. The model runtime refers to the model and the associated code to perform inferences. The model runtime is used when the bot is running to infer intents and entities. -**Inference**: Applying the bot utterance to a model yields intents and entities. The intents and entities are the inferences used by the bot. - -## Setup - -This sample uses the Anaconda environment (which provides Jupyter Lab and other machine learning tools) in order to run. - -The following instructions assume using the [Anaconda]() environment (v4.6.11+). - -Note: Be sure to install the **64-bit** version of Anaconda for the purposes of this tutorial. - -### Create and activate virtual environment - -In your local folder, open an **Anaconda prompt** and run the following commands: - -```bash -cd 101.corebot-bert-bidaf -conda create -n botsample python=3.6 anaconda -y -conda activate botsample # source conda - -# Add extension to handle Jupyter kernel based on the new environemnt. -pip install ipykernel -ipython kernel install --user --name=botsample - -# Add extension for visual controls to display correctly -conda install -c conda-forge nodejs -y -jupyter labextension install @jupyter-widgets/jupyterlab-manager -``` - -From here on out, all CLI interactions should occur within the `botsample` Anaconda virtual environment. - -### Install models package -The `models` package contains source to perform model development support and runtime inferencing using the tuned BERT and BiDAF models. - - -```bash -# Install Pytorch -conda install -c pytorch pytorch -y - -# Install models package using code in sample -# This will create the python package that contains all the -# models used in the Jupyter Notebooks and the Bot code. -cd model -pip install -e . # Note the '.' after -e - -# Verify packages installed - # On Windows: - conda list | findstr "corebot pytorch onnx" - - # On Linux/etc: - conda list | grep -e corebot -e pytorch -e onnx -``` - -You should see something like: -```bash -model-corebot101 0.0.1 dev_0 -onnx 1.5.0 pypi_0 pypi -onnxruntime 0.4.0 pypi_0 pypi -pytorch 1.1.0 py3.6_cuda100_cudnn7_1 pytorch -pytorch-pretrained-bert 0.6.2 pypi_0 pypi -``` - -## Model Development -Model development in this sample involves building a BERT classifier model and is performed in the Juypter Lab environment. - -### Training in Jupyter Lab -Training the model can be performed in Jupyter Lab. -Within the Anaconda shell, launch Jupyter Lab from the sample directory. - -```bash -# Start JupyterLab from the root of the sample directory -(botsample) 101.corebot-bert-bidaf> jupyter lab - -``` -#### Click on `notebooks` folder in the left hand navigation of JupyterLab - -
- Click for screen shot. - Selecting notebooks folder in Jupyter - Selecting notebooks folder in Jupyter -
- -#### Click on `bert_train.ipynb` notebook -If running the first time, you should select the `botsample` environment. -
- Click for screen shot. - Selecting Anaconda `botsample` environment for Jupyter Kernel - Selecting Jupyter Kernel -
- -#### Train the model - -To build the BERT classifier model, run the Jupyterlab Notebok (Run->**Run All Cells**). - -
- Click for screen shot. - Selecting Model to build folder - Selecting Bert model -
- -This process may take several minutes. The built model is placed into a directory that will get packaged with the bot during deployment. The sample demonstrates using this package in-process to the bot, our of process in a separate host that performs inferences, and within Jupyter Notebooks. - -After running the Jupyter Notebook, the output should resemble something like the following: -
- Click for screen shot. - Showing Completed Model Build - Completed Model Build -
- - - -#### Test the BERT runtime model classification -Once the model has been built, you can test the model with a separately provided Jupyter Notebook (Run->Run All Cells). -- Within the `notebooks` folder, select the `bert_model_runtime.ipynb` file. -- Run the notebook. - -[![Sample output of the bert model](./media/jupyter_lab_bert_runtime.PNG)] - -- The output shows intents (`Book flight`, `Cancel`) that will be used by the bot. - -- Add additional test cases to see how phrases will be inferenced. - -- To modify the training data, update the file below. The format of this is compatible with the LUIS schema. When done modifying the training data [re-train the model](#train-the-model) before testing. - `101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json` - - [Click to edit Training Data.](./model/model_corebot101/bert/training_data/FlightBooking.json) - -> **NOTE**: The default file output location for the tuned BERT model is `/models/bert`. - -### Test the BiDAF runtime model classification -Similarly, you can test the BiDAF model. Note there is no explicit data processing for the bidaf model. The entities detected are configured at runtime in the notebook. - -[![Sample output of the bert model](./media/jupyter_lab_bidaf_runtime.PNG)] - -> **NOTE**: The default file output location for the BiDAF model is `/models/bidaf`. - - - -## Model Runtime Options - -The sample can host the model runtime within the bot process or out-of-process in a REST API service. The following sections demonstrate how to do this on the local machine. In addition, the sample provides Dockerfiles to run this sample in containers. - -### In-process -Within an Anaconda environment (bring up a new Anaconda shell and activate your virtual environment if you would like to continue having JupyterLab running in the original shell), install dependencies for the bot: -```bash -# Install requirements required for the bot -(botsample) 101.corebot-bert-bidaf> pip install -r requirements.txt -``` -> **NOTE**: If `requirements.txt` doesn't install, you may have to stop JupyterLab if it's running. - -```bash -# Run the bot -(botsample) 101.corebot-bert-bidaf> cd bot -(botsample) 101.corebot-bert-bidaf\bot> python main.py -``` - - -> **NOTE**: If executing `main.py` with Python above doesn't work, try running Flask directly: -> -> ```bash -> # Set FLASK_APP with full path to main.py in the sample directory -> # On linux, use export instead of set. -> (botsample) 101.corebot-bert-bidaf> set FLASK_APP=main.py -> -> # Turn on development -> (botsample) 101.corebot-bert-bidaf> set FLASK_ENV=development -> -> # Run flask -> (botsample) 101.corebot-bert-bidaf> flask run -> ``` - -At this point, you can [test the bot](#testing-the-bot-using-bot-framework-emulator). - -### Out-of-process to local service -Sometimes it's helpful to host the model outside the bot's process space and serve inferences from a separate process. - -This section builds on the previous section of [In-process](#in-process). - -#### Stop any running bot/model runtime processes -Ensure there are no running bot or model runtimes executing. Hit ^C on any Anaconda shells running flask/bot or the model service runtime (`python main.py`). - -#### Modify bot configuration for localhost -To call the out-of-process REST API, the bot configuration is modified. Edit the following file: -`101.corebot-bert-bidaf/bot/config.py` - -Edit the settings for `USE_MODEL_RUNTIME_SERVICE` and set to `True`. - -```python -class DefaultConfig(object): - """Bot configuration parameters.""" - # TCP port that the bot listens on (default:3978) - PORT = 3978 - - # Azure Application ID (not required if running locally) - APP_ID = "" - # Azure Application Password (not required if running locally) - APP_PASSWORD = "" - - # Determines if the bot calls the models in-proc to the bot or call out of process - # to the service api using a REST API. - USE_MODEL_RUNTIME_SERVICE = True - # Host serving the out-of-process model runtime service api. - MODEL_RUNTIME_SERVICE_HOST = "localhost" - # TCP serving the out-of-process model runtime service api. - MODEL_RUNTIME_SERVICE_PORT = 8880 -``` -#### Set up model runtime service -Inside a separate Anaconda shell, activate the `botsample` environment, and install the model runtime service. - -```bash -# Install requirements required for model runtime service -(botsample) 101.corebot-bert-bidaf> cd model_runtime_svc -(botsample) 101.corebot-bert-bidaf\model_runtime_svc> pip install -e . # Note the dot after the -e switch -``` - -#### Run model runtime service -To run the model runtime service, execute the following: -```bash -# Navigate into the model_runtime_svc_corebot101 folder -cd model_runtime_svc_corebot101 - -# From 101.corebot-bert-bidaf\model_runtime_svc\model_runtime_svc_corebot101 -python main.py -``` -If not already running, create a separate Anaconda shell set to the `botsample` environment and [run the local bot](#local-in-process) as described above. If it was already running, ensure [the configuration changes made above](#modify-bot-configuration) are running. - -At this point, you can [test the bot](#testing-the-bot-using-bot-framework-emulator). - -### Using Docker Containers -This sample also demonstrates using Docker and Docker Compose to run a bot and model runtime service on the local host, that communicate together. - -> **NOTE**: For Windows: https://hub.docker.com/editions/community/docker-ce-desktop-windows In the configuration dialog make sure the use Linux containers is checked. - - -#### Modify bot configuration for Docker -To call the out-of-process REST API inside a Docker container, the bot configuration is modified. Edit the following file: -`101.corebot-bert-bidaf/bot/config.py` - -Ensure that the bot configuration is set to serve model predictions remotely by setting `USE_MODEL_RUNTIME_SERVICE` to `True`. - -In addition, modify the `MODEL_RUNTIME_SERVICE_HOST` to `api` (previously `localhost`). This will allow the `bot` container to properly address the model `model runtime api` service container. - -The resulting `config.py`should look like the following: -```python -class DefaultConfig(object): - """Bot configuration parameters.""" - # TCP port that the bot listens on (default:3978) - PORT = 3978 - - # Azure Application ID (not required if running locally) - APP_ID = "" - # Azure Application Password (not required if running locally) - APP_PASSWORD = "" - - # Determines if the bot calls the models in-proc to the bot or call out of process - # to the service api using a REST API. - USE_MODEL_RUNTIME_SERVICE = True - # Host serving the out-of-process model runtime service api. - MODEL_RUNTIME_SERVICE_HOST = "api" - # TCP serving the out-of-process model runtime service api. - MODEL_RUNTIME_SERVICE_PORT = 8880 -``` -#### Build the containers - -The following command builds both the bot and the model runtime service containers. -```bash -# From 101.corebot-bert-bidaf directory -docker-compose --project-directory . --file docker/docker-compose.yml build -``` -> **NOTE**: If you get error code 137, you may need to increase the amount of memory supplied to Docker. - -#### Run the containers locally -```bash -# From 101.corebot-bert-bidaf directory -docker-compose --project-directory . --file docker/docker-compose.yml up -d -``` -#### Verify -```bash -# From 101.corebot-bert-bidaf directory -docker-compose --project-directory . --file docker/docker-compose.yml logs -docker ps -``` -Look at the logs and docker to ensure the containers are running. - -> **NOTE**: When testing the bot inside containers, use your local IP address instead of `localhost` (`http://:3978/api/messages`). -> To find your IP address: -> -> - On **Windows**, `ipconfig` at a command prompt. -> - On **Linux**, `ip addr` at a command prompt. - - -## 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` - -### Test the bot -In the emulator, type phrases such as`hello`, `book flight from seattle to miami`, etc - - - -## 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) -- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) -- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) -- [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) -- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) -- [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/azure/cognitive-services/luis/) -- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) -- [Google BERT](https://github.com/google-research/bert) +# CoreBot-bert-bidaf + +Bot Framework v4 core bot sample demonstrating using open source language models employing the BERT and BiDAF. This is for demonstration purposes only. + +## Table of Contents +- [Overview](#overview) +- [Terminology](#terminology) +- [Setup](#setup) +- [Model Development](#model-development) +- [Model Runtime Options](#model-runtime-options) + - [In-Process](#in-process) + - [Out-of-process to local service](#out-of-process-to-local-service) + - [Using Docker Containers](#using-docker-containers) + + +## Overview +This bot has been created using [Bot Framework](https://dev.botframework.com). It demonstrates the following: +- Train a one layer logistic regression classifier on top of the pretrained BERT model to infer intents. +- Use locally the ONNX BiDAF pre-trained model to infer entities for the scenario, in this case simple flight booking. +- Run the bot with the model runtime in-process to the bot. +- Run the bot with the model runtime external to the bot. + +## Terminology +This document uses the following terminology. +**Model Development**: Model Development broadly covers gathering data, data processing, training/validation/evaluation and testing. This can also be thought of as model preparation or authoring time. +**Model Runtime**: The built model which can be used to perform inferences against bot utterances. The model runtime refers to the model and the associated code to perform inferences. The model runtime is used when the bot is running to infer intents and entities. +**Inference**: Applying the bot utterance to a model yields intents and entities. The intents and entities are the inferences used by the bot. + +## Setup + +This sample uses the Anaconda environment (which provides Jupyter Lab and other machine learning tools) in order to run. + +The following instructions assume using the [Anaconda]() environment (v4.6.11+). + +Note: Be sure to install the **64-bit** version of Anaconda for the purposes of this tutorial. + +### Create and activate virtual environment + +In your local folder, open an **Anaconda prompt** and run the following commands: + +```bash +cd 101.corebot-bert-bidaf +conda create -n botsample python=3.6 anaconda -y +conda activate botsample # source conda + +# Add extension to handle Jupyter kernel based on the new environemnt. +pip install ipykernel +ipython kernel install --user --name=botsample + +# Add extension for visual controls to display correctly +conda install -c conda-forge nodejs -y +jupyter labextension install @jupyter-widgets/jupyterlab-manager +``` + +From here on out, all CLI interactions should occur within the `botsample` Anaconda virtual environment. + +### Install models package +The `models` package contains source to perform model development support and runtime inferencing using the tuned BERT and BiDAF models. + + +```bash +# Install Pytorch +conda install -c pytorch pytorch -y + +# Install models package using code in sample +# This will create the python package that contains all the +# models used in the Jupyter Notebooks and the Bot code. +cd model +pip install -e . # Note the '.' after -e + +# Verify packages installed + # On Windows: + conda list | findstr "corebot pytorch onnx" + + # On Linux/etc: + conda list | grep -e corebot -e pytorch -e onnx +``` + +You should see something like: +```bash +model-corebot101 0.0.1 dev_0 +onnx 1.5.0 pypi_0 pypi +onnxruntime 0.4.0 pypi_0 pypi +pytorch 1.1.0 py3.6_cuda100_cudnn7_1 pytorch +pytorch-pretrained-bert 0.6.2 pypi_0 pypi +``` + +## Model Development +Model development in this sample involves building a BERT classifier model and is performed in the Juypter Lab environment. + +### Training in Jupyter Lab +Training the model can be performed in Jupyter Lab. +Within the Anaconda shell, launch Jupyter Lab from the sample directory. + +```bash +# Start JupyterLab from the root of the sample directory +(botsample) 101.corebot-bert-bidaf> jupyter lab + +``` +#### Click on `notebooks` folder in the left hand navigation of JupyterLab + +
+ Click for screen shot. + Selecting notebooks folder in Jupyter + Selecting notebooks folder in Jupyter +
+ +#### Click on `bert_train.ipynb` notebook +If running the first time, you should select the `botsample` environment. +
+ Click for screen shot. + Selecting Anaconda `botsample` environment for Jupyter Kernel + Selecting Jupyter Kernel +
+ +#### Train the model + +To build the BERT classifier model, run the Jupyterlab Notebok (Run->**Run All Cells**). + +
+ Click for screen shot. + Selecting Model to build folder + Selecting Bert model +
+ +This process may take several minutes. The built model is placed into a directory that will get packaged with the bot during deployment. The sample demonstrates using this package in-process to the bot, our of process in a separate host that performs inferences, and within Jupyter Notebooks. + +After running the Jupyter Notebook, the output should resemble something like the following: +
+ Click for screen shot. + Showing Completed Model Build + Completed Model Build +
+ + + +#### Test the BERT runtime model classification +Once the model has been built, you can test the model with a separately provided Jupyter Notebook (Run->Run All Cells). +- Within the `notebooks` folder, select the `bert_model_runtime.ipynb` file. +- Run the notebook. + +[![Sample output of the bert model](./media/jupyter_lab_bert_runtime.PNG)] + +- The output shows intents (`Book flight`, `Cancel`) that will be used by the bot. + +- Add additional test cases to see how phrases will be inferenced. + +- To modify the training data, update the file below. The format of this is compatible with the LUIS schema. When done modifying the training data [re-train the model](#train-the-model) before testing. + `101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json` + + [Click to edit Training Data.](./model/model_corebot101/bert/training_data/FlightBooking.json) + +> **NOTE**: The default file output location for the tuned BERT model is `/models/bert`. + +### Test the BiDAF runtime model classification +Similarly, you can test the BiDAF model. Note there is no explicit data processing for the bidaf model. The entities detected are configured at runtime in the notebook. + +[![Sample output of the bert model](./media/jupyter_lab_bidaf_runtime.PNG)] + +> **NOTE**: The default file output location for the BiDAF model is `/models/bidaf`. + + + +## Model Runtime Options + +The sample can host the model runtime within the bot process or out-of-process in a REST API service. The following sections demonstrate how to do this on the local machine. In addition, the sample provides Dockerfiles to run this sample in containers. + +### In-process +Within an Anaconda environment (bring up a new Anaconda shell and activate your virtual environment if you would like to continue having JupyterLab running in the original shell), install dependencies for the bot: +```bash +# Install requirements required for the bot +(botsample) 101.corebot-bert-bidaf> pip install -r requirements.txt +``` +> **NOTE**: If `requirements.txt` doesn't install, you may have to stop JupyterLab if it's running. + +```bash +# Run the bot +(botsample) 101.corebot-bert-bidaf> cd bot +(botsample) 101.corebot-bert-bidaf\bot> python main.py +``` + + +> **NOTE**: If executing `main.py` with Python above doesn't work, try running Flask directly: +> +> ```bash +> # Set FLASK_APP with full path to main.py in the sample directory +> # On linux, use export instead of set. +> (botsample) 101.corebot-bert-bidaf> set FLASK_APP=main.py +> +> # Turn on development +> (botsample) 101.corebot-bert-bidaf> set FLASK_ENV=development +> +> # Run flask +> (botsample) 101.corebot-bert-bidaf> flask run +> ``` + +At this point, you can [test the bot](#testing-the-bot-using-bot-framework-emulator). + +### Out-of-process to local service +Sometimes it's helpful to host the model outside the bot's process space and serve inferences from a separate process. + +This section builds on the previous section of [In-process](#in-process). + +#### Stop any running bot/model runtime processes +Ensure there are no running bot or model runtimes executing. Hit ^C on any Anaconda shells running flask/bot or the model service runtime (`python main.py`). + +#### Modify bot configuration for localhost +To call the out-of-process REST API, the bot configuration is modified. Edit the following file: +`101.corebot-bert-bidaf/bot/config.py` + +Edit the settings for `USE_MODEL_RUNTIME_SERVICE` and set to `True`. + +```python +class DefaultConfig(object): + """Bot configuration parameters.""" + # TCP port that the bot listens on (default:3978) + PORT = 3978 + + # Azure Application ID (not required if running locally) + APP_ID = "" + # Azure Application Password (not required if running locally) + APP_PASSWORD = "" + + # Determines if the bot calls the models in-proc to the bot or call out of process + # to the service api using a REST API. + USE_MODEL_RUNTIME_SERVICE = True + # Host serving the out-of-process model runtime service api. + MODEL_RUNTIME_SERVICE_HOST = "localhost" + # TCP serving the out-of-process model runtime service api. + MODEL_RUNTIME_SERVICE_PORT = 8880 +``` +#### Set up model runtime service +Inside a separate Anaconda shell, activate the `botsample` environment, and install the model runtime service. + +```bash +# Install requirements required for model runtime service +(botsample) 101.corebot-bert-bidaf> cd model_runtime_svc +(botsample) 101.corebot-bert-bidaf\model_runtime_svc> pip install -e . # Note the dot after the -e switch +``` + +#### Run model runtime service +To run the model runtime service, execute the following: +```bash +# Navigate into the model_runtime_svc_corebot101 folder +cd model_runtime_svc_corebot101 + +# From 101.corebot-bert-bidaf\model_runtime_svc\model_runtime_svc_corebot101 +python main.py +``` +If not already running, create a separate Anaconda shell set to the `botsample` environment and [run the local bot](#local-in-process) as described above. If it was already running, ensure [the configuration changes made above](#modify-bot-configuration) are running. + +At this point, you can [test the bot](#testing-the-bot-using-bot-framework-emulator). + +### Using Docker Containers +This sample also demonstrates using Docker and Docker Compose to run a bot and model runtime service on the local host, that communicate together. + +> **NOTE**: For Windows: https://hub.docker.com/editions/community/docker-ce-desktop-windows In the configuration dialog make sure the use Linux containers is checked. + + +#### Modify bot configuration for Docker +To call the out-of-process REST API inside a Docker container, the bot configuration is modified. Edit the following file: +`101.corebot-bert-bidaf/bot/config.py` + +Ensure that the bot configuration is set to serve model predictions remotely by setting `USE_MODEL_RUNTIME_SERVICE` to `True`. + +In addition, modify the `MODEL_RUNTIME_SERVICE_HOST` to `api` (previously `localhost`). This will allow the `bot` container to properly address the model `model runtime api` service container. + +The resulting `config.py`should look like the following: +```python +class DefaultConfig(object): + """Bot configuration parameters.""" + # TCP port that the bot listens on (default:3978) + PORT = 3978 + + # Azure Application ID (not required if running locally) + APP_ID = "" + # Azure Application Password (not required if running locally) + APP_PASSWORD = "" + + # Determines if the bot calls the models in-proc to the bot or call out of process + # to the service api using a REST API. + USE_MODEL_RUNTIME_SERVICE = True + # Host serving the out-of-process model runtime service api. + MODEL_RUNTIME_SERVICE_HOST = "api" + # TCP serving the out-of-process model runtime service api. + MODEL_RUNTIME_SERVICE_PORT = 8880 +``` +#### Build the containers + +The following command builds both the bot and the model runtime service containers. +```bash +# From 101.corebot-bert-bidaf directory +docker-compose --project-directory . --file docker/docker-compose.yml build +``` +> **NOTE**: If you get error code 137, you may need to increase the amount of memory supplied to Docker. + +#### Run the containers locally +```bash +# From 101.corebot-bert-bidaf directory +docker-compose --project-directory . --file docker/docker-compose.yml up -d +``` +#### Verify +```bash +# From 101.corebot-bert-bidaf directory +docker-compose --project-directory . --file docker/docker-compose.yml logs +docker ps +``` +Look at the logs and docker to ensure the containers are running. + +> **NOTE**: When testing the bot inside containers, use your local IP address instead of `localhost` (`http://:3978/api/messages`). +> To find your IP address: +> +> - On **Windows**, `ipconfig` at a command prompt. +> - On **Linux**, `ip addr` at a command prompt. + + +## 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` + +### Test the bot +In the emulator, type phrases such as`hello`, `book flight from seattle to miami`, etc + + + +## 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) +- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) +- [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) +- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) +- [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/azure/cognitive-services/luis/) +- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) +- [Google BERT](https://github.com/google-research/bert) - [ONNX BiDAF](https://github.com/onnx/models/tree/master/bidaf) \ No newline at end of file diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py b/tests/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py index ee478912d..7c71ff86f 100644 --- a/samples/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/bot/bots/__init__.py @@ -1,8 +1,8 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""bots module.""" - -from .dialog_bot import DialogBot -from .dialog_and_welcome_bot import DialogAndWelcomeBot - -__all__ = ["DialogBot", "DialogAndWelcomeBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""bots module.""" + +from .dialog_bot import DialogBot +from .dialog_and_welcome_bot import DialogAndWelcomeBot + +__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/bots/dialog_and_welcome_bot.py b/tests/experimental/101.corebot-bert-bidaf/bot/bots/dialog_and_welcome_bot.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/bots/dialog_and_welcome_bot.py rename to tests/experimental/101.corebot-bert-bidaf/bot/bots/dialog_and_welcome_bot.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/bots/dialog_bot.py b/tests/experimental/101.corebot-bert-bidaf/bot/bots/dialog_bot.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/bots/dialog_bot.py rename to tests/experimental/101.corebot-bert-bidaf/bot/bots/dialog_bot.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/bots/resources/welcomeCard.json b/tests/experimental/101.corebot-bert-bidaf/bot/bots/resources/welcomeCard.json similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/bots/resources/welcomeCard.json rename to tests/experimental/101.corebot-bert-bidaf/bot/bots/resources/welcomeCard.json diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/config.py b/tests/experimental/101.corebot-bert-bidaf/bot/config.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/bot/config.py rename to tests/experimental/101.corebot-bert-bidaf/bot/config.py index 4e8bfd007..89b234435 100644 --- a/samples/experimental/101.corebot-bert-bidaf/bot/config.py +++ b/tests/experimental/101.corebot-bert-bidaf/bot/config.py @@ -1,26 +1,26 @@ -#!/usr/bin/env python3 -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Bot/Flask Configuration parameters. -Configuration parameters for the bot. -""" - - -class DefaultConfig(object): - """Bot configuration parameters.""" - - # TCP port that the bot listens on (default:3978) - PORT = 3978 - - # Azure Application ID (not required if running locally) - APP_ID = "" - # Azure Application Password (not required if running locally) - APP_PASSWORD = "" - - # Determines if the bot calls the models in-proc to the bot or call out of process - # to the service api. - USE_MODEL_RUNTIME_SERVICE = False - # Host serving the out-of-process model runtime service api. - MODEL_RUNTIME_SERVICE_HOST = "localhost" - # TCP serving the out-of-process model runtime service api. - MODEL_RUNTIME_SERVICE_PORT = 8880 +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Bot/Flask Configuration parameters. +Configuration parameters for the bot. +""" + + +class DefaultConfig(object): + """Bot configuration parameters.""" + + # TCP port that the bot listens on (default:3978) + PORT = 3978 + + # Azure Application ID (not required if running locally) + APP_ID = "" + # Azure Application Password (not required if running locally) + APP_PASSWORD = "" + + # Determines if the bot calls the models in-proc to the bot or call out of process + # to the service api. + USE_MODEL_RUNTIME_SERVICE = False + # Host serving the out-of-process model runtime service api. + MODEL_RUNTIME_SERVICE_HOST = "localhost" + # TCP serving the out-of-process model runtime service api. + MODEL_RUNTIME_SERVICE_PORT = 8880 diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/dialogs/__init__.py b/tests/experimental/101.corebot-bert-bidaf/bot/dialogs/__init__.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/dialogs/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/bot/dialogs/__init__.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/dialogs/booking_dialog.py b/tests/experimental/101.corebot-bert-bidaf/bot/dialogs/booking_dialog.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/dialogs/booking_dialog.py rename to tests/experimental/101.corebot-bert-bidaf/bot/dialogs/booking_dialog.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/dialogs/cancel_and_help_dialog.py b/tests/experimental/101.corebot-bert-bidaf/bot/dialogs/cancel_and_help_dialog.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/dialogs/cancel_and_help_dialog.py rename to tests/experimental/101.corebot-bert-bidaf/bot/dialogs/cancel_and_help_dialog.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/dialogs/date_resolver_dialog.py b/tests/experimental/101.corebot-bert-bidaf/bot/dialogs/date_resolver_dialog.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/dialogs/date_resolver_dialog.py rename to tests/experimental/101.corebot-bert-bidaf/bot/dialogs/date_resolver_dialog.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/dialogs/main_dialog.py b/tests/experimental/101.corebot-bert-bidaf/bot/dialogs/main_dialog.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/dialogs/main_dialog.py rename to tests/experimental/101.corebot-bert-bidaf/bot/dialogs/main_dialog.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/helpers/__init__.py b/tests/experimental/101.corebot-bert-bidaf/bot/helpers/__init__.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/helpers/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/bot/helpers/__init__.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/helpers/activity_helper.py b/tests/experimental/101.corebot-bert-bidaf/bot/helpers/activity_helper.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/helpers/activity_helper.py rename to tests/experimental/101.corebot-bert-bidaf/bot/helpers/activity_helper.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/helpers/dialog_helper.py b/tests/experimental/101.corebot-bert-bidaf/bot/helpers/dialog_helper.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/helpers/dialog_helper.py rename to tests/experimental/101.corebot-bert-bidaf/bot/helpers/dialog_helper.py diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/main.py b/tests/experimental/101.corebot-bert-bidaf/bot/main.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/bot/main.py rename to tests/experimental/101.corebot-bert-bidaf/bot/main.py diff --git a/samples/experimental/101.corebot-bert-bidaf/requirements.txt b/tests/experimental/101.corebot-bert-bidaf/bot/requirements.txt similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/requirements.txt rename to tests/experimental/101.corebot-bert-bidaf/bot/requirements.txt diff --git a/samples/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml b/tests/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml similarity index 94% rename from samples/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml rename to tests/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml index 29c6de853..55599a3c9 100644 --- a/samples/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml +++ b/tests/experimental/101.corebot-bert-bidaf/docker/docker-compose.yml @@ -1,20 +1,20 @@ -version: '3.7' -services: - bot: - build: - context: . - dockerfile: Dockerfile_bot - ports: - - "3978:3978" - links: - - api - environment: - MODEL_RUNTIME_API_HOST : api - - api: - build: - context: . - dockerfile: Dockerfile_model_runtime - ports: - - "8880:8880" - +version: '3.7' +services: + bot: + build: + context: . + dockerfile: Dockerfile_bot + ports: + - "3978:3978" + links: + - api + environment: + MODEL_RUNTIME_API_HOST : api + + api: + build: + context: . + dockerfile: Dockerfile_model_runtime + ports: + - "8880:8880" + diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_complete.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_complete.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_complete.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_complete.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_runtime.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_runtime.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_runtime.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_runtime.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_train.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_train.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_train.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bert_train.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bidaf_runtime.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bidaf_runtime.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bidaf_runtime.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_bidaf_runtime.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_model_nav.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_model_nav.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_model_nav.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_model_nav.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_run_all_cells.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_run_all_cells.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_run_all_cells.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_run_all_cells.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_select_kernel.PNG b/tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_select_kernel.PNG similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/media/jupyter_lab_select_kernel.PNG rename to tests/experimental/101.corebot-bert-bidaf/media/jupyter_lab_select_kernel.PNG diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py index 340b35e3e..e6dd2b2d7 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/about.py @@ -1,14 +1,14 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Package information.""" -import os - -__title__ = "model_corebot101" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Package information.""" +import os + +__title__ = "model_corebot101" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py index b339b691f..f9d109364 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/__init__.py @@ -1,8 +1,8 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .bert_util import BertUtil -from .input_example import InputExample -from .input_features import InputFeatures - -__all__ = ["BertUtil", "InputExample", "InputFeatures"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .bert_util import BertUtil +from .input_example import InputExample +from .input_features import InputFeatures + +__all__ = ["BertUtil", "InputExample", "InputFeatures"] diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py index ee9ab630e..800cee607 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/bert_util.py @@ -1,156 +1,156 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import logging -from typing import List - -from .input_features import InputFeatures -from scipy.stats import pearsonr, spearmanr -from sklearn.metrics import f1_score - - -class BertUtil: - logger = logging.getLogger(__name__) - - @classmethod - def convert_examples_to_features( - cls, examples, label_list, max_seq_length, tokenizer, output_mode - ) -> List: - """Loads a data file into a list of `InputBatch`s.""" - - label_map = {label: i for i, label in enumerate(label_list)} - - features = [] - for (ex_index, example) in enumerate(examples): - if ex_index % 10000 == 0: - cls.logger.info("Writing example %d of %d" % (ex_index, len(examples))) - - tokens_a = tokenizer.tokenize(example.text_a) - - tokens_b = None - if example.text_b: - tokens_b = tokenizer.tokenize(example.text_b) - # Modifies `tokens_a` and `tokens_b` in place so that the total - # length is less than the specified length. - # Account for [CLS], [SEP], [SEP] with "- 3" - BertUtil._truncate_seq_pair(tokens_a, tokens_b, max_seq_length - 3) - else: - # Account for [CLS] and [SEP] with "- 2" - if len(tokens_a) > max_seq_length - 2: - tokens_a = tokens_a[: (max_seq_length - 2)] - - # The convention in BERT is: - # (a) For sequence pairs: - # tokens: [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP] - # type_ids: 0 0 0 0 0 0 0 0 1 1 1 1 1 1 - # (b) For single sequences: - # tokens: [CLS] the dog is hairy . [SEP] - # type_ids: 0 0 0 0 0 0 0 - # - # Where "type_ids" are used to indicate whether this is the first - # sequence or the second sequence. The embedding vectors for `type=0` and - # `type=1` were learned during pre-training and are added to the wordpiece - # embedding vector (and position vector). This is not *strictly* necessary - # since the [SEP] token unambiguously separates the sequences, but it makes - # it easier for the model to learn the concept of sequences. - # - # For classification tasks, the first vector (corresponding to [CLS]) is - # used as as the "sentence vector". Note that this only makes sense because - # the entire model is fine-tuned. - tokens = ["[CLS]"] + tokens_a + ["[SEP]"] - segment_ids = [0] * len(tokens) - - if tokens_b: - tokens += tokens_b + ["[SEP]"] - segment_ids += [1] * (len(tokens_b) + 1) - - input_ids = tokenizer.convert_tokens_to_ids(tokens) - - # The mask has 1 for real tokens and 0 for padding tokens. Only real - # tokens are attended to. - input_mask = [1] * len(input_ids) - - # Zero-pad up to the sequence length. - padding = [0] * (max_seq_length - len(input_ids)) - input_ids += padding - input_mask += padding - segment_ids += padding - - assert len(input_ids) == max_seq_length - assert len(input_mask) == max_seq_length - assert len(segment_ids) == max_seq_length - - if output_mode == "classification": - label_id = label_map[example.label] - elif output_mode == "regression": - label_id = float(example.label) - else: - raise KeyError(output_mode) - - if ex_index < 5: - cls.logger.info("*** Example ***") - cls.logger.info("guid: %s" % (example.guid)) - cls.logger.info("tokens: %s" % " ".join([str(x) for x in tokens])) - cls.logger.info("input_ids: %s" % " ".join([str(x) for x in input_ids])) - cls.logger.info( - "input_mask: %s" % " ".join([str(x) for x in input_mask]) - ) - cls.logger.info( - "segment_ids: %s" % " ".join([str(x) for x in segment_ids]) - ) - cls.logger.info("label: %s (id = %d)" % (example.label, label_id)) - - features.append( - InputFeatures( - input_ids=input_ids, - input_mask=input_mask, - segment_ids=segment_ids, - label_id=label_id, - ) - ) - return features - - @staticmethod - def _truncate_seq_pair(tokens_a, tokens_b, max_length): - """Truncates a sequence pair in place to the maximum length.""" - - # This is a simple heuristic which will always truncate the longer sequence - # one token at a time. This makes more sense than truncating an equal percent - # of tokens from each, since if one sequence is very short then each token - # that's truncated likely contains more information than a longer sequence. - while True: - total_length = len(tokens_a) + len(tokens_b) - if total_length <= max_length: - break - if len(tokens_a) > len(tokens_b): - tokens_a.pop() - else: - tokens_b.pop() - - @staticmethod - def simple_accuracy(preds, labels): - return (preds == labels).mean() - - @staticmethod - def acc_and_f1(preds, labels): - acc = BertUtil.simple_accuracy(preds, labels) - f1 = f1_score(y_true=labels, y_pred=preds) - return {"acc": acc, "f1": f1, "acc_and_f1": (acc + f1) / 2} - - @staticmethod - def pearson_and_spearman(preds, labels): - pearson_corr = pearsonr(preds, labels)[0] - spearman_corr = spearmanr(preds, labels)[0] - return { - "pearson": pearson_corr, - "spearmanr": spearman_corr, - "corr": (pearson_corr + spearman_corr) / 2, - } - - @staticmethod - def compute_metrics(task_name, preds, labels): - assert len(preds) == len(labels) - if task_name == "flight_booking": - return BertUtil.acc_and_f1(preds, labels) - else: - raise KeyError(task_name) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +from typing import List + +from .input_features import InputFeatures +from scipy.stats import pearsonr, spearmanr +from sklearn.metrics import f1_score + + +class BertUtil: + logger = logging.getLogger(__name__) + + @classmethod + def convert_examples_to_features( + cls, examples, label_list, max_seq_length, tokenizer, output_mode + ) -> List: + """Loads a data file into a list of `InputBatch`s.""" + + label_map = {label: i for i, label in enumerate(label_list)} + + features = [] + for (ex_index, example) in enumerate(examples): + if ex_index % 10000 == 0: + cls.logger.info("Writing example %d of %d" % (ex_index, len(examples))) + + tokens_a = tokenizer.tokenize(example.text_a) + + tokens_b = None + if example.text_b: + tokens_b = tokenizer.tokenize(example.text_b) + # Modifies `tokens_a` and `tokens_b` in place so that the total + # length is less than the specified length. + # Account for [CLS], [SEP], [SEP] with "- 3" + BertUtil._truncate_seq_pair(tokens_a, tokens_b, max_seq_length - 3) + else: + # Account for [CLS] and [SEP] with "- 2" + if len(tokens_a) > max_seq_length - 2: + tokens_a = tokens_a[: (max_seq_length - 2)] + + # The convention in BERT is: + # (a) For sequence pairs: + # tokens: [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP] + # type_ids: 0 0 0 0 0 0 0 0 1 1 1 1 1 1 + # (b) For single sequences: + # tokens: [CLS] the dog is hairy . [SEP] + # type_ids: 0 0 0 0 0 0 0 + # + # Where "type_ids" are used to indicate whether this is the first + # sequence or the second sequence. The embedding vectors for `type=0` and + # `type=1` were learned during pre-training and are added to the wordpiece + # embedding vector (and position vector). This is not *strictly* necessary + # since the [SEP] token unambiguously separates the sequences, but it makes + # it easier for the model to learn the concept of sequences. + # + # For classification tasks, the first vector (corresponding to [CLS]) is + # used as as the "sentence vector". Note that this only makes sense because + # the entire model is fine-tuned. + tokens = ["[CLS]"] + tokens_a + ["[SEP]"] + segment_ids = [0] * len(tokens) + + if tokens_b: + tokens += tokens_b + ["[SEP]"] + segment_ids += [1] * (len(tokens_b) + 1) + + input_ids = tokenizer.convert_tokens_to_ids(tokens) + + # The mask has 1 for real tokens and 0 for padding tokens. Only real + # tokens are attended to. + input_mask = [1] * len(input_ids) + + # Zero-pad up to the sequence length. + padding = [0] * (max_seq_length - len(input_ids)) + input_ids += padding + input_mask += padding + segment_ids += padding + + assert len(input_ids) == max_seq_length + assert len(input_mask) == max_seq_length + assert len(segment_ids) == max_seq_length + + if output_mode == "classification": + label_id = label_map[example.label] + elif output_mode == "regression": + label_id = float(example.label) + else: + raise KeyError(output_mode) + + if ex_index < 5: + cls.logger.info("*** Example ***") + cls.logger.info("guid: %s" % (example.guid)) + cls.logger.info("tokens: %s" % " ".join([str(x) for x in tokens])) + cls.logger.info("input_ids: %s" % " ".join([str(x) for x in input_ids])) + cls.logger.info( + "input_mask: %s" % " ".join([str(x) for x in input_mask]) + ) + cls.logger.info( + "segment_ids: %s" % " ".join([str(x) for x in segment_ids]) + ) + cls.logger.info("label: %s (id = %d)" % (example.label, label_id)) + + features.append( + InputFeatures( + input_ids=input_ids, + input_mask=input_mask, + segment_ids=segment_ids, + label_id=label_id, + ) + ) + return features + + @staticmethod + def _truncate_seq_pair(tokens_a, tokens_b, max_length): + """Truncates a sequence pair in place to the maximum length.""" + + # This is a simple heuristic which will always truncate the longer sequence + # one token at a time. This makes more sense than truncating an equal percent + # of tokens from each, since if one sequence is very short then each token + # that's truncated likely contains more information than a longer sequence. + while True: + total_length = len(tokens_a) + len(tokens_b) + if total_length <= max_length: + break + if len(tokens_a) > len(tokens_b): + tokens_a.pop() + else: + tokens_b.pop() + + @staticmethod + def simple_accuracy(preds, labels): + return (preds == labels).mean() + + @staticmethod + def acc_and_f1(preds, labels): + acc = BertUtil.simple_accuracy(preds, labels) + f1 = f1_score(y_true=labels, y_pred=preds) + return {"acc": acc, "f1": f1, "acc_and_f1": (acc + f1) / 2} + + @staticmethod + def pearson_and_spearman(preds, labels): + pearson_corr = pearsonr(preds, labels)[0] + spearman_corr = spearmanr(preds, labels)[0] + return { + "pearson": pearson_corr, + "spearmanr": spearman_corr, + "corr": (pearson_corr + spearman_corr) / 2, + } + + @staticmethod + def compute_metrics(task_name, preds, labels): + assert len(preds) == len(labels) + if task_name == "flight_booking": + return BertUtil.acc_and_f1(preds, labels) + else: + raise KeyError(task_name) diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py index b674642f3..63410a11f 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_example.py @@ -1,23 +1,23 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class InputExample(object): - """A single training/test example for sequence classification.""" - - def __init__(self, guid, text_a, text_b=None, label=None): - """Constructs a InputExample. - - Args: - guid: Unique id for the example. - text_a: string. The untokenized text of the first sequence. For single - sequence tasks, only this sequence must be specified. - text_b: (Optional) string. The untokenized text of the second sequence. - Only must be specified for sequence pair tasks. - label: (Optional) string. The label of the example. This should be - specified for train and dev examples, but not for test examples. - """ - self.guid = guid - self.text_a = text_a - self.text_b = text_b - self.label = label +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class InputExample(object): + """A single training/test example for sequence classification.""" + + def __init__(self, guid, text_a, text_b=None, label=None): + """Constructs a InputExample. + + Args: + guid: Unique id for the example. + text_a: string. The untokenized text of the first sequence. For single + sequence tasks, only this sequence must be specified. + text_b: (Optional) string. The untokenized text of the second sequence. + Only must be specified for sequence pair tasks. + label: (Optional) string. The label of the example. This should be + specified for train and dev examples, but not for test examples. + """ + self.guid = guid + self.text_a = text_a + self.text_b = text_b + self.label = label diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py index 97be63ecf..0138e75e2 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/common/input_features.py @@ -1,12 +1,12 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class InputFeatures(object): - """A single set of features of data.""" - - def __init__(self, input_ids, input_mask, segment_ids, label_id): - self.input_ids = input_ids - self.input_mask = input_mask - self.segment_ids = segment_ids - self.label_id = label_id +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class InputFeatures(object): + """A single set of features of data.""" + + def __init__(self, input_ids, input_mask, segment_ids, label_id): + self.input_ids = input_ids + self.input_mask = input_mask + self.segment_ids = segment_ids + self.label_id = label_id diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py index 8c5946fe4..22497eea5 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .bert_model_runtime import BertModelRuntime - -__all__ = ["BertModelRuntime"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .bert_model_runtime import BertModelRuntime + +__all__ = ["BertModelRuntime"] diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py index 112c1167b..bb66ddc07 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/model_runtime/bert_model_runtime.py @@ -1,122 +1,122 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Bert model runtime.""" - -import os -import sys -from typing import List -import numpy as np -import torch -from torch.utils.data import DataLoader, SequentialSampler, TensorDataset -from pytorch_pretrained_bert import BertForSequenceClassification, BertTokenizer -from model_corebot101.bert.common.bert_util import BertUtil -from model_corebot101.bert.common.input_example import InputExample - - -class BertModelRuntime: - """Model runtime for the Bert model.""" - - def __init__( - self, - model_dir: str, - label_list: List[str], - max_seq_length: int = 128, - output_mode: str = "classification", - no_cuda: bool = False, - do_lower_case: bool = True, - ): - self.model_dir = model_dir - self.label_list = label_list - self.num_labels = len(self.label_list) - self.max_seq_length = max_seq_length - self.output_mode = output_mode - self.no_cuda = no_cuda - self.do_lower_case = do_lower_case - self._load_model() - - # pylint:disable=unused-argument - @staticmethod - def init_bert(bert_model_dir: str) -> bool: - """ Handle any one-time initlization """ - if os.path.isdir(bert_model_dir): - print("bert model directory already present..", file=sys.stderr) - else: - print("Creating bert model directory..", file=sys.stderr) - os.makedirs(bert_model_dir, exist_ok=True) - return True - - def _load_model(self) -> None: - self.device = torch.device( - "cuda" if torch.cuda.is_available() and not self.no_cuda else "cpu" - ) - self.n_gpu = torch.cuda.device_count() - - # Load a trained model and vocabulary that you have fine-tuned - self.model = BertForSequenceClassification.from_pretrained( - self.model_dir, num_labels=self.num_labels - ) - self.tokenizer = BertTokenizer.from_pretrained( - self.model_dir, do_lower_case=self.do_lower_case - ) - self.model.to(self.device) - - def serve(self, query: str) -> str: - example = InputExample( - guid="", text_a=query, text_b=None, label=self.label_list[0] - ) - examples = [example] - - eval_features = BertUtil.convert_examples_to_features( - examples, - self.label_list, - self.max_seq_length, - self.tokenizer, - self.output_mode, - ) - all_input_ids = torch.tensor( - [f.input_ids for f in eval_features], dtype=torch.long - ) - all_input_mask = torch.tensor( - [f.input_mask for f in eval_features], dtype=torch.long - ) - all_segment_ids = torch.tensor( - [f.segment_ids for f in eval_features], dtype=torch.long - ) - - if self.output_mode == "classification": - all_label_ids = torch.tensor( - [f.label_id for f in eval_features], dtype=torch.long - ) - - eval_data = TensorDataset( - all_input_ids, all_input_mask, all_segment_ids, all_label_ids - ) - # Run prediction for full data - eval_sampler = SequentialSampler(eval_data) - eval_dataloader = DataLoader(eval_data, sampler=eval_sampler, batch_size=1) - - self.model.eval() - nb_eval_steps = 0 - preds = [] - - for input_ids, input_mask, segment_ids, label_ids in eval_dataloader: - input_ids = input_ids.to(self.device) - input_mask = input_mask.to(self.device) - segment_ids = segment_ids.to(self.device) - - with torch.no_grad(): - logits = self.model(input_ids, segment_ids, input_mask, labels=None) - - nb_eval_steps += 1 - if len(preds) == 0: - preds.append(logits.detach().cpu().numpy()) - else: - preds[0] = np.append(preds[0], logits.detach().cpu().numpy(), axis=0) - - preds = preds[0] - if self.output_mode == "classification": - preds = np.argmax(preds, axis=1) - - label_id = preds[0] - pred_label = self.label_list[label_id] - return pred_label +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Bert model runtime.""" + +import os +import sys +from typing import List +import numpy as np +import torch +from torch.utils.data import DataLoader, SequentialSampler, TensorDataset +from pytorch_pretrained_bert import BertForSequenceClassification, BertTokenizer +from model_corebot101.bert.common.bert_util import BertUtil +from model_corebot101.bert.common.input_example import InputExample + + +class BertModelRuntime: + """Model runtime for the Bert model.""" + + def __init__( + self, + model_dir: str, + label_list: List[str], + max_seq_length: int = 128, + output_mode: str = "classification", + no_cuda: bool = False, + do_lower_case: bool = True, + ): + self.model_dir = model_dir + self.label_list = label_list + self.num_labels = len(self.label_list) + self.max_seq_length = max_seq_length + self.output_mode = output_mode + self.no_cuda = no_cuda + self.do_lower_case = do_lower_case + self._load_model() + + # pylint:disable=unused-argument + @staticmethod + def init_bert(bert_model_dir: str) -> bool: + """ Handle any one-time initlization """ + if os.path.isdir(bert_model_dir): + print("bert model directory already present..", file=sys.stderr) + else: + print("Creating bert model directory..", file=sys.stderr) + os.makedirs(bert_model_dir, exist_ok=True) + return True + + def _load_model(self) -> None: + self.device = torch.device( + "cuda" if torch.cuda.is_available() and not self.no_cuda else "cpu" + ) + self.n_gpu = torch.cuda.device_count() + + # Load a trained model and vocabulary that you have fine-tuned + self.model = BertForSequenceClassification.from_pretrained( + self.model_dir, num_labels=self.num_labels + ) + self.tokenizer = BertTokenizer.from_pretrained( + self.model_dir, do_lower_case=self.do_lower_case + ) + self.model.to(self.device) + + def serve(self, query: str) -> str: + example = InputExample( + guid="", text_a=query, text_b=None, label=self.label_list[0] + ) + examples = [example] + + eval_features = BertUtil.convert_examples_to_features( + examples, + self.label_list, + self.max_seq_length, + self.tokenizer, + self.output_mode, + ) + all_input_ids = torch.tensor( + [f.input_ids for f in eval_features], dtype=torch.long + ) + all_input_mask = torch.tensor( + [f.input_mask for f in eval_features], dtype=torch.long + ) + all_segment_ids = torch.tensor( + [f.segment_ids for f in eval_features], dtype=torch.long + ) + + if self.output_mode == "classification": + all_label_ids = torch.tensor( + [f.label_id for f in eval_features], dtype=torch.long + ) + + eval_data = TensorDataset( + all_input_ids, all_input_mask, all_segment_ids, all_label_ids + ) + # Run prediction for full data + eval_sampler = SequentialSampler(eval_data) + eval_dataloader = DataLoader(eval_data, sampler=eval_sampler, batch_size=1) + + self.model.eval() + nb_eval_steps = 0 + preds = [] + + for input_ids, input_mask, segment_ids, label_ids in eval_dataloader: + input_ids = input_ids.to(self.device) + input_mask = input_mask.to(self.device) + segment_ids = segment_ids.to(self.device) + + with torch.no_grad(): + logits = self.model(input_ids, segment_ids, input_mask, labels=None) + + nb_eval_steps += 1 + if len(preds) == 0: + preds.append(logits.detach().cpu().numpy()) + else: + preds[0] = np.append(preds[0], logits.detach().cpu().numpy(), axis=0) + + preds = preds[0] + if self.output_mode == "classification": + preds = np.argmax(preds, axis=1) + + label_id = preds[0] + pred_label = self.label_list[label_id] + return pred_label diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt similarity index 92% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt index 10f898f8b..f9d97a146 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/requirements.txt @@ -1,3 +1,3 @@ -torch -tqdm -pytorch-pretrained-bert +torch +tqdm +pytorch-pretrained-bert diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py index 277890a19..1bd0ac221 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/__init__.py @@ -1,9 +1,9 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Bert tuning training.""" - -from .args import Args -from .bert_train_eval import BertTrainEval -from .flight_booking_processor import FlightBookingProcessor - -__all__ = ["Args", "BertTrainEval", "FlightBookingProcessor"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Bert tuning training.""" + +from .args import Args +from .bert_train_eval import BertTrainEval +from .flight_booking_processor import FlightBookingProcessor + +__all__ = ["Args", "BertTrainEval", "FlightBookingProcessor"] diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py index c49036572..3d0f77811 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/args.py @@ -1,58 +1,58 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Arguments for the model. """ - -import os -import sys -from pathlib import Path - -# pylint:disable=line-too-long -class Args: - """Arguments for the model.""" - - training_data_dir: str = None - bert_model: str = None - task_name: str = None - model_dir: str = None - cleanup_output_dir: bool = False - cache_dir: str = "" - max_seq_length: int = 128 - do_train: bool = None - do_eval: bool = None - do_lower_case: bool = None - train_batch_size: int = 4 - eval_batch_size: int = 8 - learning_rate: float = 5e-5 - num_train_epochs: float = 3.0 - warmup_proportion: float = 0.1 - no_cuda: bool = None - local_rank: int = -1 - seed: int = 42 - gradient_accumulation_steps: int = 1 - fp16: bool = None - loss_scale: float = 0 - - @classmethod - def for_flight_booking( - cls, - training_data_dir: str = os.path.abspath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "../training_data") - ), - task_name: str = "flight_booking", - ): - """Return the flight booking args.""" - args = cls() - - args.training_data_dir = training_data_dir - args.task_name = task_name - home_dir = str(Path.home()) - args.model_dir = os.path.abspath(os.path.join(home_dir, "models/bert")) - args.bert_model = "bert-base-uncased" - args.do_lower_case = True - - print( - f"Bert Model training_data_dir is set to {args.training_data_dir}", - file=sys.stderr, - ) - print(f"Bert Model model_dir is set to {args.model_dir}", file=sys.stderr) - return args +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Arguments for the model. """ + +import os +import sys +from pathlib import Path + +# pylint:disable=line-too-long +class Args: + """Arguments for the model.""" + + training_data_dir: str = None + bert_model: str = None + task_name: str = None + model_dir: str = None + cleanup_output_dir: bool = False + cache_dir: str = "" + max_seq_length: int = 128 + do_train: bool = None + do_eval: bool = None + do_lower_case: bool = None + train_batch_size: int = 4 + eval_batch_size: int = 8 + learning_rate: float = 5e-5 + num_train_epochs: float = 3.0 + warmup_proportion: float = 0.1 + no_cuda: bool = None + local_rank: int = -1 + seed: int = 42 + gradient_accumulation_steps: int = 1 + fp16: bool = None + loss_scale: float = 0 + + @classmethod + def for_flight_booking( + cls, + training_data_dir: str = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "../training_data") + ), + task_name: str = "flight_booking", + ): + """Return the flight booking args.""" + args = cls() + + args.training_data_dir = training_data_dir + args.task_name = task_name + home_dir = str(Path.home()) + args.model_dir = os.path.abspath(os.path.join(home_dir, "models/bert")) + args.bert_model = "bert-base-uncased" + args.do_lower_case = True + + print( + f"Bert Model training_data_dir is set to {args.training_data_dir}", + file=sys.stderr, + ) + print(f"Bert Model model_dir is set to {args.model_dir}", file=sys.stderr) + return args diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py index fde3fce80..11d6d558e 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/bert_train_eval.py @@ -1,375 +1,375 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import logging -import os -import random -import shutil -import numpy as np -import torch -from .args import Args - -from model_corebot101.bert.common.bert_util import BertUtil -from model_corebot101.bert.train.flight_booking_processor import FlightBookingProcessor -from pytorch_pretrained_bert.file_utils import ( - CONFIG_NAME, - PYTORCH_PRETRAINED_BERT_CACHE, - WEIGHTS_NAME, -) -from pytorch_pretrained_bert.modeling import ( - BertForSequenceClassification, - BertPreTrainedModel, -) -from pytorch_pretrained_bert.optimization import BertAdam -from pytorch_pretrained_bert.tokenization import BertTokenizer -from torch.nn import CrossEntropyLoss -from torch.utils.data import DataLoader, RandomSampler, SequentialSampler, TensorDataset -from torch.utils.data.distributed import DistributedSampler - -from tqdm import tqdm, trange - - -class BertTrainEval: - logger = logging.getLogger(__name__) - - def __init__(self, args: Args): - self.processor = FlightBookingProcessor() - self.output_mode = "classification" - self.args = args - self._prepare() - self.model = self._prepare_model() - - @classmethod - def train_eval(cls, cleanup_output_dir: bool = False) -> None: - # uncomment the following line for debugging. - # import pdb; pdb.set_trace() - args = Args.for_flight_booking() - args.do_train = True - args.do_eval = True - args.cleanup_output_dir = cleanup_output_dir - bert = cls(args) - bert.train() - bert.eval() - - def train(self) -> None: - # Prepare optimizer - param_optimizer = list(self.model.named_parameters()) - no_decay = ["bias", "LayerNorm.bias", "LayerNorm.weight"] - optimizer_grouped_parameters = [ - { - "params": [ - p for n, p in param_optimizer if not any(nd in n for nd in no_decay) - ], - "weight_decay": 0.01, - }, - { - "params": [ - p for n, p in param_optimizer if any(nd in n for nd in no_decay) - ], - "weight_decay": 0.0, - }, - ] - optimizer = BertAdam( - optimizer_grouped_parameters, - lr=self.args.learning_rate, - warmup=self.args.warmup_proportion, - t_total=self.num_train_optimization_steps, - ) - - global_step: int = 0 - nb_tr_steps = 0 - tr_loss: float = 0 - train_features = BertUtil.convert_examples_to_features( - self.train_examples, - self.label_list, - self.args.max_seq_length, - self.tokenizer, - self.output_mode, - ) - self.logger.info("***** Running training *****") - self.logger.info(" Num examples = %d", len(self.train_examples)) - self.logger.info(" Batch size = %d", self.args.train_batch_size) - self.logger.info(" Num steps = %d", self.num_train_optimization_steps) - all_input_ids = torch.tensor( - [f.input_ids for f in train_features], dtype=torch.long - ) - all_input_mask = torch.tensor( - [f.input_mask for f in train_features], dtype=torch.long - ) - all_segment_ids = torch.tensor( - [f.segment_ids for f in train_features], dtype=torch.long - ) - - if self.output_mode == "classification": - all_label_ids = torch.tensor( - [f.label_id for f in train_features], dtype=torch.long - ) - - train_data = TensorDataset( - all_input_ids, all_input_mask, all_segment_ids, all_label_ids - ) - if self.args.local_rank == -1: - train_sampler = RandomSampler(train_data) - else: - train_sampler = DistributedSampler(train_data) - train_dataloader = DataLoader( - train_data, sampler=train_sampler, batch_size=self.args.train_batch_size - ) - - self.model.train() - for _ in trange(int(self.args.num_train_epochs), desc="Epoch"): - tr_loss = 0 - nb_tr_examples, nb_tr_steps = 0, 0 - for step, batch in enumerate(tqdm(train_dataloader, desc="Iteration")): - batch = tuple(t.to(self.device) for t in batch) - input_ids, input_mask, segment_ids, label_ids = batch - - # define a new function to compute loss values for both output_modes - logits = self.model(input_ids, segment_ids, input_mask, labels=None) - - if self.output_mode == "classification": - loss_fct = CrossEntropyLoss() - loss = loss_fct( - logits.view(-1, self.num_labels), label_ids.view(-1) - ) - - if self.args.gradient_accumulation_steps > 1: - loss = loss / self.args.gradient_accumulation_steps - - loss.backward() - - tr_loss += loss.item() - nb_tr_examples += input_ids.size(0) - nb_tr_steps += 1 - if (step + 1) % self.args.gradient_accumulation_steps == 0: - optimizer.step() - optimizer.zero_grad() - global_step += 1 - - if self.args.local_rank == -1 or torch.distributed.get_rank() == 0: - # Save a trained model, configuration and tokenizer - model_to_save = ( - self.model.module if hasattr(self.model, "module") else self.model - ) # Only save the model it-self - - # If we save using the predefined names, we can load using `from_pretrained` - output_model_file = os.path.join(self.args.model_dir, WEIGHTS_NAME) - output_config_file = os.path.join(self.args.model_dir, CONFIG_NAME) - - torch.save(model_to_save.state_dict(), output_model_file) - model_to_save.config.to_json_file(output_config_file) - self.tokenizer.save_vocabulary(self.args.model_dir) - - # Load a trained model and vocabulary that you have fine-tuned - self.model = BertForSequenceClassification.from_pretrained( - self.args.model_dir, num_labels=self.num_labels - ) - self.tokenizer = BertTokenizer.from_pretrained( - self.args.model_dir, do_lower_case=self.args.do_lower_case - ) - else: - self.model = BertForSequenceClassification.from_pretrained( - self.args.bert_model, num_labels=self.num_labels - ) - self.model.to(self.device) - - self.tr_loss, self.global_step = tr_loss, global_step - - self.logger.info("DONE TRAINING."), - - def eval(self) -> None: - if not (self.args.local_rank == -1 or torch.distributed.get_rank() == 0): - return - - eval_examples = self.processor.get_dev_examples(self.args.training_data_dir) - eval_features = BertUtil.convert_examples_to_features( - eval_examples, - self.label_list, - self.args.max_seq_length, - self.tokenizer, - self.output_mode, - ) - self.logger.info("***** Running evaluation *****") - self.logger.info(" Num examples = %d", len(eval_examples)) - self.logger.info(" Batch size = %d", self.args.eval_batch_size) - all_input_ids = torch.tensor( - [f.input_ids for f in eval_features], dtype=torch.long - ) - all_input_mask = torch.tensor( - [f.input_mask for f in eval_features], dtype=torch.long - ) - all_segment_ids = torch.tensor( - [f.segment_ids for f in eval_features], dtype=torch.long - ) - - if self.output_mode == "classification": - all_label_ids = torch.tensor( - [f.label_id for f in eval_features], dtype=torch.long - ) - - eval_data = TensorDataset( - all_input_ids, all_input_mask, all_segment_ids, all_label_ids - ) - # Run prediction for full data - eval_sampler = SequentialSampler(eval_data) - eval_dataloader = DataLoader( - eval_data, sampler=eval_sampler, batch_size=self.args.eval_batch_size - ) - - self.model.eval() - eval_loss = 0 - nb_eval_steps = 0 - preds = [] - - for input_ids, input_mask, segment_ids, label_ids in tqdm( - eval_dataloader, desc="Evaluating" - ): - input_ids = input_ids.to(self.device) - input_mask = input_mask.to(self.device) - segment_ids = segment_ids.to(self.device) - label_ids = label_ids.to(self.device) - - with torch.no_grad(): - logits = self.model(input_ids, segment_ids, input_mask, labels=None) - - # create eval loss and other metric required by the task - if self.output_mode == "classification": - loss_fct = CrossEntropyLoss() - tmp_eval_loss = loss_fct( - logits.view(-1, self.num_labels), label_ids.view(-1) - ) - - eval_loss += tmp_eval_loss.mean().item() - nb_eval_steps += 1 - if len(preds) == 0: - preds.append(logits.detach().cpu().numpy()) - else: - preds[0] = np.append(preds[0], logits.detach().cpu().numpy(), axis=0) - - eval_loss = eval_loss / nb_eval_steps - preds = preds[0] - if self.output_mode == "classification": - preds = np.argmax(preds, axis=1) - result = BertUtil.compute_metrics(self.task_name, preds, all_label_ids.numpy()) - loss = self.tr_loss / self.global_step if self.args.do_train else None - - result["eval_loss"] = eval_loss - result["global_step"] = self.global_step - result["loss"] = loss - - output_eval_file = os.path.join(self.args.model_dir, "eval_results.txt") - with open(output_eval_file, "w") as writer: - self.logger.info("***** Eval results *****") - for key in sorted(result.keys()): - self.logger.info(" %s = %s", key, str(result[key])) - writer.write("%s = %s\n" % (key, str(result[key]))) - - self.logger.info("DONE EVALUATING.") - - def _prepare(self, cleanup_output_dir: bool = False) -> None: - if self.args.local_rank == -1 or self.args.no_cuda: - self.device = torch.device( - "cuda" if torch.cuda.is_available() and not self.args.no_cuda else "cpu" - ) - self.n_gpu = torch.cuda.device_count() - else: - torch.cuda.set_device(self.args.local_rank) - self.device = torch.device("cuda", self.args.local_rank) - self.n_gpu = 1 - # Initializes the distributed backend which will take care of sychronizing nodes/GPUs - torch.distributed.init_process_group(backend="nccl") - - logging.basicConfig( - format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", - datefmt="%m/%d/%Y %H:%M:%S", - level=logging.INFO if self.args.local_rank in [-1, 0] else logging.WARN, - ) - - self.logger.info( - "device: {} n_gpu: {}, distributed training: {}, 16-bits training: {}".format( - self.device, - self.n_gpu, - bool(self.args.local_rank != -1), - self.args.fp16, - ) - ) - - if self.args.gradient_accumulation_steps < 1: - raise ValueError( - "Invalid gradient_accumulation_steps parameter: {}, should be >= 1".format( - self.args.gradient_accumulation_steps - ) - ) - - self.args.train_batch_size = ( - self.args.train_batch_size // self.args.gradient_accumulation_steps - ) - - random.seed(self.args.seed) - np.random.seed(self.args.seed) - torch.manual_seed(self.args.seed) - if self.n_gpu > 0: - torch.cuda.manual_seed_all(self.args.seed) - - if not self.args.do_train and not self.args.do_eval: - raise ValueError("At least one of `do_train` or `do_eval` must be True.") - - if self.args.cleanup_output_dir: - if os.path.exists(self.args.model_dir): - shutil.rmtree(self.args.model_dir) - - if ( - os.path.exists(self.args.model_dir) - and os.listdir(self.args.model_dir) - and self.args.do_train - ): - raise ValueError( - "Output directory ({}) already exists and is not empty.".format( - self.args.model_dir - ) - ) - if not os.path.exists(self.args.model_dir): - os.makedirs(self.args.model_dir) - - self.task_name = self.args.task_name.lower() - - self.label_list = self.processor.get_labels() - self.num_labels = len(self.label_list) - - self.tokenizer = BertTokenizer.from_pretrained( - self.args.bert_model, do_lower_case=self.args.do_lower_case - ) - - self.train_examples = None - self.num_train_optimization_steps = None - if self.args.do_train: - self.train_examples = self.processor.get_train_examples( - self.args.training_data_dir - ) - self.num_train_optimization_steps = ( - int( - len(self.train_examples) - / self.args.train_batch_size - / self.args.gradient_accumulation_steps - ) - * self.args.num_train_epochs - ) - if self.args.local_rank != -1: - self.num_train_optimization_steps = ( - self.num_train_optimization_steps - // torch.distributed.get_world_size() - ) - - def _prepare_model(self) -> BertPreTrainedModel: - if self.args.cache_dir: - cache_dir = self.args.cache_dir - else: - cache_dir = os.path.join( - str(PYTORCH_PRETRAINED_BERT_CACHE), - f"distributed_{self.args.local_rank}", - ) - model = BertForSequenceClassification.from_pretrained( - self.args.bert_model, cache_dir=cache_dir, num_labels=self.num_labels - ) - model.to(self.device) - return model +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +import os +import random +import shutil +import numpy as np +import torch +from .args import Args + +from model_corebot101.bert.common.bert_util import BertUtil +from model_corebot101.bert.train.flight_booking_processor import FlightBookingProcessor +from pytorch_pretrained_bert.file_utils import ( + CONFIG_NAME, + PYTORCH_PRETRAINED_BERT_CACHE, + WEIGHTS_NAME, +) +from pytorch_pretrained_bert.modeling import ( + BertForSequenceClassification, + BertPreTrainedModel, +) +from pytorch_pretrained_bert.optimization import BertAdam +from pytorch_pretrained_bert.tokenization import BertTokenizer +from torch.nn import CrossEntropyLoss +from torch.utils.data import DataLoader, RandomSampler, SequentialSampler, TensorDataset +from torch.utils.data.distributed import DistributedSampler + +from tqdm import tqdm, trange + + +class BertTrainEval: + logger = logging.getLogger(__name__) + + def __init__(self, args: Args): + self.processor = FlightBookingProcessor() + self.output_mode = "classification" + self.args = args + self._prepare() + self.model = self._prepare_model() + + @classmethod + def train_eval(cls, cleanup_output_dir: bool = False) -> None: + # uncomment the following line for debugging. + # import pdb; pdb.set_trace() + args = Args.for_flight_booking() + args.do_train = True + args.do_eval = True + args.cleanup_output_dir = cleanup_output_dir + bert = cls(args) + bert.train() + bert.eval() + + def train(self) -> None: + # Prepare optimizer + param_optimizer = list(self.model.named_parameters()) + no_decay = ["bias", "LayerNorm.bias", "LayerNorm.weight"] + optimizer_grouped_parameters = [ + { + "params": [ + p for n, p in param_optimizer if not any(nd in n for nd in no_decay) + ], + "weight_decay": 0.01, + }, + { + "params": [ + p for n, p in param_optimizer if any(nd in n for nd in no_decay) + ], + "weight_decay": 0.0, + }, + ] + optimizer = BertAdam( + optimizer_grouped_parameters, + lr=self.args.learning_rate, + warmup=self.args.warmup_proportion, + t_total=self.num_train_optimization_steps, + ) + + global_step: int = 0 + nb_tr_steps = 0 + tr_loss: float = 0 + train_features = BertUtil.convert_examples_to_features( + self.train_examples, + self.label_list, + self.args.max_seq_length, + self.tokenizer, + self.output_mode, + ) + self.logger.info("***** Running training *****") + self.logger.info(" Num examples = %d", len(self.train_examples)) + self.logger.info(" Batch size = %d", self.args.train_batch_size) + self.logger.info(" Num steps = %d", self.num_train_optimization_steps) + all_input_ids = torch.tensor( + [f.input_ids for f in train_features], dtype=torch.long + ) + all_input_mask = torch.tensor( + [f.input_mask for f in train_features], dtype=torch.long + ) + all_segment_ids = torch.tensor( + [f.segment_ids for f in train_features], dtype=torch.long + ) + + if self.output_mode == "classification": + all_label_ids = torch.tensor( + [f.label_id for f in train_features], dtype=torch.long + ) + + train_data = TensorDataset( + all_input_ids, all_input_mask, all_segment_ids, all_label_ids + ) + if self.args.local_rank == -1: + train_sampler = RandomSampler(train_data) + else: + train_sampler = DistributedSampler(train_data) + train_dataloader = DataLoader( + train_data, sampler=train_sampler, batch_size=self.args.train_batch_size + ) + + self.model.train() + for _ in trange(int(self.args.num_train_epochs), desc="Epoch"): + tr_loss = 0 + nb_tr_examples, nb_tr_steps = 0, 0 + for step, batch in enumerate(tqdm(train_dataloader, desc="Iteration")): + batch = tuple(t.to(self.device) for t in batch) + input_ids, input_mask, segment_ids, label_ids = batch + + # define a new function to compute loss values for both output_modes + logits = self.model(input_ids, segment_ids, input_mask, labels=None) + + if self.output_mode == "classification": + loss_fct = CrossEntropyLoss() + loss = loss_fct( + logits.view(-1, self.num_labels), label_ids.view(-1) + ) + + if self.args.gradient_accumulation_steps > 1: + loss = loss / self.args.gradient_accumulation_steps + + loss.backward() + + tr_loss += loss.item() + nb_tr_examples += input_ids.size(0) + nb_tr_steps += 1 + if (step + 1) % self.args.gradient_accumulation_steps == 0: + optimizer.step() + optimizer.zero_grad() + global_step += 1 + + if self.args.local_rank == -1 or torch.distributed.get_rank() == 0: + # Save a trained model, configuration and tokenizer + model_to_save = ( + self.model.module if hasattr(self.model, "module") else self.model + ) # Only save the model it-self + + # If we save using the predefined names, we can load using `from_pretrained` + output_model_file = os.path.join(self.args.model_dir, WEIGHTS_NAME) + output_config_file = os.path.join(self.args.model_dir, CONFIG_NAME) + + torch.save(model_to_save.state_dict(), output_model_file) + model_to_save.config.to_json_file(output_config_file) + self.tokenizer.save_vocabulary(self.args.model_dir) + + # Load a trained model and vocabulary that you have fine-tuned + self.model = BertForSequenceClassification.from_pretrained( + self.args.model_dir, num_labels=self.num_labels + ) + self.tokenizer = BertTokenizer.from_pretrained( + self.args.model_dir, do_lower_case=self.args.do_lower_case + ) + else: + self.model = BertForSequenceClassification.from_pretrained( + self.args.bert_model, num_labels=self.num_labels + ) + self.model.to(self.device) + + self.tr_loss, self.global_step = tr_loss, global_step + + self.logger.info("DONE TRAINING."), + + def eval(self) -> None: + if not (self.args.local_rank == -1 or torch.distributed.get_rank() == 0): + return + + eval_examples = self.processor.get_dev_examples(self.args.training_data_dir) + eval_features = BertUtil.convert_examples_to_features( + eval_examples, + self.label_list, + self.args.max_seq_length, + self.tokenizer, + self.output_mode, + ) + self.logger.info("***** Running evaluation *****") + self.logger.info(" Num examples = %d", len(eval_examples)) + self.logger.info(" Batch size = %d", self.args.eval_batch_size) + all_input_ids = torch.tensor( + [f.input_ids for f in eval_features], dtype=torch.long + ) + all_input_mask = torch.tensor( + [f.input_mask for f in eval_features], dtype=torch.long + ) + all_segment_ids = torch.tensor( + [f.segment_ids for f in eval_features], dtype=torch.long + ) + + if self.output_mode == "classification": + all_label_ids = torch.tensor( + [f.label_id for f in eval_features], dtype=torch.long + ) + + eval_data = TensorDataset( + all_input_ids, all_input_mask, all_segment_ids, all_label_ids + ) + # Run prediction for full data + eval_sampler = SequentialSampler(eval_data) + eval_dataloader = DataLoader( + eval_data, sampler=eval_sampler, batch_size=self.args.eval_batch_size + ) + + self.model.eval() + eval_loss = 0 + nb_eval_steps = 0 + preds = [] + + for input_ids, input_mask, segment_ids, label_ids in tqdm( + eval_dataloader, desc="Evaluating" + ): + input_ids = input_ids.to(self.device) + input_mask = input_mask.to(self.device) + segment_ids = segment_ids.to(self.device) + label_ids = label_ids.to(self.device) + + with torch.no_grad(): + logits = self.model(input_ids, segment_ids, input_mask, labels=None) + + # create eval loss and other metric required by the task + if self.output_mode == "classification": + loss_fct = CrossEntropyLoss() + tmp_eval_loss = loss_fct( + logits.view(-1, self.num_labels), label_ids.view(-1) + ) + + eval_loss += tmp_eval_loss.mean().item() + nb_eval_steps += 1 + if len(preds) == 0: + preds.append(logits.detach().cpu().numpy()) + else: + preds[0] = np.append(preds[0], logits.detach().cpu().numpy(), axis=0) + + eval_loss = eval_loss / nb_eval_steps + preds = preds[0] + if self.output_mode == "classification": + preds = np.argmax(preds, axis=1) + result = BertUtil.compute_metrics(self.task_name, preds, all_label_ids.numpy()) + loss = self.tr_loss / self.global_step if self.args.do_train else None + + result["eval_loss"] = eval_loss + result["global_step"] = self.global_step + result["loss"] = loss + + output_eval_file = os.path.join(self.args.model_dir, "eval_results.txt") + with open(output_eval_file, "w") as writer: + self.logger.info("***** Eval results *****") + for key in sorted(result.keys()): + self.logger.info(" %s = %s", key, str(result[key])) + writer.write("%s = %s\n" % (key, str(result[key]))) + + self.logger.info("DONE EVALUATING.") + + def _prepare(self, cleanup_output_dir: bool = False) -> None: + if self.args.local_rank == -1 or self.args.no_cuda: + self.device = torch.device( + "cuda" if torch.cuda.is_available() and not self.args.no_cuda else "cpu" + ) + self.n_gpu = torch.cuda.device_count() + else: + torch.cuda.set_device(self.args.local_rank) + self.device = torch.device("cuda", self.args.local_rank) + self.n_gpu = 1 + # Initializes the distributed backend which will take care of sychronizing nodes/GPUs + torch.distributed.init_process_group(backend="nccl") + + logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%m/%d/%Y %H:%M:%S", + level=logging.INFO if self.args.local_rank in [-1, 0] else logging.WARN, + ) + + self.logger.info( + "device: {} n_gpu: {}, distributed training: {}, 16-bits training: {}".format( + self.device, + self.n_gpu, + bool(self.args.local_rank != -1), + self.args.fp16, + ) + ) + + if self.args.gradient_accumulation_steps < 1: + raise ValueError( + "Invalid gradient_accumulation_steps parameter: {}, should be >= 1".format( + self.args.gradient_accumulation_steps + ) + ) + + self.args.train_batch_size = ( + self.args.train_batch_size // self.args.gradient_accumulation_steps + ) + + random.seed(self.args.seed) + np.random.seed(self.args.seed) + torch.manual_seed(self.args.seed) + if self.n_gpu > 0: + torch.cuda.manual_seed_all(self.args.seed) + + if not self.args.do_train and not self.args.do_eval: + raise ValueError("At least one of `do_train` or `do_eval` must be True.") + + if self.args.cleanup_output_dir: + if os.path.exists(self.args.model_dir): + shutil.rmtree(self.args.model_dir) + + if ( + os.path.exists(self.args.model_dir) + and os.listdir(self.args.model_dir) + and self.args.do_train + ): + raise ValueError( + "Output directory ({}) already exists and is not empty.".format( + self.args.model_dir + ) + ) + if not os.path.exists(self.args.model_dir): + os.makedirs(self.args.model_dir) + + self.task_name = self.args.task_name.lower() + + self.label_list = self.processor.get_labels() + self.num_labels = len(self.label_list) + + self.tokenizer = BertTokenizer.from_pretrained( + self.args.bert_model, do_lower_case=self.args.do_lower_case + ) + + self.train_examples = None + self.num_train_optimization_steps = None + if self.args.do_train: + self.train_examples = self.processor.get_train_examples( + self.args.training_data_dir + ) + self.num_train_optimization_steps = ( + int( + len(self.train_examples) + / self.args.train_batch_size + / self.args.gradient_accumulation_steps + ) + * self.args.num_train_epochs + ) + if self.args.local_rank != -1: + self.num_train_optimization_steps = ( + self.num_train_optimization_steps + // torch.distributed.get_world_size() + ) + + def _prepare_model(self) -> BertPreTrainedModel: + if self.args.cache_dir: + cache_dir = self.args.cache_dir + else: + cache_dir = os.path.join( + str(PYTORCH_PRETRAINED_BERT_CACHE), + f"distributed_{self.args.local_rank}", + ) + model = BertForSequenceClassification.from_pretrained( + self.args.bert_model, cache_dir=cache_dir, num_labels=self.num_labels + ) + model.to(self.device) + return model diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py index f59759d53..b1104ce92 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/train/flight_booking_processor.py @@ -1,51 +1,51 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import os -from typing import List, Tuple - -from model_corebot101.bert.common.input_example import InputExample - - -class FlightBookingProcessor: - """Processor for the flight booking data set.""" - - def get_train_examples(self, data_dir): - """See base class.""" - return self._create_examples( - self._read_json(os.path.join(data_dir, "FlightBooking.json")), "train" - ) - - def get_dev_examples(self, data_dir): - """See base class.""" - return self._create_examples( - self._read_json(os.path.join(data_dir, "FlightBooking.json")), "dev" - ) - - def get_labels(self): - """See base class.""" - return ["Book flight", "Cancel"] - - def _create_examples(self, lines, set_type): - """Creates examples for the training and dev sets.""" - examples = [] - for (i, line) in enumerate(lines): - guid = "%s-%s" % (set_type, i) - text_a = line[1] - label = line[0] - examples.append( - InputExample(guid=guid, text_a=text_a, text_b=None, label=label) - ) - return examples - - @classmethod - def _read_json(cls, input_file): - with open(input_file, "r", encoding="utf-8") as f: - obj = json.load(f) - examples = obj["utterances"] - lines: List[Tuple[str, str]] = [] - for example in examples: - lines.append((example["intent"], example["text"])) - - return lines +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os +from typing import List, Tuple + +from model_corebot101.bert.common.input_example import InputExample + + +class FlightBookingProcessor: + """Processor for the flight booking data set.""" + + def get_train_examples(self, data_dir): + """See base class.""" + return self._create_examples( + self._read_json(os.path.join(data_dir, "FlightBooking.json")), "train" + ) + + def get_dev_examples(self, data_dir): + """See base class.""" + return self._create_examples( + self._read_json(os.path.join(data_dir, "FlightBooking.json")), "dev" + ) + + def get_labels(self): + """See base class.""" + return ["Book flight", "Cancel"] + + def _create_examples(self, lines, set_type): + """Creates examples for the training and dev sets.""" + examples = [] + for (i, line) in enumerate(lines): + guid = "%s-%s" % (set_type, i) + text_a = line[1] + label = line[0] + examples.append( + InputExample(guid=guid, text_a=text_a, text_b=None, label=label) + ) + return examples + + @classmethod + def _read_json(cls, input_file): + with open(input_file, "r", encoding="utf-8") as f: + obj = json.load(f) + examples = obj["utterances"] + lines: List[Tuple[str, str]] = [] + for example in examples: + lines.append((example["intent"], example["text"])) + + return lines diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json similarity index 94% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json index 43781ee85..e2b881b21 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bert/training_data/FlightBooking.json @@ -1,241 +1,241 @@ -{ - "luis_schema_version": "3.2.0", - "versionId": "0.1", - "name": "Airline Reservation", - "desc": "A LUIS model that uses intent and entities.", - "culture": "en-us", - "tokenizerVersion": "1.0.0", - "intents": [ - { - "name": "Book flight" - }, - { - "name": "Cancel" - }, - { - "name": "None" - } - ], - "entities": [], - "composites": [ - { - "name": "From", - "children": [ - "Airport" - ], - "roles": [] - }, - { - "name": "To", - "children": [ - "Airport" - ], - "roles": [] - } - ], - "closedLists": [ - { - "name": "Airport", - "subLists": [ - { - "canonicalForm": "Paris", - "list": [ - "paris" - ] - }, - { - "canonicalForm": "London", - "list": [ - "london" - ] - }, - { - "canonicalForm": "Berlin", - "list": [ - "berlin" - ] - }, - { - "canonicalForm": "New York", - "list": [ - "new york" - ] - } - ], - "roles": [] - } - ], - "patternAnyEntities": [], - "regex_entities": [], - "prebuiltEntities": [ - { - "name": "datetimeV2", - "roles": [] - } - ], - "model_features": [], - "regex_features": [], - "patterns": [], - "utterances": [ - { - "text": "book flight from london to paris on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 27, - "endPos": 31 - }, - { - "entity": "From", - "startPos": 17, - "endPos": 22 - } - ] - }, - { - "text": "book flight to berlin on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 15, - "endPos": 20 - } - ] - }, - { - "text": "book me a flight from london to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "From", - "startPos": 22, - "endPos": 27 - }, - { - "entity": "To", - "startPos": 32, - "endPos": 36 - } - ] - }, - { - "text": "bye", - "intent": "Cancel", - "entities": [] - }, - { - "text": "cancel booking", - "intent": "Cancel", - "entities": [] - }, - { - "text": "exit", - "intent": "Cancel", - "entities": [] - }, - { - "text": "I don't want that", - "intent": "Cancel", - "entities": [] - }, - { - "text": "not this one", - "intent": "Cancel", - "entities": [] - }, - { - "text": "don't want that", - "intent": "Cancel", - "entities": [] - }, - { - "text": "flight to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - }, - { - "text": "flight to paris from london on feb 14th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - }, - { - "entity": "From", - "startPos": 21, - "endPos": 26 - } - ] - }, - { - "text": "fly from berlin to paris on may 5th", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 19, - "endPos": 23 - }, - { - "entity": "From", - "startPos": 9, - "endPos": 14 - } - ] - }, - { - "text": "go to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 6, - "endPos": 10 - } - ] - }, - { - "text": "going from paris to berlin", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 20, - "endPos": 25 - }, - { - "entity": "From", - "startPos": 11, - "endPos": 15 - } - ] - }, - { - "text": "ignore", - "intent": "Cancel", - "entities": [] - }, - { - "text": "travel to paris", - "intent": "Book flight", - "entities": [ - { - "entity": "To", - "startPos": 10, - "endPos": 14 - } - ] - } - ], - "settings": [] +{ + "luis_schema_version": "3.2.0", + "versionId": "0.1", + "name": "Airline Reservation", + "desc": "A LUIS model that uses intent and entities.", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "intents": [ + { + "name": "Book flight" + }, + { + "name": "Cancel" + }, + { + "name": "None" + } + ], + "entities": [], + "composites": [ + { + "name": "From", + "children": [ + "Airport" + ], + "roles": [] + }, + { + "name": "To", + "children": [ + "Airport" + ], + "roles": [] + } + ], + "closedLists": [ + { + "name": "Airport", + "subLists": [ + { + "canonicalForm": "Paris", + "list": [ + "paris" + ] + }, + { + "canonicalForm": "London", + "list": [ + "london" + ] + }, + { + "canonicalForm": "Berlin", + "list": [ + "berlin" + ] + }, + { + "canonicalForm": "New York", + "list": [ + "new york" + ] + } + ], + "roles": [] + } + ], + "patternAnyEntities": [], + "regex_entities": [], + "prebuiltEntities": [ + { + "name": "datetimeV2", + "roles": [] + } + ], + "model_features": [], + "regex_features": [], + "patterns": [], + "utterances": [ + { + "text": "book flight from london to paris on feb 14th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 27, + "endPos": 31 + }, + { + "entity": "From", + "startPos": 17, + "endPos": 22 + } + ] + }, + { + "text": "book flight to berlin on feb 14th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 15, + "endPos": 20 + } + ] + }, + { + "text": "book me a flight from london to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "From", + "startPos": 22, + "endPos": 27 + }, + { + "entity": "To", + "startPos": 32, + "endPos": 36 + } + ] + }, + { + "text": "bye", + "intent": "Cancel", + "entities": [] + }, + { + "text": "cancel booking", + "intent": "Cancel", + "entities": [] + }, + { + "text": "exit", + "intent": "Cancel", + "entities": [] + }, + { + "text": "I don't want that", + "intent": "Cancel", + "entities": [] + }, + { + "text": "not this one", + "intent": "Cancel", + "entities": [] + }, + { + "text": "don't want that", + "intent": "Cancel", + "entities": [] + }, + { + "text": "flight to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "flight to paris from london on feb 14th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + }, + { + "entity": "From", + "startPos": 21, + "endPos": 26 + } + ] + }, + { + "text": "fly from berlin to paris on may 5th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 19, + "endPos": 23 + }, + { + "entity": "From", + "startPos": 9, + "endPos": 14 + } + ] + }, + { + "text": "go to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 6, + "endPos": 10 + } + ] + }, + { + "text": "going from paris to berlin", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 20, + "endPos": 25 + }, + { + "entity": "From", + "startPos": 11, + "endPos": 15 + } + ] + }, + { + "text": "ignore", + "intent": "Cancel", + "entities": [] + }, + { + "text": "travel to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + } + ], + "settings": [] } \ No newline at end of file diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py index a7780a076..9d191b568 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .bidaf_model_runtime import BidafModelRuntime - -__all__ = ["BidafModelRuntime"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .bidaf_model_runtime import BidafModelRuntime + +__all__ = ["BidafModelRuntime"] diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py index 982e31054..2f3ed506e 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/model_runtime/bidaf_model_runtime.py @@ -1,101 +1,101 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -import os -import sys -import requests -import shutil -from typing import Dict, List, Tuple -import nltk -import numpy as np -from nltk import word_tokenize -from onnxruntime import InferenceSession - -# pylint:disable=line-too-long -class BidafModelRuntime: - def __init__(self, targets: List[str], queries: Dict[str, str], model_dir: str): - self.queries = queries - self.targets = targets - bidaf_model = os.path.abspath(os.path.join(model_dir, "bidaf.onnx")) - print(f"Loading Inference session from {bidaf_model}..", file=sys.stderr) - self.session = InferenceSession(bidaf_model) - print(f"Inference session loaded..", file=sys.stderr) - self.processed_queries = self._process_queries() - print(f"Processed queries..", file=sys.stderr) - - @staticmethod - def init_bidaf(bidaf_model_dir: str, download_ntlk_punkt: bool = False) -> bool: - if os.path.isdir(bidaf_model_dir): - print("bidaf model directory already present..", file=sys.stderr) - else: - print("Creating bidaf model directory..", file=sys.stderr) - os.makedirs(bidaf_model_dir, exist_ok=True) - - # Download Punkt Sentence Tokenizer - if download_ntlk_punkt: - nltk.download("punkt", download_dir=bidaf_model_dir) - nltk.download("punkt") - - # Download bidaf onnx model - onnx_model_file = os.path.abspath(os.path.join(bidaf_model_dir, "bidaf.onnx")) - - print(f"Checking file {onnx_model_file}..", file=sys.stderr) - if os.path.isfile(onnx_model_file): - print("bidaf.onnx downloaded already!", file=sys.stderr) - else: - print("Downloading bidaf.onnx...", file=sys.stderr) - response = requests.get( - "https://onnxzoo.blob.core.windows.net/models/opset_9/bidaf/bidaf.onnx", - stream=True, - ) - with open(onnx_model_file, "wb") as f: - response.raw.decode_content = True - shutil.copyfileobj(response.raw, f) - return True - - def serve(self, context: str) -> Dict[str, str]: - result = {} - cw, cc = BidafModelRuntime._preprocess(context) - for target in self.targets: - qw, qc = self.processed_queries[target] - answer = self.session.run( - ["start_pos", "end_pos"], - { - "context_word": cw, - "context_char": cc, - "query_word": qw, - "query_char": qc, - }, - ) - start = answer[0].item() - end = answer[1].item() - result_item = cw[start : end + 1] - result[target] = BidafModelRuntime._convert_result(result_item) - - return result - - def _process_queries(self) -> Dict[str, Tuple[np.ndarray, np.ndarray]]: - result = {} - for target in self.targets: - question = self.queries[target] - result[target] = BidafModelRuntime._preprocess(question) - - return result - - @staticmethod - def _convert_result(result_item: np.ndarray) -> str: - result = [] - for item in result_item: - result.append(item[0]) - - return " ".join(result) - - @staticmethod - def _preprocess(text: str) -> Tuple[np.ndarray, np.ndarray]: - tokens = word_tokenize(text) - # split into lower-case word tokens, in numpy array with shape of (seq, 1) - words = np.asarray([w.lower() for w in tokens]).reshape(-1, 1) - # split words into chars, in numpy array with shape of (seq, 1, 1, 16) - chars = [[c for c in t][:16] for t in tokens] - chars = [cs + [""] * (16 - len(cs)) for cs in chars] - chars = np.asarray(chars).reshape(-1, 1, 1, 16) - return words, chars +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +import sys +import requests +import shutil +from typing import Dict, List, Tuple +import nltk +import numpy as np +from nltk import word_tokenize +from onnxruntime import InferenceSession + +# pylint:disable=line-too-long +class BidafModelRuntime: + def __init__(self, targets: List[str], queries: Dict[str, str], model_dir: str): + self.queries = queries + self.targets = targets + bidaf_model = os.path.abspath(os.path.join(model_dir, "bidaf.onnx")) + print(f"Loading Inference session from {bidaf_model}..", file=sys.stderr) + self.session = InferenceSession(bidaf_model) + print(f"Inference session loaded..", file=sys.stderr) + self.processed_queries = self._process_queries() + print(f"Processed queries..", file=sys.stderr) + + @staticmethod + def init_bidaf(bidaf_model_dir: str, download_ntlk_punkt: bool = False) -> bool: + if os.path.isdir(bidaf_model_dir): + print("bidaf model directory already present..", file=sys.stderr) + else: + print("Creating bidaf model directory..", file=sys.stderr) + os.makedirs(bidaf_model_dir, exist_ok=True) + + # Download Punkt Sentence Tokenizer + if download_ntlk_punkt: + nltk.download("punkt", download_dir=bidaf_model_dir) + nltk.download("punkt") + + # Download bidaf onnx model + onnx_model_file = os.path.abspath(os.path.join(bidaf_model_dir, "bidaf.onnx")) + + print(f"Checking file {onnx_model_file}..", file=sys.stderr) + if os.path.isfile(onnx_model_file): + print("bidaf.onnx downloaded already!", file=sys.stderr) + else: + print("Downloading bidaf.onnx...", file=sys.stderr) + response = requests.get( + "https://onnxzoo.blob.core.windows.net/models/opset_9/bidaf/bidaf.onnx", + stream=True, + ) + with open(onnx_model_file, "wb") as f: + response.raw.decode_content = True + shutil.copyfileobj(response.raw, f) + return True + + def serve(self, context: str) -> Dict[str, str]: + result = {} + cw, cc = BidafModelRuntime._preprocess(context) + for target in self.targets: + qw, qc = self.processed_queries[target] + answer = self.session.run( + ["start_pos", "end_pos"], + { + "context_word": cw, + "context_char": cc, + "query_word": qw, + "query_char": qc, + }, + ) + start = answer[0].item() + end = answer[1].item() + result_item = cw[start : end + 1] + result[target] = BidafModelRuntime._convert_result(result_item) + + return result + + def _process_queries(self) -> Dict[str, Tuple[np.ndarray, np.ndarray]]: + result = {} + for target in self.targets: + question = self.queries[target] + result[target] = BidafModelRuntime._preprocess(question) + + return result + + @staticmethod + def _convert_result(result_item: np.ndarray) -> str: + result = [] + for item in result_item: + result.append(item[0]) + + return " ".join(result) + + @staticmethod + def _preprocess(text: str) -> Tuple[np.ndarray, np.ndarray]: + tokens = word_tokenize(text) + # split into lower-case word tokens, in numpy array with shape of (seq, 1) + words = np.asarray([w.lower() for w in tokens]).reshape(-1, 1) + # split words into chars, in numpy array with shape of (seq, 1, 1, 16) + chars = [[c for c in t][:16] for t in tokens] + chars = [cs + [""] * (16 - len(cs)) for cs in chars] + chars = np.asarray(chars).reshape(-1, 1, 1, 16) + return words, chars diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt similarity index 88% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt index a2eea036e..bb0cd1821 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/bidaf/requirements.txt @@ -1,3 +1,3 @@ -nltk -numpy -onnxruntime +nltk +numpy +onnxruntime diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/booking_details.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/booking_details.py similarity index 100% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/booking_details.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/booking_details.py diff --git a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py rename to tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py index bc75260f9..c98ae0d09 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/model_corebot101/language_helper.py @@ -1,212 +1,212 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Language helper that invokes the language model. -This is used from the Bot and Model Runtime to load and invoke the language models. -""" - -import os -import sys -from typing import Dict -from pathlib import Path -import requests -from datatypes_date_time.timex import Timex -from model_corebot101.booking_details import BookingDetails -from model_corebot101.bidaf.model_runtime import BidafModelRuntime -from model_corebot101.bert.model_runtime import BertModelRuntime -from model_corebot101.bert.train import BertTrainEval - -# pylint:disable=line-too-long -class LanguageHelper: - """Language helper that invokes the language model.""" - - home_dir = str(Path.home()) - bert_model_dir_default = os.path.abspath(os.path.join(home_dir, "models/bert")) - bidaf_model_dir_default = os.path.abspath(os.path.join(home_dir, "models/bidaf")) - - # pylint:disable=bad-continuation - def __init__(self): - """Create Language Helper. - Note: Creating the Bert/Bidaf Model Runtime is only necessary for in-proc usage. - """ - self._bidaf_entities = None - self._bert_intents = None - - @property - def entities(self) -> BidafModelRuntime: - """Model used to detect entities.""" - return self._bidaf_entities - - @property - def intents(self) -> BertModelRuntime: - """Model used to detect intents.""" - return self._bert_intents - - def initialize_models( - self, - bert_model_dir: str = bert_model_dir_default, - bidaf_model_dir: str = bidaf_model_dir_default, - ) -> bool: - """ Initialize models. - Perform initialization of the models. - """ - if not BidafModelRuntime.init_bidaf(bidaf_model_dir, download_ntlk_punkt=True): - print( - f"bidaf model creation failed at model directory {bidaf_model_dir}..", - file=sys.stderr, - ) - return False - - if not BertModelRuntime.init_bert(bert_model_dir): - print( - "bert model creation failed at model directory {bert_model_dir}..", - file=sys.stderr, - ) - return False - - print(f"Loading BERT model from {bert_model_dir}...", file=sys.stderr) - if not os.listdir(bert_model_dir): - print(f"No BERT model present, building model..", file=sys.stderr) - BertTrainEval.train_eval(cleanup_output_dir=True) - - self._bert_intents = BertModelRuntime( - model_dir=bert_model_dir, label_list=["Book flight", "Cancel"] - ) - print(f"Loaded BERT model. Loading BiDaf model..", file=sys.stderr) - - self._bidaf_entities = BidafModelRuntime( - targets=["from", "to", "date"], - queries={ - "from": "which city will you travel from?", - "to": "which city will you travel to?", - "date": "which date will you travel?", - }, - model_dir=bidaf_model_dir, - ) - print(f"Loaded BiDAF model from {bidaf_model_dir}.", file=sys.stderr) - - return True - - async def excecute_query_inproc(self, utterance: str) -> BookingDetails: - """Exeecute a query against language model.""" - booking_details = BookingDetails() - intent = self.intents.serve(utterance) - print(f'Recognized intent "{intent}" from "{utterance}".', file=sys.stderr) - if intent == "Book flight": - # Bert gave us the intent. - # Now look for entities with BiDAF.. - entities = self.entities.serve(utterance) - - if "to" in entities: - print(f' Recognized "to" entitiy: {entities["to"]}.', file=sys.stderr) - booking_details.destination = entities["to"] - if "from" in entities: - print( - f' Recognized "from" entitiy: {entities["from"]}.', - file=sys.stderr, - ) - booking_details.origin = entities["from"] - if "date" in entities: - # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. - # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. - print( - f' Recognized "date" entitiy: {entities["date"]}.', - file=sys.stderr, - ) - travel_date = entities["date"] - if await LanguageHelper.validate_timex(travel_date): - booking_details.travel_date = travel_date - - return booking_details - - @staticmethod - async def excecute_query_service( - configuration: dict, utterance: str - ) -> BookingDetails: - """Invoke lu service to perform prediction/evaluation of utterance.""" - booking_details = BookingDetails() - lu_response = await LanguageHelper.call_model_runtime(configuration, utterance) - if lu_response.status_code == 200: - - response_json = lu_response.json() - intent = response_json["intent"] if "intent" in response_json else None - entities = await LanguageHelper.validate_entities( - response_json["entities"] if "entities" in response_json else None - ) - if intent: - if "to" in entities: - print( - f' Recognized "to" entity: {entities["to"]}.', file=sys.stderr - ) - booking_details.destination = entities["to"] - if "from" in entities: - print( - f' Recognized "from" entity: {entities["from"]}.', - file=sys.stderr, - ) - booking_details.origin = entities["from"] - if "date" in entities: - # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. - # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. - print( - f' Recognized "date" entity: {entities["date"]}.', - file=sys.stderr, - ) - travel_date = entities["date"] - if await LanguageHelper.validate_timex(travel_date): - booking_details.travel_date = travel_date - return booking_details - - @staticmethod - async def call_model_runtime( - configuration: Dict[str, object], text: str - ) -> requests.Request: - """ Makes a call to the model runtime api - - The model runtime api signature is: - http://:/v1.0/model?q= - - where: - - model_runtime_host - The host running the model runtime api. To resolve - the host running the model runtime api (in the following order): - - MODEL_RUNTIME_API environment variable. Used in docker. - - config.py (which contains the DefaultConfig class). Used running - locally. - - port - http port number (ie, 8880) - - q - A query string to process (ie, the text utterance from user) - - For more details: (See TBD swagger file) - """ - port = os.environ.get("MODEL_RUNTIME_SERVICE_PORT") - host = os.environ.get("MODEL_RUNTIME_SERVICE_HOST") - if host is None: - host = configuration["MODEL_RUNTIME_SERVICE_HOST"] - if port is None: - port = configuration["MODEL_RUNTIME_SERVICE_PORT"] - - api_url = f"http://{host}:{port}/v1.0/model" - qstrings = {"q": text} - return requests.get(api_url, params=qstrings) - - @staticmethod - async def validate_entities(entities: Dict[str, str]) -> bool: - """Validate the entities. - The to and from cities can't be the same. If this is detected, - remove the ambiguous results. """ - if "to" in entities and "from" in entities: - if entities["to"] == entities["from"]: - del entities["to"] - del entities["from"] - return entities - - @staticmethod - async def validate_timex(travel_date: str) -> bool: - """Validate the time. - Make sure time given in the right format. """ - # uncomment the following line for debugging. - # import pdb; pdb.set_trace() - timex_property = Timex(travel_date) - - return len(timex_property.types) > 0 and "definite" not in timex_property.types +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Language helper that invokes the language model. +This is used from the Bot and Model Runtime to load and invoke the language models. +""" + +import os +import sys +from typing import Dict +from pathlib import Path +import requests +from datatypes_date_time.timex import Timex +from model_corebot101.booking_details import BookingDetails +from model_corebot101.bidaf.model_runtime import BidafModelRuntime +from model_corebot101.bert.model_runtime import BertModelRuntime +from model_corebot101.bert.train import BertTrainEval + +# pylint:disable=line-too-long +class LanguageHelper: + """Language helper that invokes the language model.""" + + home_dir = str(Path.home()) + bert_model_dir_default = os.path.abspath(os.path.join(home_dir, "models/bert")) + bidaf_model_dir_default = os.path.abspath(os.path.join(home_dir, "models/bidaf")) + + # pylint:disable=bad-continuation + def __init__(self): + """Create Language Helper. + Note: Creating the Bert/Bidaf Model Runtime is only necessary for in-proc usage. + """ + self._bidaf_entities = None + self._bert_intents = None + + @property + def entities(self) -> BidafModelRuntime: + """Model used to detect entities.""" + return self._bidaf_entities + + @property + def intents(self) -> BertModelRuntime: + """Model used to detect intents.""" + return self._bert_intents + + def initialize_models( + self, + bert_model_dir: str = bert_model_dir_default, + bidaf_model_dir: str = bidaf_model_dir_default, + ) -> bool: + """ Initialize models. + Perform initialization of the models. + """ + if not BidafModelRuntime.init_bidaf(bidaf_model_dir, download_ntlk_punkt=True): + print( + f"bidaf model creation failed at model directory {bidaf_model_dir}..", + file=sys.stderr, + ) + return False + + if not BertModelRuntime.init_bert(bert_model_dir): + print( + "bert model creation failed at model directory {bert_model_dir}..", + file=sys.stderr, + ) + return False + + print(f"Loading BERT model from {bert_model_dir}...", file=sys.stderr) + if not os.listdir(bert_model_dir): + print(f"No BERT model present, building model..", file=sys.stderr) + BertTrainEval.train_eval(cleanup_output_dir=True) + + self._bert_intents = BertModelRuntime( + model_dir=bert_model_dir, label_list=["Book flight", "Cancel"] + ) + print(f"Loaded BERT model. Loading BiDaf model..", file=sys.stderr) + + self._bidaf_entities = BidafModelRuntime( + targets=["from", "to", "date"], + queries={ + "from": "which city will you travel from?", + "to": "which city will you travel to?", + "date": "which date will you travel?", + }, + model_dir=bidaf_model_dir, + ) + print(f"Loaded BiDAF model from {bidaf_model_dir}.", file=sys.stderr) + + return True + + async def excecute_query_inproc(self, utterance: str) -> BookingDetails: + """Exeecute a query against language model.""" + booking_details = BookingDetails() + intent = self.intents.serve(utterance) + print(f'Recognized intent "{intent}" from "{utterance}".', file=sys.stderr) + if intent == "Book flight": + # Bert gave us the intent. + # Now look for entities with BiDAF.. + entities = self.entities.serve(utterance) + + if "to" in entities: + print(f' Recognized "to" entitiy: {entities["to"]}.', file=sys.stderr) + booking_details.destination = entities["to"] + if "from" in entities: + print( + f' Recognized "from" entitiy: {entities["from"]}.', + file=sys.stderr, + ) + booking_details.origin = entities["from"] + if "date" in entities: + # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. + # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. + print( + f' Recognized "date" entitiy: {entities["date"]}.', + file=sys.stderr, + ) + travel_date = entities["date"] + if await LanguageHelper.validate_timex(travel_date): + booking_details.travel_date = travel_date + + return booking_details + + @staticmethod + async def excecute_query_service( + configuration: dict, utterance: str + ) -> BookingDetails: + """Invoke lu service to perform prediction/evaluation of utterance.""" + booking_details = BookingDetails() + lu_response = await LanguageHelper.call_model_runtime(configuration, utterance) + if lu_response.status_code == 200: + + response_json = lu_response.json() + intent = response_json["intent"] if "intent" in response_json else None + entities = await LanguageHelper.validate_entities( + response_json["entities"] if "entities" in response_json else None + ) + if intent: + if "to" in entities: + print( + f' Recognized "to" entity: {entities["to"]}.', file=sys.stderr + ) + booking_details.destination = entities["to"] + if "from" in entities: + print( + f' Recognized "from" entity: {entities["from"]}.', + file=sys.stderr, + ) + booking_details.origin = entities["from"] + if "date" in entities: + # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. + # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. + print( + f' Recognized "date" entity: {entities["date"]}.', + file=sys.stderr, + ) + travel_date = entities["date"] + if await LanguageHelper.validate_timex(travel_date): + booking_details.travel_date = travel_date + return booking_details + + @staticmethod + async def call_model_runtime( + configuration: Dict[str, object], text: str + ) -> requests.Request: + """ Makes a call to the model runtime api + + The model runtime api signature is: + http://:/v1.0/model?q= + + where: + + model_runtime_host - The host running the model runtime api. To resolve + the host running the model runtime api (in the following order): + - MODEL_RUNTIME_API environment variable. Used in docker. + - config.py (which contains the DefaultConfig class). Used running + locally. + + port - http port number (ie, 8880) + + q - A query string to process (ie, the text utterance from user) + + For more details: (See TBD swagger file) + """ + port = os.environ.get("MODEL_RUNTIME_SERVICE_PORT") + host = os.environ.get("MODEL_RUNTIME_SERVICE_HOST") + if host is None: + host = configuration["MODEL_RUNTIME_SERVICE_HOST"] + if port is None: + port = configuration["MODEL_RUNTIME_SERVICE_PORT"] + + api_url = f"http://{host}:{port}/v1.0/model" + qstrings = {"q": text} + return requests.get(api_url, params=qstrings) + + @staticmethod + async def validate_entities(entities: Dict[str, str]) -> bool: + """Validate the entities. + The to and from cities can't be the same. If this is detected, + remove the ambiguous results. """ + if "to" in entities and "from" in entities: + if entities["to"] == entities["from"]: + del entities["to"] + del entities["from"] + return entities + + @staticmethod + async def validate_timex(travel_date: str) -> bool: + """Validate the time. + Make sure time given in the right format. """ + # uncomment the following line for debugging. + # import pdb; pdb.set_trace() + timex_property = Timex(travel_date) + + return len(timex_property.types) > 0 and "definite" not in timex_property.types diff --git a/samples/experimental/101.corebot-bert-bidaf/model/setup.py b/tests/experimental/101.corebot-bert-bidaf/model/setup.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model/setup.py rename to tests/experimental/101.corebot-bert-bidaf/model/setup.py index e10cc4872..86a7180b7 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model/setup.py +++ b/tests/experimental/101.corebot-bert-bidaf/model/setup.py @@ -1,51 +1,51 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -from setuptools import setup - -REQUIRES = [ - "torch", - "tqdm", - "pytorch-pretrained-bert", - "onnxruntime>=0.4.0", - "onnx>=1.5.0", - "datatypes-date-time>=1.0.0.a1", - "nltk>=3.4.1", -] - - -root = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(root, "model_corebot101", "about.py")) as f: - package_info = {} - info = f.read() - exec(info, package_info) - -setup( - name=package_info["__title__"], - version=package_info["__version__"], - url=package_info["__uri__"], - author=package_info["__author__"], - description=package_info["__description__"], - keywords="botframework azure botbuilder", - long_description=package_info["__summary__"], - license=package_info["__license__"], - packages=[ - "model_corebot101.bert.train", - "model_corebot101.bert.common", - "model_corebot101.bert.model_runtime", - "model_corebot101.bidaf.model_runtime", - ], - install_requires=REQUIRES, - dependency_links=["https://github.com/pytorch/pytorch"], - include_package_data=True, - classifiers=[ - "Programming Language :: Python :: 3.6", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - ], -) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "torch", + "tqdm", + "pytorch-pretrained-bert", + "onnxruntime>=0.4.0", + "onnx>=1.5.0", + "datatypes-date-time>=1.0.0.a1", + "nltk>=3.4.1", +] + + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "model_corebot101", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords="botframework azure botbuilder", + long_description=package_info["__summary__"], + license=package_info["__license__"], + packages=[ + "model_corebot101.bert.train", + "model_corebot101.bert.common", + "model_corebot101.bert.model_runtime", + "model_corebot101.bidaf.model_runtime", + ], + install_requires=REQUIRES, + dependency_links=["https://github.com/pytorch/pytorch"], + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py index d3a549063..c702f213e 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Model Runtime.""" -from .model_cache import ModelCache - -__all__ = ["ModelCache"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Model Runtime.""" +from .model_cache import ModelCache + +__all__ = ["ModelCache"] diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py index 7fb9c163e..ce4ebf0e1 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/about.py @@ -1,14 +1,14 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os - -__title__ = "model_runtime_svc_corebot101" -__version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" -) -__uri__ = "https://www.github.com/Microsoft/botbuilder-python" -__author__ = "Microsoft" -__description__ = "Microsoft Bot Framework Bot Builder" -__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." -__license__ = "MIT" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "model_runtime_svc_corebot101" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "0.0.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py index 1e285cc3e..c59fde586 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/docker_init.py @@ -1,19 +1,19 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Docker initialization. -This is called from the Dockerfile when creating the model runtime service API -container. -""" -import os -from pathlib import Path -from model_corebot101.language_helper import LanguageHelper - -# Initialize the models -LH = LanguageHelper() -HOME_DIR = str(Path.home()) -BERT_MODEL_DIR_DEFAULT = os.path.abspath(os.path.join(HOME_DIR, "models/bert")) -BIDAF_MODEL_DIR_DEFAULT = os.path.abspath(os.path.join(HOME_DIR, "models/bidaf")) - -LH.initialize_models( - bert_model_dir=BERT_MODEL_DIR_DEFAULT, bidaf_model_dir=BIDAF_MODEL_DIR_DEFAULT -) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Docker initialization. +This is called from the Dockerfile when creating the model runtime service API +container. +""" +import os +from pathlib import Path +from model_corebot101.language_helper import LanguageHelper + +# Initialize the models +LH = LanguageHelper() +HOME_DIR = str(Path.home()) +BERT_MODEL_DIR_DEFAULT = os.path.abspath(os.path.join(HOME_DIR, "models/bert")) +BIDAF_MODEL_DIR_DEFAULT = os.path.abspath(os.path.join(HOME_DIR, "models/bidaf")) + +LH.initialize_models( + bert_model_dir=BERT_MODEL_DIR_DEFAULT, bidaf_model_dir=BIDAF_MODEL_DIR_DEFAULT +) diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py index 5b7f7a925..d7fe4b228 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py index 32a3ff1bb..ef6e78a86 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/handlers/model_handler.py @@ -1,52 +1,52 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Tornado handler to access the model runtime. - -To invoke: - /v1.0/model?q= -""" - -import logging -import json -from tornado.web import RequestHandler -from model_corebot101.language_helper import LanguageHelper - -# pylint:disable=abstract-method -class ModelHandler(RequestHandler): - """Model Handler implementation to access the model runtime.""" - - _handler_routes = ["/v1.0/model/$", "/v1.0/model$"] - - @classmethod - def build_config(cls, ref_obj: dict): - """Build the Tornado configuration for this handler.""" - return [(route, ModelHandler, ref_obj) for route in cls._handler_routes] - - def set_default_headers(self): - """Set the default HTTP headers.""" - RequestHandler.set_default_headers(self) - self.set_header("Content-Type", "application/json") - self.set_header("Access-Control-Allow-Origin", "*") - self.set_header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept") - self.set_header("Access-Control-Allow-Methods", "OPTIONS, GET") - - # pylint:disable=attribute-defined-outside-init - def initialize(self, language_helper: LanguageHelper): - """Initialize the handler.""" - RequestHandler.initialize(self) - self._language_helper = language_helper - self._logger = logging.getLogger("MODEL_HANDLER") - - async def get(self): - """Handle HTTP GET request.""" - text = self.get_argument("q", None, True) - if not text: - return (404, "Missing the q query string with the text") - - response = {} - intent = self._language_helper.intents.serve(text) - response["intent"] = intent if intent else "None" - entities = self._language_helper.entities.serve(text) - response["entities"] = entities if entities else "None" - self.write(json.dumps(response)) - return (200, "Complete") +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Tornado handler to access the model runtime. + +To invoke: + /v1.0/model?q= +""" + +import logging +import json +from tornado.web import RequestHandler +from model_corebot101.language_helper import LanguageHelper + +# pylint:disable=abstract-method +class ModelHandler(RequestHandler): + """Model Handler implementation to access the model runtime.""" + + _handler_routes = ["/v1.0/model/$", "/v1.0/model$"] + + @classmethod + def build_config(cls, ref_obj: dict): + """Build the Tornado configuration for this handler.""" + return [(route, ModelHandler, ref_obj) for route in cls._handler_routes] + + def set_default_headers(self): + """Set the default HTTP headers.""" + RequestHandler.set_default_headers(self) + self.set_header("Content-Type", "application/json") + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept") + self.set_header("Access-Control-Allow-Methods", "OPTIONS, GET") + + # pylint:disable=attribute-defined-outside-init + def initialize(self, language_helper: LanguageHelper): + """Initialize the handler.""" + RequestHandler.initialize(self) + self._language_helper = language_helper + self._logger = logging.getLogger("MODEL_HANDLER") + + async def get(self): + """Handle HTTP GET request.""" + text = self.get_argument("q", None, True) + if not text: + return (404, "Missing the q query string with the text") + + response = {} + intent = self._language_helper.intents.serve(text) + response["intent"] = intent if intent else "None" + entities = self._language_helper.entities.serve(text) + response["entities"] = entities if entities else "None" + self.write(json.dumps(response)) + return (200, "Complete") diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py index 6d622f182..a8f6ba5ca 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/main.py @@ -1,109 +1,109 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Model Runtime. -Entry point for the model runtime. -""" -import os -import signal -import logging -from logging.handlers import RotatingFileHandler -import tornado -from tornado.options import define, options -from pathlib import Path -from model_corebot101.language_helper import LanguageHelper -from handlers.model_handler import ModelHandler - -HOME_DIR = str(Path.home()) - -# Define Tornado options -define("port", default=8880, help="HTTP port for model runtime to listen on", type=int) -define( - "bidaf_model_dir", - default=os.path.abspath(os.path.join(HOME_DIR, "models/bidaf")), - help="bidaf model directory", -) -define( - "bert_model_dir", - default=os.path.abspath(os.path.join(HOME_DIR, "models/bert")), - help="bert model directory", -) - - -def setup_logging(): - """Set up logging.""" - logging.info("Setting up logging infrastructure") - - # Create the rotating log handler - if not os.path.exists("logs"): - os.mkdir("logs") - handler = RotatingFileHandler( - os.path.join("./logs", "model-runtime.log"), - maxBytes=5 * 1024 ** 2, # 5 MB chunks, - backupCount=5, # limit to 25 MB logs max - ) - - # Set the formatter - handler.setFormatter( - logging.Formatter("%(asctime)s %(name)-12s %(levelname)-8s %(message)s") - ) - - # Setup the root logging with the necessary handlers - log = logging.getLogger() - log.addHandler(handler) - - # Set to info for normal processing - log.setLevel(logging.INFO) - - -# pylint:disable=unused-argument -def signal_handler(sig_num, frame): - """Stop activity on signal.""" - tornado.ioloop.IOLoop.instance().stop() - - -def run(): - """Main entry point for model runtime api.""" - - # Register signal handlers. - logging.info("Preparing signal handlers..") - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - # Set up model cache. - # If containerizing, suggest initializing the directories (and associated - # file downloads) be performed during container build time. - logging.info("Initializing model directories:") - logging.info(" bert : %s", options.bert_model_dir) - logging.info(" bidaf : %s", options.bidaf_model_dir) - - language_helper = LanguageHelper() - if ( - language_helper.initialize_models( - options.bert_model_dir, options.bidaf_model_dir - ) - is False - ): - logging.error("Could not initilize model directories. Exiting..") - return - - # Build the configuration - logging.info("Building config..") - ref_obj = {"language_helper": language_helper} - app_config = ModelHandler.build_config(ref_obj) - - logging.info("Starting Tornado model runtime service..") - application = tornado.web.Application(app_config) - application.listen(options.port) - - # Protect the loop with a try/catch - try: - # Start the app and wait for a close - tornado.ioloop.IOLoop.instance().start() - finally: - # handle error with shutting down loop - tornado.ioloop.IOLoop.instance().stop() - - -if __name__ == "__main__": - setup_logging() - run() +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Model Runtime. +Entry point for the model runtime. +""" +import os +import signal +import logging +from logging.handlers import RotatingFileHandler +import tornado +from tornado.options import define, options +from pathlib import Path +from model_corebot101.language_helper import LanguageHelper +from handlers.model_handler import ModelHandler + +HOME_DIR = str(Path.home()) + +# Define Tornado options +define("port", default=8880, help="HTTP port for model runtime to listen on", type=int) +define( + "bidaf_model_dir", + default=os.path.abspath(os.path.join(HOME_DIR, "models/bidaf")), + help="bidaf model directory", +) +define( + "bert_model_dir", + default=os.path.abspath(os.path.join(HOME_DIR, "models/bert")), + help="bert model directory", +) + + +def setup_logging(): + """Set up logging.""" + logging.info("Setting up logging infrastructure") + + # Create the rotating log handler + if not os.path.exists("logs"): + os.mkdir("logs") + handler = RotatingFileHandler( + os.path.join("./logs", "model-runtime.log"), + maxBytes=5 * 1024 ** 2, # 5 MB chunks, + backupCount=5, # limit to 25 MB logs max + ) + + # Set the formatter + handler.setFormatter( + logging.Formatter("%(asctime)s %(name)-12s %(levelname)-8s %(message)s") + ) + + # Setup the root logging with the necessary handlers + log = logging.getLogger() + log.addHandler(handler) + + # Set to info for normal processing + log.setLevel(logging.INFO) + + +# pylint:disable=unused-argument +def signal_handler(sig_num, frame): + """Stop activity on signal.""" + tornado.ioloop.IOLoop.instance().stop() + + +def run(): + """Main entry point for model runtime api.""" + + # Register signal handlers. + logging.info("Preparing signal handlers..") + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Set up model cache. + # If containerizing, suggest initializing the directories (and associated + # file downloads) be performed during container build time. + logging.info("Initializing model directories:") + logging.info(" bert : %s", options.bert_model_dir) + logging.info(" bidaf : %s", options.bidaf_model_dir) + + language_helper = LanguageHelper() + if ( + language_helper.initialize_models( + options.bert_model_dir, options.bidaf_model_dir + ) + is False + ): + logging.error("Could not initilize model directories. Exiting..") + return + + # Build the configuration + logging.info("Building config..") + ref_obj = {"language_helper": language_helper} + app_config = ModelHandler.build_config(ref_obj) + + logging.info("Starting Tornado model runtime service..") + application = tornado.web.Application(app_config) + application.listen(options.port) + + # Protect the loop with a try/catch + try: + # Start the app and wait for a close + tornado.ioloop.IOLoop.instance().start() + finally: + # handle error with shutting down loop + tornado.ioloop.IOLoop.instance().stop() + + +if __name__ == "__main__": + setup_logging() + run() diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py similarity index 97% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py index 2c9d61c87..4b989a821 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/model_runtime_svc_corebot101/model_cache.py @@ -1,67 +1,67 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -"""Model Cache. -Simple container for bidaf/bert models. -""" -import os -import logging - -from model_corebot101.bidaf.model_runtime import BidafModelRuntime -from model_corebot101.bert.model_runtime import BertModelRuntime - -# pylint:disable=line-too-long,bad-continuation -class DeprecateModelCache(object): - """Model Cache implementation.""" - - def __init__(self): - self._logger = logging.getLogger("ModelCache") - self._bert_model_dir = None - self._bidaf_model_dir = None - self._bert_intents = None - self._bidaf_entities = None - - def init_model_dir(self, bidaf_model_dir: str, bert_model_dir: str) -> bool: - """ Initialize models """ - if not os.path.exists(bidaf_model_dir): - # BiDAF needs no training, just download - if not BidafModelRuntime.init_bidaf(bidaf_model_dir, True): - self._logger.error( - "bidaf model creation failed at model directory %s..", - bidaf_model_dir, - ) - return False - - if not os.path.exists(bert_model_dir): - self._logger.error( - 'BERT model directory does not exist "%s"', bert_model_dir - ) - return False - - self._bert_model_dir = os.path.normpath(bert_model_dir) - self._bidaf_model_dir = os.path.normpath(bidaf_model_dir) - - self._bert_intents = BertModelRuntime( - model_dir=self._bert_model_dir, label_list=["Book flight", "Cancel"] - ) - self._bidaf_entities = BidafModelRuntime( - targets=["from", "to", "date"], - queries={ - "from": "which city will you travel from?", - "to": "which city will you travel to?", - "date": "which date will you travel?", - }, - model_dir=self._bidaf_model_dir, - ) - self._logger.info("bidaf entities model created : %s..", self._bidaf_model_dir) - - return True - - @property - def entities(self): - """Get the model that detect entities: bidaf.""" - return self._bidaf_entities - - @property - def intents(self): - """Get the model that detect intents: bert.""" - return self._bert_intents +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Model Cache. +Simple container for bidaf/bert models. +""" +import os +import logging + +from model_corebot101.bidaf.model_runtime import BidafModelRuntime +from model_corebot101.bert.model_runtime import BertModelRuntime + +# pylint:disable=line-too-long,bad-continuation +class DeprecateModelCache(object): + """Model Cache implementation.""" + + def __init__(self): + self._logger = logging.getLogger("ModelCache") + self._bert_model_dir = None + self._bidaf_model_dir = None + self._bert_intents = None + self._bidaf_entities = None + + def init_model_dir(self, bidaf_model_dir: str, bert_model_dir: str) -> bool: + """ Initialize models """ + if not os.path.exists(bidaf_model_dir): + # BiDAF needs no training, just download + if not BidafModelRuntime.init_bidaf(bidaf_model_dir, True): + self._logger.error( + "bidaf model creation failed at model directory %s..", + bidaf_model_dir, + ) + return False + + if not os.path.exists(bert_model_dir): + self._logger.error( + 'BERT model directory does not exist "%s"', bert_model_dir + ) + return False + + self._bert_model_dir = os.path.normpath(bert_model_dir) + self._bidaf_model_dir = os.path.normpath(bidaf_model_dir) + + self._bert_intents = BertModelRuntime( + model_dir=self._bert_model_dir, label_list=["Book flight", "Cancel"] + ) + self._bidaf_entities = BidafModelRuntime( + targets=["from", "to", "date"], + queries={ + "from": "which city will you travel from?", + "to": "which city will you travel to?", + "date": "which date will you travel?", + }, + model_dir=self._bidaf_model_dir, + ) + self._logger.info("bidaf entities model created : %s..", self._bidaf_model_dir) + + return True + + @property + def entities(self): + """Get the model that detect entities: bidaf.""" + return self._bidaf_entities + + @property + def intents(self): + """Get the model that detect intents: bert.""" + return self._bert_intents diff --git a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py rename to tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py index 35494dacc..95958734c 100644 --- a/samples/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py +++ b/tests/experimental/101.corebot-bert-bidaf/model_runtime_svc/setup.py @@ -1,42 +1,42 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -from setuptools import setup - -REQUIRES = [ - "scikit-learn>=0.21.2", - "scipy>=1.3.0", - "tornado>=6.0.2", - "model_corebot101>=0.0.1", -] - -root = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(root, "model_runtime_svc_corebot101", "about.py")) as f: - package_info = {} - info = f.read() - exec(info, package_info) - -setup( - name=package_info["__title__"], - version=package_info["__version__"], - url=package_info["__uri__"], - author=package_info["__author__"], - description=package_info["__description__"], - keywords="botframework azure botbuilder", - long_description=package_info["__summary__"], - license=package_info["__license__"], - packages=["model_runtime_svc_corebot101", "model_runtime_svc_corebot101.handlers"], - install_requires=REQUIRES, - dependency_links=["https://github.com/pytorch/pytorch"], - include_package_data=True, - classifiers=[ - "Programming Language :: Python :: 3.6", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - ], -) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "scikit-learn>=0.21.2", + "scipy>=1.3.0", + "tornado>=6.0.2", + "model_corebot101>=0.0.1", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "model_runtime_svc_corebot101", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords="botframework azure botbuilder", + long_description=package_info["__summary__"], + license=package_info["__license__"], + packages=["model_runtime_svc_corebot101", "model_runtime_svc_corebot101.handlers"], + install_requires=REQUIRES, + dependency_links=["https://github.com/pytorch/pytorch"], + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) diff --git a/samples/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb b/tests/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb similarity index 95% rename from samples/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb rename to tests/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb index 4dd87e70d..fc32cd77e 100644 --- a/samples/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb +++ b/tests/experimental/101.corebot-bert-bidaf/notebooks/bert_model_runtime.ipynb @@ -1,323 +1,323 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Test the intent classifier\n", - "This notebook uses the model trained in [bert_train.ipynb notebook](bert_train.ipynb). See the [README.md](../README.md) for instructions on how to use that notebook.\n", - "\n", - "## `model_corebot101` package\n", - "This sample creates a separate python package (`model_corebot101`) which contains code to train (tune), evaluate and infer intent classifiers for this sample.\n", - "\n", - "\n", - "## See also:\n", - "- [bert_train.ipynb](bert_train.ipynb) to train the intent classifier model.\n", - "- [bidaf_model_runtime.ipynb](bidaf_model_runtime.ipynb) to test the associated BiDAF model to test the entity classifier model.\n", - "- [The model runtime](model_runtime.ipynb) to test the both the BERT and BiDAF model together." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from model_corebot101.bert.model_runtime.bert_model_runtime import BertModelRuntime" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## `BertModelRuntime` class\n", - "The `BertModelRuntime` class is used to perform the inferences against the bot utterances.\n", - "\n", - "The model is placed (during training) in the `/models/bert` directory which is packaged with the bot.\n", - "\n", - "The `label_list` is an array of intents." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "from pathlib import Path\n", - "bert_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bert\"))\n", - "s = BertModelRuntime(model_dir=bert_model_dir, label_list=[\"Book flight\", \"Cancel\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## `BertModelRuntime.serve` method\n", - "The `BertModelRuntime.serve` method is used to perform the classification against the bot utterances." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Book flight'" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve('i want to travel from new york to berlin')" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Book flight'" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"please book a flight for me\")" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Book flight'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"from seattle to san\")" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Cancel'" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"random random random 42\")" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Cancel'" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"any\")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Book flight'" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"take me to New York\")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Book flight'" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"we'd like to go to seattle\")" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Cancel'" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"not this one\")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Cancel'" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"I don't care about this one\")" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Cancel'" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"I don't want to see that\")" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Cancel'" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"boring\")" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Book flight'" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"you have no clue how to book a flight\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "botsample", - "language": "python", - "name": "botsample" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test the intent classifier\n", + "This notebook uses the model trained in [bert_train.ipynb notebook](bert_train.ipynb). See the [README.md](../README.md) for instructions on how to use that notebook.\n", + "\n", + "## `model_corebot101` package\n", + "This sample creates a separate python package (`model_corebot101`) which contains code to train (tune), evaluate and infer intent classifiers for this sample.\n", + "\n", + "\n", + "## See also:\n", + "- [bert_train.ipynb](bert_train.ipynb) to train the intent classifier model.\n", + "- [bidaf_model_runtime.ipynb](bidaf_model_runtime.ipynb) to test the associated BiDAF model to test the entity classifier model.\n", + "- [The model runtime](model_runtime.ipynb) to test the both the BERT and BiDAF model together." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from model_corebot101.bert.model_runtime.bert_model_runtime import BertModelRuntime" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `BertModelRuntime` class\n", + "The `BertModelRuntime` class is used to perform the inferences against the bot utterances.\n", + "\n", + "The model is placed (during training) in the `/models/bert` directory which is packaged with the bot.\n", + "\n", + "The `label_list` is an array of intents." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "bert_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bert\"))\n", + "s = BertModelRuntime(model_dir=bert_model_dir, label_list=[\"Book flight\", \"Cancel\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `BertModelRuntime.serve` method\n", + "The `BertModelRuntime.serve` method is used to perform the classification against the bot utterances." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Book flight'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve('i want to travel from new york to berlin')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Book flight'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"please book a flight for me\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Book flight'" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"from seattle to san\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Cancel'" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"random random random 42\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Cancel'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"any\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Book flight'" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"take me to New York\")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Book flight'" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"we'd like to go to seattle\")" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Cancel'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"not this one\")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Cancel'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"I don't care about this one\")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Cancel'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"I don't want to see that\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Cancel'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"boring\")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Book flight'" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"you have no clue how to book a flight\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "botsample", + "language": "python", + "name": "botsample" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb b/tests/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb similarity index 99% rename from samples/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb rename to tests/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb index a9296240e..fe95eb688 100644 --- a/samples/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb +++ b/tests/experimental/101.corebot-bert-bidaf/notebooks/bert_train.ipynb @@ -1,281 +1,281 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Train the intent classifier using pretrained BERT model as featurizer\n", - "This notebook creates the BERT language classifier model. See the [README.md](../README.md) for instructions on how to run this sample.\n", - "The resulting model is placed in the `/models/bert` directory which is packaged with the bot.\n", - "\n", - "## `model_corebot101` package\n", - "This sample creates a separate python package (`model_corebot101`) which contains all the code to train, evaluate and infer intent classifiers for this sample.\n", - "\n", - "## See also:\n", - "- [The BERT runtime model](bert_model_runtime.ipynb) to test the resulting intent classifier model.\n", - "- [The BiDAF runtime model](bidaf_model_runtime.ipynb) to test the associated BiDAF model to test the entity classifier model.\n", - "- [The model runtime](model_runtime.ipynb) to test the both the BERT and BiDAF model together.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from model_corebot101.bert.train import BertTrainEval" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## `BertTrainEvan.train_eval` method\n", - "This method performs all the training and performs evaluation that's listed at the bottom of the output. Training may take several minutes to complete.\n", - "\n", - "The evaluation output should look something like the following:\n", - "```bash\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - ***** Eval results *****\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - acc = 1.0\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - acc_and_f1 = 1.0\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - eval_loss = 0.06498947739601135\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - f1 = 1.0\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - global_step = 12\n", - "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - loss = 0.02480666587750117\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Bert Model training_data_dir is set to d:\\python\\daveta-docker-wizard\\apub\\samples\\flask\\101.corebot-bert-bidaf\\model\\model_corebot101\\bert\\training_data\n", - "Bert Model model_dir is set to C:\\Users\\daveta\\models\\bert\n", - "07/02/2019 07:16:09 - INFO - model_corebot101.bert.train.bert_train_eval - device: cpu n_gpu: 0, distributed training: False, 16-bits training: None\n", - "07/02/2019 07:16:09 - INFO - pytorch_pretrained_bert.tokenization - loading vocabulary file https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-vocab.txt from cache at C:\\Users\\daveta\\.pytorch_pretrained_bert\\26bc1ad6c0ac742e9b52263248f6d0f00068293b33709fae12320c0e35ccfbbb.542ce4285a40d23a559526243235df47c5f75c197f04f37d1a0c124c32c9a084\n", - "07/02/2019 07:16:10 - INFO - pytorch_pretrained_bert.modeling - loading archive file https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased.tar.gz from cache at C:\\Users\\daveta\\.pytorch_pretrained_bert\\distributed_-1\\9c41111e2de84547a463fd39217199738d1e3deb72d4fec4399e6e241983c6f0.ae3cef932725ca7a30cdcb93fc6e09150a55e2a130ec7af63975a16c153ae2ba\n", - "07/02/2019 07:16:10 - INFO - pytorch_pretrained_bert.modeling - extracting archive file C:\\Users\\daveta\\.pytorch_pretrained_bert\\distributed_-1\\9c41111e2de84547a463fd39217199738d1e3deb72d4fec4399e6e241983c6f0.ae3cef932725ca7a30cdcb93fc6e09150a55e2a130ec7af63975a16c153ae2ba to temp dir C:\\Users\\daveta\\AppData\\Local\\Temp\\tmp9hepebcl\n", - "07/02/2019 07:16:13 - INFO - pytorch_pretrained_bert.modeling - Model config {\n", - " \"attention_probs_dropout_prob\": 0.1,\n", - " \"hidden_act\": \"gelu\",\n", - " \"hidden_dropout_prob\": 0.1,\n", - " \"hidden_size\": 768,\n", - " \"initializer_range\": 0.02,\n", - " \"intermediate_size\": 3072,\n", - " \"max_position_embeddings\": 512,\n", - " \"num_attention_heads\": 12,\n", - " \"num_hidden_layers\": 12,\n", - " \"type_vocab_size\": 2,\n", - " \"vocab_size\": 30522\n", - "}\n", - "\n", - "07/02/2019 07:16:16 - INFO - pytorch_pretrained_bert.modeling - Weights of BertForSequenceClassification not initialized from pretrained model: ['classifier.weight', 'classifier.bias']\n", - "07/02/2019 07:16:16 - INFO - pytorch_pretrained_bert.modeling - Weights from pretrained model not used in BertForSequenceClassification: ['cls.predictions.bias', 'cls.predictions.transform.dense.weight', 'cls.predictions.transform.dense.bias', 'cls.predictions.decoder.weight', 'cls.seq_relationship.weight', 'cls.seq_relationship.bias', 'cls.predictions.transform.LayerNorm.weight', 'cls.predictions.transform.LayerNorm.bias']\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - Writing example 0 of 16\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] book flight from london to paris on feb 14th [SEP]\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 2338 3462 2013 2414 2000 3000 2006 13114 6400 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Book flight (id = 0)\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-1\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] book flight to berlin on feb 14th [SEP]\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 2338 3462 2000 4068 2006 13114 6400 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Book flight (id = 0)\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-2\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] book me a flight from london to paris [SEP]\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 2338 2033 1037 3462 2013 2414 2000 3000 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Book flight (id = 0)\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-3\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] bye [SEP]\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 9061 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Cancel (id = 1)\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-4\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] cancel booking [SEP]\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 17542 21725 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Cancel (id = 1)\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - ***** Running training *****\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - Num examples = 16\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - Batch size = 4\n", - "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - Num steps = 12\n", - "Epoch: 0%| | 0/3 [00:00 float(.30):\n", - " raise Exception(f'Size of output file {f} is out of range of expected.')\n", - " else:\n", - " raise Exception(f'Expected file {f} missing from output.')" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "botsample", - "language": "python", - "name": "botsample" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Train the intent classifier using pretrained BERT model as featurizer\n", + "This notebook creates the BERT language classifier model. See the [README.md](../README.md) for instructions on how to run this sample.\n", + "The resulting model is placed in the `/models/bert` directory which is packaged with the bot.\n", + "\n", + "## `model_corebot101` package\n", + "This sample creates a separate python package (`model_corebot101`) which contains all the code to train, evaluate and infer intent classifiers for this sample.\n", + "\n", + "## See also:\n", + "- [The BERT runtime model](bert_model_runtime.ipynb) to test the resulting intent classifier model.\n", + "- [The BiDAF runtime model](bidaf_model_runtime.ipynb) to test the associated BiDAF model to test the entity classifier model.\n", + "- [The model runtime](model_runtime.ipynb) to test the both the BERT and BiDAF model together.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from model_corebot101.bert.train import BertTrainEval" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `BertTrainEvan.train_eval` method\n", + "This method performs all the training and performs evaluation that's listed at the bottom of the output. Training may take several minutes to complete.\n", + "\n", + "The evaluation output should look something like the following:\n", + "```bash\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - ***** Eval results *****\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - acc = 1.0\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - acc_and_f1 = 1.0\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - eval_loss = 0.06498947739601135\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - f1 = 1.0\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - global_step = 12\n", + "06/02/2019 19:46:52 - INFO - model_corebot101.bert.train.bert_train_eval - loss = 0.02480666587750117\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Bert Model training_data_dir is set to d:\\python\\daveta-docker-wizard\\apub\\samples\\flask\\101.corebot-bert-bidaf\\model\\model_corebot101\\bert\\training_data\n", + "Bert Model model_dir is set to C:\\Users\\daveta\\models\\bert\n", + "07/02/2019 07:16:09 - INFO - model_corebot101.bert.train.bert_train_eval - device: cpu n_gpu: 0, distributed training: False, 16-bits training: None\n", + "07/02/2019 07:16:09 - INFO - pytorch_pretrained_bert.tokenization - loading vocabulary file https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-vocab.txt from cache at C:\\Users\\daveta\\.pytorch_pretrained_bert\\26bc1ad6c0ac742e9b52263248f6d0f00068293b33709fae12320c0e35ccfbbb.542ce4285a40d23a559526243235df47c5f75c197f04f37d1a0c124c32c9a084\n", + "07/02/2019 07:16:10 - INFO - pytorch_pretrained_bert.modeling - loading archive file https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased.tar.gz from cache at C:\\Users\\daveta\\.pytorch_pretrained_bert\\distributed_-1\\9c41111e2de84547a463fd39217199738d1e3deb72d4fec4399e6e241983c6f0.ae3cef932725ca7a30cdcb93fc6e09150a55e2a130ec7af63975a16c153ae2ba\n", + "07/02/2019 07:16:10 - INFO - pytorch_pretrained_bert.modeling - extracting archive file C:\\Users\\daveta\\.pytorch_pretrained_bert\\distributed_-1\\9c41111e2de84547a463fd39217199738d1e3deb72d4fec4399e6e241983c6f0.ae3cef932725ca7a30cdcb93fc6e09150a55e2a130ec7af63975a16c153ae2ba to temp dir C:\\Users\\daveta\\AppData\\Local\\Temp\\tmp9hepebcl\n", + "07/02/2019 07:16:13 - INFO - pytorch_pretrained_bert.modeling - Model config {\n", + " \"attention_probs_dropout_prob\": 0.1,\n", + " \"hidden_act\": \"gelu\",\n", + " \"hidden_dropout_prob\": 0.1,\n", + " \"hidden_size\": 768,\n", + " \"initializer_range\": 0.02,\n", + " \"intermediate_size\": 3072,\n", + " \"max_position_embeddings\": 512,\n", + " \"num_attention_heads\": 12,\n", + " \"num_hidden_layers\": 12,\n", + " \"type_vocab_size\": 2,\n", + " \"vocab_size\": 30522\n", + "}\n", + "\n", + "07/02/2019 07:16:16 - INFO - pytorch_pretrained_bert.modeling - Weights of BertForSequenceClassification not initialized from pretrained model: ['classifier.weight', 'classifier.bias']\n", + "07/02/2019 07:16:16 - INFO - pytorch_pretrained_bert.modeling - Weights from pretrained model not used in BertForSequenceClassification: ['cls.predictions.bias', 'cls.predictions.transform.dense.weight', 'cls.predictions.transform.dense.bias', 'cls.predictions.decoder.weight', 'cls.seq_relationship.weight', 'cls.seq_relationship.bias', 'cls.predictions.transform.LayerNorm.weight', 'cls.predictions.transform.LayerNorm.bias']\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - Writing example 0 of 16\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] book flight from london to paris on feb 14th [SEP]\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 2338 3462 2013 2414 2000 3000 2006 13114 6400 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Book flight (id = 0)\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-1\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] book flight to berlin on feb 14th [SEP]\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 2338 3462 2000 4068 2006 13114 6400 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Book flight (id = 0)\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-2\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] book me a flight from london to paris [SEP]\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 2338 2033 1037 3462 2013 2414 2000 3000 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Book flight (id = 0)\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-3\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] bye [SEP]\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 9061 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Cancel (id = 1)\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - *** Example ***\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - guid: train-4\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - tokens: [CLS] cancel booking [SEP]\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_ids: 101 17542 21725 102 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - input_mask: 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - segment_ids: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.common.bert_util - label: Cancel (id = 1)\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - ***** Running training *****\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - Num examples = 16\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - Batch size = 4\n", + "07/02/2019 07:16:16 - INFO - model_corebot101.bert.train.bert_train_eval - Num steps = 12\n", + "Epoch: 0%| | 0/3 [00:00 float(.30):\n", + " raise Exception(f'Size of output file {f} is out of range of expected.')\n", + " else:\n", + " raise Exception(f'Expected file {f} missing from output.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "botsample", + "language": "python", + "name": "botsample" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb b/tests/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb similarity index 96% rename from samples/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb rename to tests/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb index 682df02c8..1b145d4af 100644 --- a/samples/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb +++ b/tests/experimental/101.corebot-bert-bidaf/notebooks/bidaf_model_runtime.ipynb @@ -1,228 +1,228 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Test the BiDAF runtime model\n", - "This notebook uses the BiDAF language entitiy recognizer model. See the [README.md](../README.md) for instructions on how to run this sample.\n", - "\n", - "## `model_corebot101` package\n", - "This sample creates a python package (`model_corebot101`) which contains all the code to train, evaluate and infer intent classifier for this sample.\n", - "\n", - "## See also:\n", - "- [The BERT training model](bert_train.ipynb) to train the intent classifier model.\n", - "- [The BERT runtime model](bert_model_runtime.ipynb) to test the BERT model to test the intent classifier model.\n", - "- [The model runtime](model_runtime.ipynb) to test the both the BERT and BiDAF model together." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from model_corebot101.bidaf.model_runtime.bidaf_model_runtime import BidafModelRuntime\n", - "import os\n", - "from pathlib import Path\n", - "from IPython.display import display\n", - "\n", - "bidaf_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bidaf\"))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## `BidafModelRuntime` class\n", - "The `BidafModelRuntime` class is used to perform the classification for entities on the bot utterances.\n", - "\n", - "The model is completely is downloaded and placed in the `/models/bidaf` directory.\n", - "\n", - "## `BidafModelRuntime.init_bidaf` method\n", - "The `BidafModelRuntime.init_bidaf` method downloads the necessary ONNX model.\n", - "\n", - "Output should look like the following: \n", - "\n", - "```bash\n", - "Creating bidaf model directory..\n", - "Checking file ../../bot/cognitiveModels/bidaf\\bidaf.onnx..\n", - "Downloading bidaf.onnx...\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "bidaf model directory already present..\n", - "[nltk_data] Downloading package punkt to\n", - "[nltk_data] C:\\Users\\daveta\\models\\bidaf...\n", - "[nltk_data] Package punkt is already up-to-date!\n", - "[nltk_data] Downloading package punkt to\n", - "[nltk_data] C:\\Users\\daveta\\AppData\\Roaming\\nltk_data...\n", - "[nltk_data] Package punkt is already up-to-date!\n", - "Checking file C:\\Users\\daveta\\models\\bidaf\\bidaf.onnx..\n", - "bidaf.onnx downloaded already!\n" - ] - }, - { - "data": { - "text/plain": [ - "'The BiDAF model successfully downloaded.'" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "if not BidafModelRuntime.init_bidaf(bidaf_model_dir, True):\n", - " display('The BiDAF model was not downloaded successfully. See output below for more details.')\n", - "else:\n", - " display('The BiDAF model successfully downloaded.')" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## `BidafModelRuntime` class\n", - "The `BidafModelRuntime` class is used to perform the classification against the bot utterances.\n", - "\n", - "- `targets` : an array of entities to classify.\n", - "- `queries` : examples passed to assist the classifier\n", - "- `model_dir` : path to the model\n", - "\n", - "The output should resemble the following:\n", - "\n", - "```bash\n", - "Loading Inference session from C:\\Users\\<>\\models\\bidaf\\bidaf.onnx..\n", - "Inference session loaded..\n", - "Processed queries..\n", - "```\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Loading Inference session from C:\\Users\\daveta\\models\\bidaf\\bidaf.onnx..\n", - "Inference session loaded..\n", - "Processed queries..\n" - ] - } - ], - "source": [ - "s = BidafModelRuntime(\n", - " targets=[\"from\", \"to\", \"date\"],\n", - " queries={\n", - " \"from\": \"which city will you travel from?\",\n", - " \"to\": \"which city will you travel to?\",\n", - " \"date\": \"which date will you travel?\",\n", - " },\n", - " model_dir = bidaf_model_dir\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'from': 'london', 'to': 'paris', 'date': 'feb 14th'}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"flight to paris from london on feb 14th\")" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'from': 'london', 'to': 'paris', 'date': 'feb 14th'}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"book flight from london to paris on feb 14th\")" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'from': 'berlin', 'to': 'berlin', 'date': '5th'}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s.serve(\"fly from berlin to paris on may 5th\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "botsample", - "language": "python", - "name": "botsample" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test the BiDAF runtime model\n", + "This notebook uses the BiDAF language entitiy recognizer model. See the [README.md](../README.md) for instructions on how to run this sample.\n", + "\n", + "## `model_corebot101` package\n", + "This sample creates a python package (`model_corebot101`) which contains all the code to train, evaluate and infer intent classifier for this sample.\n", + "\n", + "## See also:\n", + "- [The BERT training model](bert_train.ipynb) to train the intent classifier model.\n", + "- [The BERT runtime model](bert_model_runtime.ipynb) to test the BERT model to test the intent classifier model.\n", + "- [The model runtime](model_runtime.ipynb) to test the both the BERT and BiDAF model together." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from model_corebot101.bidaf.model_runtime.bidaf_model_runtime import BidafModelRuntime\n", + "import os\n", + "from pathlib import Path\n", + "from IPython.display import display\n", + "\n", + "bidaf_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bidaf\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `BidafModelRuntime` class\n", + "The `BidafModelRuntime` class is used to perform the classification for entities on the bot utterances.\n", + "\n", + "The model is completely is downloaded and placed in the `/models/bidaf` directory.\n", + "\n", + "## `BidafModelRuntime.init_bidaf` method\n", + "The `BidafModelRuntime.init_bidaf` method downloads the necessary ONNX model.\n", + "\n", + "Output should look like the following: \n", + "\n", + "```bash\n", + "Creating bidaf model directory..\n", + "Checking file ../../bot/cognitiveModels/bidaf\\bidaf.onnx..\n", + "Downloading bidaf.onnx...\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "bidaf model directory already present..\n", + "[nltk_data] Downloading package punkt to\n", + "[nltk_data] C:\\Users\\daveta\\models\\bidaf...\n", + "[nltk_data] Package punkt is already up-to-date!\n", + "[nltk_data] Downloading package punkt to\n", + "[nltk_data] C:\\Users\\daveta\\AppData\\Roaming\\nltk_data...\n", + "[nltk_data] Package punkt is already up-to-date!\n", + "Checking file C:\\Users\\daveta\\models\\bidaf\\bidaf.onnx..\n", + "bidaf.onnx downloaded already!\n" + ] + }, + { + "data": { + "text/plain": [ + "'The BiDAF model successfully downloaded.'" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "if not BidafModelRuntime.init_bidaf(bidaf_model_dir, True):\n", + " display('The BiDAF model was not downloaded successfully. See output below for more details.')\n", + "else:\n", + " display('The BiDAF model successfully downloaded.')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `BidafModelRuntime` class\n", + "The `BidafModelRuntime` class is used to perform the classification against the bot utterances.\n", + "\n", + "- `targets` : an array of entities to classify.\n", + "- `queries` : examples passed to assist the classifier\n", + "- `model_dir` : path to the model\n", + "\n", + "The output should resemble the following:\n", + "\n", + "```bash\n", + "Loading Inference session from C:\\Users\\<>\\models\\bidaf\\bidaf.onnx..\n", + "Inference session loaded..\n", + "Processed queries..\n", + "```\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Loading Inference session from C:\\Users\\daveta\\models\\bidaf\\bidaf.onnx..\n", + "Inference session loaded..\n", + "Processed queries..\n" + ] + } + ], + "source": [ + "s = BidafModelRuntime(\n", + " targets=[\"from\", \"to\", \"date\"],\n", + " queries={\n", + " \"from\": \"which city will you travel from?\",\n", + " \"to\": \"which city will you travel to?\",\n", + " \"date\": \"which date will you travel?\",\n", + " },\n", + " model_dir = bidaf_model_dir\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'from': 'london', 'to': 'paris', 'date': 'feb 14th'}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"flight to paris from london on feb 14th\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'from': 'london', 'to': 'paris', 'date': 'feb 14th'}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"book flight from london to paris on feb 14th\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'from': 'berlin', 'to': 'berlin', 'date': '5th'}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s.serve(\"fly from berlin to paris on may 5th\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "botsample", + "language": "python", + "name": "botsample" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb b/tests/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb similarity index 95% rename from samples/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb rename to tests/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb index 8adca2b30..4b6b71c60 100644 --- a/samples/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb +++ b/tests/experimental/101.corebot-bert-bidaf/notebooks/model_runtime.ipynb @@ -1,206 +1,206 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Test the intent classifier and entity extractor\n", - "This notebook uses the pretrained BiDAF model and BERT model tuned in [bert_train.ipynb notebook](bert_train.ipynb). See the [README.md](../README.md) for instructions on how to use that notebook.\n", - "\n", - "## See also:\n", - "- [bert_train.ipynb](bert_train.ipynb) to train the intent classifier model.\n", - "- [bert_model_runtime.ipynb](bert_model_runtime.ipynb) to test the BERT intent classifier model.\n", - "- [bidaf_model_runtime.ipynb](bidaf_model_runtime.ipynb) to test the associated BiDAF model to test the entity extractor model." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from model_corebot101.bert.model_runtime.bert_model_runtime import BertModelRuntime\n", - "from model_corebot101.bidaf.model_runtime.bidaf_model_runtime import BidafModelRuntime\n", - "import os\n", - "from pathlib import Path\n", - "bidaf_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bidaf\"))\n", - "bert_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bert\"))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "bidaf model directory already present..\n" - ] - } - ], - "source": [ - "BidafModelRuntime.init_bidaf(bidaf_model_dir, True)\n", - "bidaf = BidafModelRuntime(\n", - " targets=[\"from\", \"to\", \"date\"],\n", - " queries={\n", - " \"from\": \"which city will you travel from?\",\n", - " \"to\": \"which city will you travel to?\",\n", - " \"date\": \"which date will you travel?\",\n", - " },\n", - " model_dir = bidaf_model_dir\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "bert = BertModelRuntime(model_dir=bert_model_dir, label_list=[\"Book flight\", \"Cancel\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def serve(utterance):\n", - " intent = bert.serve(utterance)\n", - " entities = bidaf.serve(utterance)\n", - " return intent, entities" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## `BertModelRuntime.serve` method\n", - "The `BertModelRuntime.serve` method is used to perform the classification against the bot utterances." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"flight to paris from london on feb 14th\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"from seattle to san\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"random random random 42\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"any\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"take me to New York\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"we'd like to go to seattle\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"not this one\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"I don't care about this one\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"I don't want to see that\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"boring\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "serve(\"you have no clue how to book a flight\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.8" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test the intent classifier and entity extractor\n", + "This notebook uses the pretrained BiDAF model and BERT model tuned in [bert_train.ipynb notebook](bert_train.ipynb). See the [README.md](../README.md) for instructions on how to use that notebook.\n", + "\n", + "## See also:\n", + "- [bert_train.ipynb](bert_train.ipynb) to train the intent classifier model.\n", + "- [bert_model_runtime.ipynb](bert_model_runtime.ipynb) to test the BERT intent classifier model.\n", + "- [bidaf_model_runtime.ipynb](bidaf_model_runtime.ipynb) to test the associated BiDAF model to test the entity extractor model." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from model_corebot101.bert.model_runtime.bert_model_runtime import BertModelRuntime\n", + "from model_corebot101.bidaf.model_runtime.bidaf_model_runtime import BidafModelRuntime\n", + "import os\n", + "from pathlib import Path\n", + "bidaf_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bidaf\"))\n", + "bert_model_dir = os.path.abspath(os.path.join(Path.home(), \"models/bert\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "bidaf model directory already present..\n" + ] + } + ], + "source": [ + "BidafModelRuntime.init_bidaf(bidaf_model_dir, True)\n", + "bidaf = BidafModelRuntime(\n", + " targets=[\"from\", \"to\", \"date\"],\n", + " queries={\n", + " \"from\": \"which city will you travel from?\",\n", + " \"to\": \"which city will you travel to?\",\n", + " \"date\": \"which date will you travel?\",\n", + " },\n", + " model_dir = bidaf_model_dir\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bert = BertModelRuntime(model_dir=bert_model_dir, label_list=[\"Book flight\", \"Cancel\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def serve(utterance):\n", + " intent = bert.serve(utterance)\n", + " entities = bidaf.serve(utterance)\n", + " return intent, entities" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `BertModelRuntime.serve` method\n", + "The `BertModelRuntime.serve` method is used to perform the classification against the bot utterances." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"flight to paris from london on feb 14th\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"from seattle to san\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"random random random 42\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"any\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"take me to New York\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"we'd like to go to seattle\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"not this one\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"I don't care about this one\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"I don't want to see that\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"boring\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "serve(\"you have no clue how to book a flight\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/experimental/101.corebot-bert-bidaf/bot/requirements.txt b/tests/experimental/101.corebot-bert-bidaf/requirements.txt similarity index 95% rename from samples/experimental/101.corebot-bert-bidaf/bot/requirements.txt rename to tests/experimental/101.corebot-bert-bidaf/requirements.txt index 4b99b18d5..496696f2c 100644 --- a/samples/experimental/101.corebot-bert-bidaf/bot/requirements.txt +++ b/tests/experimental/101.corebot-bert-bidaf/requirements.txt @@ -1,41 +1,41 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# Note: The model must be built first! -# cd model -# - -# The following are installed outside of requirements.txt -# conda install -c pytorch pytorch -y -# pip install onnxruntime -# Install python package dependencies with the following: -# `pip install -r requirements.txt` - -# Bot -Flask>=1.0.2 -asyncio>=3.4.3 -requests>=2.18.1 - -# Bot Framework -botframework-connector>=4.4.0.b1 -botbuilder-schema>=4.4.0.b1 -botbuilder-core>=4.4.0.b1 -botbuilder-dialogs>=4.4.0.b1 -botbuilder-ai>=4.4.0.b1 -datatypes-date-time>=1.0.0.a1 -azure-cognitiveservices-language-luis>=0.2.0 -msrest>=0.6.6 - -# Internal library - must be built first! -model_corebot101>=0.0.1 - -torch -onnx -onnxruntime -tqdm>=4.32.1 -pytorch-pretrained-bert>=0.6.2 -nltk>=3.4.1 -numpy>=1.16.3 -scipy>=1.3.0 -scikit-learn>=0.21.2 - +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# Note: The model must be built first! +# cd model +# + +# The following are installed outside of requirements.txt +# conda install -c pytorch pytorch -y +# pip install onnxruntime +# Install python package dependencies with the following: +# `pip install -r requirements.txt` + +# Bot +Flask>=1.0.2 +asyncio>=3.4.3 +requests>=2.18.1 + +# Bot Framework +botframework-connector>=4.4.0.b1 +botbuilder-schema>=4.4.0.b1 +botbuilder-core>=4.4.0.b1 +botbuilder-dialogs>=4.4.0.b1 +botbuilder-ai>=4.4.0.b1 +datatypes-date-time>=1.0.0.a1 +azure-cognitiveservices-language-luis>=0.2.0 +msrest>=0.6.6 + +# Internal library - must be built first! +model_corebot101>=0.0.1 + +torch +onnx +onnxruntime +tqdm>=4.32.1 +pytorch-pretrained-bert>=0.6.2 +nltk>=3.4.1 +numpy>=1.16.3 +scipy>=1.3.0 +scikit-learn>=0.21.2 + diff --git a/samples/experimental/sso/child/adapter_with_error_handler.py b/tests/experimental/sso/child/adapter_with_error_handler.py similarity index 100% rename from samples/experimental/sso/child/adapter_with_error_handler.py rename to tests/experimental/sso/child/adapter_with_error_handler.py diff --git a/samples/experimental/sso/child/app.py b/tests/experimental/sso/child/app.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/app.py rename to tests/experimental/sso/child/app.py diff --git a/samples/experimental/sso/child/bots/__init__.py b/tests/experimental/sso/child/bots/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/bots/__init__.py rename to tests/experimental/sso/child/bots/__init__.py diff --git a/samples/experimental/sso/child/bots/child_bot.py b/tests/experimental/sso/child/bots/child_bot.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/bots/child_bot.py rename to tests/experimental/sso/child/bots/child_bot.py diff --git a/samples/experimental/sso/child/config.py b/tests/experimental/sso/child/config.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/config.py rename to tests/experimental/sso/child/config.py diff --git a/samples/experimental/sso/child/dialogs/__init__.py b/tests/experimental/sso/child/dialogs/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/dialogs/__init__.py rename to tests/experimental/sso/child/dialogs/__init__.py diff --git a/samples/experimental/sso/child/dialogs/main_dialog.py b/tests/experimental/sso/child/dialogs/main_dialog.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/dialogs/main_dialog.py rename to tests/experimental/sso/child/dialogs/main_dialog.py diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py b/tests/experimental/sso/child/helpers/__init__.py similarity index 100% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py rename to tests/experimental/sso/child/helpers/__init__.py diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py b/tests/experimental/sso/child/helpers/dialog_helper.py similarity index 100% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py rename to tests/experimental/sso/child/helpers/dialog_helper.py diff --git a/samples/experimental/sso/parent/ReadMeForSSOTesting.md b/tests/experimental/sso/parent/ReadMeForSSOTesting.md similarity index 100% rename from samples/experimental/sso/parent/ReadMeForSSOTesting.md rename to tests/experimental/sso/parent/ReadMeForSSOTesting.md diff --git a/samples/experimental/sso/parent/app.py b/tests/experimental/sso/parent/app.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/parent/app.py rename to tests/experimental/sso/parent/app.py diff --git a/samples/experimental/sso/parent/bots/__init__.py b/tests/experimental/sso/parent/bots/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/parent/bots/__init__.py rename to tests/experimental/sso/parent/bots/__init__.py diff --git a/samples/experimental/sso/parent/bots/parent_bot.py b/tests/experimental/sso/parent/bots/parent_bot.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/parent/bots/parent_bot.py rename to tests/experimental/sso/parent/bots/parent_bot.py diff --git a/samples/experimental/sso/parent/config.py b/tests/experimental/sso/parent/config.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/parent/config.py rename to tests/experimental/sso/parent/config.py diff --git a/samples/experimental/sso/parent/dialogs/__init__.py b/tests/experimental/sso/parent/dialogs/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/parent/dialogs/__init__.py rename to tests/experimental/sso/parent/dialogs/__init__.py diff --git a/samples/experimental/sso/parent/dialogs/main_dialog.py b/tests/experimental/sso/parent/dialogs/main_dialog.py similarity index 100% rename from samples/experimental/sso/parent/dialogs/main_dialog.py rename to tests/experimental/sso/parent/dialogs/main_dialog.py diff --git a/samples/experimental/sso/child/helpers/__init__.py b/tests/experimental/sso/parent/helpers/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/helpers/__init__.py rename to tests/experimental/sso/parent/helpers/__init__.py diff --git a/samples/experimental/sso/child/helpers/dialog_helper.py b/tests/experimental/sso/parent/helpers/dialog_helper.py old mode 100755 new mode 100644 similarity index 100% rename from samples/experimental/sso/child/helpers/dialog_helper.py rename to tests/experimental/sso/parent/helpers/dialog_helper.py diff --git a/samples/experimental/sso/parent/skill_client.py b/tests/experimental/sso/parent/skill_client.py similarity index 100% rename from samples/experimental/sso/parent/skill_client.py rename to tests/experimental/sso/parent/skill_client.py diff --git a/samples/experimental/test-protocol/app.py b/tests/experimental/test-protocol/app.py similarity index 100% rename from samples/experimental/test-protocol/app.py rename to tests/experimental/test-protocol/app.py diff --git a/samples/experimental/test-protocol/config.py b/tests/experimental/test-protocol/config.py similarity index 96% rename from samples/experimental/test-protocol/config.py rename to tests/experimental/test-protocol/config.py index 9a6ec94ea..a6a419f17 100644 --- a/samples/experimental/test-protocol/config.py +++ b/tests/experimental/test-protocol/config.py @@ -1,18 +1,18 @@ -#!/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 = 3428 - APP_ID = os.environ.get("MicrosoftAppId", "") - APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") - NEXT = "http://localhost:3978/api/messages" - SERVICE_URL = "http://localhost:3428/api/connector" - SKILL_APP_ID = "" +#!/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 = 3428 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + NEXT = "http://localhost:3978/api/messages" + SERVICE_URL = "http://localhost:3428/api/connector" + SKILL_APP_ID = "" diff --git a/samples/experimental/test-protocol/routing_handler.py b/tests/experimental/test-protocol/routing_handler.py similarity index 97% rename from samples/experimental/test-protocol/routing_handler.py rename to tests/experimental/test-protocol/routing_handler.py index 9b9bd346e..8d13b45a2 100644 --- a/samples/experimental/test-protocol/routing_handler.py +++ b/tests/experimental/test-protocol/routing_handler.py @@ -1,134 +1,134 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -from botbuilder.core import ChannelServiceHandler -from botbuilder.schema import ( - Activity, - ChannelAccount, - ConversationParameters, - ConversationResourceResponse, - ConversationsResult, - PagedMembersResult, - ResourceResponse -) -from botframework.connector.aio import ConnectorClient -from botframework.connector.auth import ( - AuthenticationConfiguration, - ChannelProvider, - ClaimsIdentity, - CredentialProvider, - MicrosoftAppCredentials -) - -from routing_id_factory import RoutingIdFactory - - -class RoutingHandler(ChannelServiceHandler): - def __init__( - self, - conversation_id_factory: RoutingIdFactory, - credential_provider: CredentialProvider, - auth_configuration: AuthenticationConfiguration, - channel_provider: ChannelProvider = None - ): - super().__init__(credential_provider, auth_configuration, channel_provider) - self._factory = conversation_id_factory - self._credentials = MicrosoftAppCredentials(None, None) - - async def on_reply_to_activity( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - activity_id: str, - activity: Activity, - ) -> ResourceResponse: - back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) - connector_client = self._get_connector_client(back_service_url) - activity.conversation.id = back_conversation_id - activity.service_url = back_service_url - - return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) - - async def on_send_to_conversation( - self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, - ) -> ResourceResponse: - back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) - connector_client = self._get_connector_client(back_service_url) - activity.conversation.id = back_conversation_id - activity.service_url = back_service_url - - return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) - - async def on_update_activity( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - activity_id: str, - activity: Activity, - ) -> ResourceResponse: - back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) - connector_client = self._get_connector_client(back_service_url) - activity.conversation.id = back_conversation_id - activity.service_url = back_service_url - - return await connector_client.conversations.update_activity(back_conversation_id, activity.id, activity) - - async def on_delete_activity( - self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, - ): - back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) - connector_client = self._get_connector_client(back_service_url) - - return await connector_client.conversations.delete_activity(back_conversation_id, activity_id) - - async def on_create_conversation( - self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, - ) -> ConversationResourceResponse: - # This call will be used in Teams scenarios. - - # Scenario #1 - creating a thread with an activity in a Channel in a Team - # In order to know the serviceUrl in the case of Teams we would need to look it up based upon the - # TeamsChannelData. - # The inbound activity will contain the TeamsChannelData and so will the ConversationParameters. - - # Scenario #2 - starting a one on one conversation with a particular user - # - needs further analysis - - - back_service_url = "http://tempuri" - connector_client = self._get_connector_client(back_service_url) - - return await connector_client.conversations.create_conversation(parameters) - - async def on_delete_conversation_member( - self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, - ): - return await super().on_delete_conversation_member(claims_identity, conversation_id, member_id) - - async def on_get_activity_members( - self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, - ) -> List[ChannelAccount]: - return await super().on_get_activity_members(claims_identity, conversation_id, activity_id) - - async def on_get_conversation_members( - self, claims_identity: ClaimsIdentity, conversation_id: str, - ) -> List[ChannelAccount]: - return await super().on_get_conversation_members(claims_identity, conversation_id) - - async def on_get_conversations( - self, claims_identity: ClaimsIdentity, continuation_token: str = "", - ) -> ConversationsResult: - return await super().on_get_conversations(claims_identity, continuation_token) - - async def on_get_conversation_paged_members( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - page_size: int = None, - continuation_token: str = "", - ) -> PagedMembersResult: - return await super().on_get_conversation_paged_members(claims_identity, conversation_id, continuation_token) - - def _get_connector_client(self, service_url: str): - return ConnectorClient(self._credentials, service_url) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core import ChannelServiceHandler +from botbuilder.schema import ( + Activity, + ChannelAccount, + ConversationParameters, + ConversationResourceResponse, + ConversationsResult, + PagedMembersResult, + ResourceResponse +) +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, + MicrosoftAppCredentials +) + +from routing_id_factory import RoutingIdFactory + + +class RoutingHandler(ChannelServiceHandler): + def __init__( + self, + conversation_id_factory: RoutingIdFactory, + credential_provider: CredentialProvider, + auth_configuration: AuthenticationConfiguration, + channel_provider: ChannelProvider = None + ): + super().__init__(credential_provider, auth_configuration, channel_provider) + self._factory = conversation_id_factory + self._credentials = MicrosoftAppCredentials(None, None) + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.update_activity(back_conversation_id, activity.id, activity) + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + + return await connector_client.conversations.delete_activity(back_conversation_id, activity_id) + + async def on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + # This call will be used in Teams scenarios. + + # Scenario #1 - creating a thread with an activity in a Channel in a Team + # In order to know the serviceUrl in the case of Teams we would need to look it up based upon the + # TeamsChannelData. + # The inbound activity will contain the TeamsChannelData and so will the ConversationParameters. + + # Scenario #2 - starting a one on one conversation with a particular user + # - needs further analysis - + + back_service_url = "http://tempuri" + connector_client = self._get_connector_client(back_service_url) + + return await connector_client.conversations.create_conversation(parameters) + + async def on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + return await super().on_delete_conversation_member(claims_identity, conversation_id, member_id) + + async def on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + return await super().on_get_activity_members(claims_identity, conversation_id, activity_id) + + async def on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + return await super().on_get_conversation_members(claims_identity, conversation_id) + + async def on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + return await super().on_get_conversations(claims_identity, continuation_token) + + async def on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + return await super().on_get_conversation_paged_members(claims_identity, conversation_id, continuation_token) + + def _get_connector_client(self, service_url: str): + return ConnectorClient(self._credentials, service_url) diff --git a/samples/experimental/test-protocol/routing_id_factory.py b/tests/experimental/test-protocol/routing_id_factory.py similarity index 97% rename from samples/experimental/test-protocol/routing_id_factory.py rename to tests/experimental/test-protocol/routing_id_factory.py index c5ddb7524..0460f2df9 100644 --- a/samples/experimental/test-protocol/routing_id_factory.py +++ b/tests/experimental/test-protocol/routing_id_factory.py @@ -1,22 +1,22 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from uuid import uuid4 -from typing import Dict, Tuple - - -class RoutingIdFactory: - def __init__(self): - self._forward_x_ref: Dict[str, str] = {} - self._backward_x_ref: Dict[str, Tuple[str, str]] = {} - - def create_skill_conversation_id(self, conversation_id: str, service_url: str) -> str: - result = self._forward_x_ref.get(conversation_id, str(uuid4())) - - self._forward_x_ref[conversation_id] = result - self._backward_x_ref[result] = (conversation_id, service_url) - - return result - - def get_conversation_info(self, encoded_conversation_id) -> Tuple[str, str]: - return self._backward_x_ref[encoded_conversation_id] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from typing import Dict, Tuple + + +class RoutingIdFactory: + def __init__(self): + self._forward_x_ref: Dict[str, str] = {} + self._backward_x_ref: Dict[str, Tuple[str, str]] = {} + + def create_skill_conversation_id(self, conversation_id: str, service_url: str) -> str: + result = self._forward_x_ref.get(conversation_id, str(uuid4())) + + self._forward_x_ref[conversation_id] = result + self._backward_x_ref[result] = (conversation_id, service_url) + + return result + + def get_conversation_info(self, encoded_conversation_id) -> Tuple[str, str]: + return self._backward_x_ref[encoded_conversation_id] diff --git a/samples/experimental/skills-buffered/child/app.py b/tests/skills/skills-buffered/child/app.py similarity index 100% rename from samples/experimental/skills-buffered/child/app.py rename to tests/skills/skills-buffered/child/app.py diff --git a/samples/experimental/skills-buffered/child/bots/__init__.py b/tests/skills/skills-buffered/child/bots/__init__.py similarity index 100% rename from samples/experimental/skills-buffered/child/bots/__init__.py rename to tests/skills/skills-buffered/child/bots/__init__.py diff --git a/samples/experimental/skills-buffered/child/bots/child_bot.py b/tests/skills/skills-buffered/child/bots/child_bot.py similarity index 100% rename from samples/experimental/skills-buffered/child/bots/child_bot.py rename to tests/skills/skills-buffered/child/bots/child_bot.py diff --git a/samples/experimental/skills-buffered/child/config.py b/tests/skills/skills-buffered/child/config.py similarity index 100% rename from samples/experimental/skills-buffered/child/config.py rename to tests/skills/skills-buffered/child/config.py diff --git a/samples/experimental/skills-buffered/child/requirements.txt b/tests/skills/skills-buffered/child/requirements.txt similarity index 100% rename from samples/experimental/skills-buffered/child/requirements.txt rename to tests/skills/skills-buffered/child/requirements.txt diff --git a/samples/experimental/skills-buffered/parent/app.py b/tests/skills/skills-buffered/parent/app.py similarity index 100% rename from samples/experimental/skills-buffered/parent/app.py rename to tests/skills/skills-buffered/parent/app.py diff --git a/samples/experimental/skills-buffered/parent/bots/__init__.py b/tests/skills/skills-buffered/parent/bots/__init__.py similarity index 100% rename from samples/experimental/skills-buffered/parent/bots/__init__.py rename to tests/skills/skills-buffered/parent/bots/__init__.py diff --git a/samples/experimental/skills-buffered/parent/bots/parent_bot.py b/tests/skills/skills-buffered/parent/bots/parent_bot.py similarity index 100% rename from samples/experimental/skills-buffered/parent/bots/parent_bot.py rename to tests/skills/skills-buffered/parent/bots/parent_bot.py diff --git a/samples/experimental/skills-buffered/parent/config.py b/tests/skills/skills-buffered/parent/config.py similarity index 100% rename from samples/experimental/skills-buffered/parent/config.py rename to tests/skills/skills-buffered/parent/config.py diff --git a/samples/experimental/skills-buffered/parent/requirements.txt b/tests/skills/skills-buffered/parent/requirements.txt similarity index 100% rename from samples/experimental/skills-buffered/parent/requirements.txt rename to tests/skills/skills-buffered/parent/requirements.txt diff --git a/samples/experimental/skills-buffered/parent/skill_conversation_id_factory.py b/tests/skills/skills-buffered/parent/skill_conversation_id_factory.py similarity index 100% rename from samples/experimental/skills-buffered/parent/skill_conversation_id_factory.py rename to tests/skills/skills-buffered/parent/skill_conversation_id_factory.py diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/README.md similarity index 97% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/README.md index 40e84f525..f1a48af72 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/README.md @@ -1,30 +1,30 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/app.py similarity index 96% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/app.py index 95fd89577..96ffb9b2b 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/app.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/app.py @@ -1,98 +1,98 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import sys -import traceback -from datetime import datetime - -from aiohttp import web -from aiohttp.web import Request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - ConversationState, - MemoryStorage, - UserState, - TurnContext, - BotFrameworkAdapter, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import AuthBot -from dialogs import MainDialog -from config import DefaultConfig - -CONFIG = DefaultConfig() - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) -ADAPTER = BotFrameworkAdapter(SETTINGS) - -STORAGE = MemoryStorage() - -CONVERSATION_STATE = ConversationState(STORAGE) -USER_STATE = UserState(STORAGE) - - -# 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) - traceback.print_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 - -DIALOG = MainDialog(CONFIG) - - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - # Create the Bot - bot = AuthBot(CONVERSATION_STATE, USER_STATE, DIALOG) - - # Main bot message handler. - if "application/json" in req.headers["Content-Type"]: - body = await req.json() - else: - return Response(status=415) - - activity = Activity().deserialize(body) - auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" - - try: - await ADAPTER.process_activity(activity, auth_header, bot.on_turn) - return Response(status=201) - except Exception as exception: - raise exception - - -APP = web.Application() -APP.router.add_post("/api/messages", messages) - -if __name__ == "__main__": - try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import AuthBot +from dialogs import MainDialog +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +STORAGE = MemoryStorage() + +CONVERSATION_STATE = ConversationState(STORAGE) +USER_STATE = UserState(STORAGE) + + +# 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) + traceback.print_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 + +DIALOG = MainDialog(CONFIG) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Create the Bot + bot = AuthBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, bot.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py similarity index 97% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py index 9fae5bf38..9b49815be 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from .dialog_bot import DialogBot -from .auth_bot import AuthBot - -__all__ = ["DialogBot", "AuthBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from .dialog_bot import DialogBot +from .auth_bot import AuthBot + +__all__ = ["DialogBot", "AuthBot"] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py similarity index 97% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py index ec0325fda..e72b681c1 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/auth_bot.py @@ -1,42 +1,42 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List - -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.schema import ActivityTypes, ChannelAccount - -from helpers.dialog_helper import DialogHelper -from bots import DialogBot - - -class AuthBot(DialogBot): - async def on_turn(self, turn_context: TurnContext): - if turn_context.activity.type == ActivityTypes.invoke: - await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState") - ) - else: - await super().on_turn(turn_context) - - async def on_members_added_activity( - self, members_added: List[ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - MessageFactory.text("Hello and welcome!") - ) - - async def on_token_response_event( - self, turn_context: TurnContext - ): - print("on token: Running dialog with Message Activity.") - - return await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState") - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import ActivityTypes, ChannelAccount + +from helpers.dialog_helper import DialogHelper +from bots import DialogBot + + +class AuthBot(DialogBot): + async def on_turn(self, turn_context: TurnContext): + if turn_context.activity.type == ActivityTypes.invoke: + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState") + ) + else: + await super().on_turn(turn_context) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + MessageFactory.text("Hello and welcome!") + ) + + async def on_token_response_event( + self, turn_context: TurnContext + ): + print("on token: Running dialog with Message Activity.") + + return await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState") + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py similarity index 97% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py index 12576303e..eb9b355f6 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/bots/dialog_bot.py @@ -1,29 +1,29 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext -from botbuilder.dialogs import Dialog - -from helpers.dialog_helper import DialogHelper - - -class DialogBot(ActivityHandler): - def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): - self.conversation_state = conversation_state - self._user_state = user_state - self.dialog = dialog - - async def on_turn(self, turn_context: TurnContext): - await super().on_turn(turn_context) - - await self.conversation_state.save_changes(turn_context, False) - await self._user_state.save_changes(turn_context, False) - - async def on_message_activity(self, turn_context: TurnContext): - print("on message: Running dialog with Message Activity.") - - return await DialogHelper.run_dialog( - self.dialog, - turn_context, - self.conversation_state.create_property("DialogState") - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog + +from helpers.dialog_helper import DialogHelper + + +class DialogBot(ActivityHandler): + def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + self.conversation_state = conversation_state + self._user_state = user_state + self.dialog = dialog + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + await self.conversation_state.save_changes(turn_context, False) + await self._user_state.save_changes(turn_context, False) + + async def on_message_activity(self, turn_context: TurnContext): + print("on message: Running dialog with Message Activity.") + + return await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState") + ) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/config.py similarity index 95% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/config.py index 97a5625bf..3c064f3ff 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/config.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/config.py @@ -1,16 +1,16 @@ -#!/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", "") - CONNECTION_NAME = "" +#!/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", "") + CONNECTION_NAME = "" diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py similarity index 94% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py index f8117421c..6ec3374f3 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/__init__.py @@ -1,7 +1,7 @@ -from .logout_dialog import LogoutDialog -from .main_dialog import MainDialog - -__all__ = [ - "LogoutDialog", - "MainDialog" -] +from .logout_dialog import LogoutDialog +from .main_dialog import MainDialog + +__all__ = [ + "LogoutDialog", + "MainDialog" +] diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py similarity index 97% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py index 2e4a6c653..6855b8710 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/logout_dialog.py @@ -1,47 +1,47 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - ComponentDialog, - DialogTurnResult, -) -from botbuilder.dialogs import DialogContext -from botbuilder.core import BotFrameworkAdapter, MessageFactory -from botbuilder.schema import ActivityTypes - - -class LogoutDialog(ComponentDialog): - def __init__( - self, dialog_id: str, connection_name: str, - ): - super().__init__(dialog_id) - - self.connection_name = connection_name - - async def on_begin_dialog( - self, inner_dc: DialogContext, options: object - ) -> DialogTurnResult: - result = await self._interrupt(inner_dc) - if result: - return result - - return await super().on_begin_dialog(inner_dc, options) - - async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: - result = await self._interrupt(inner_dc) - if result: - return result - - return await super().on_continue_dialog(inner_dc) - - async def _interrupt(self, inner_dc: DialogContext): - if inner_dc.context.activity.type == ActivityTypes.message: - text = inner_dc.context.activity.text.lower() - - if text == "logout": - bot_adapter: BotFrameworkAdapter = inner_dc.context.adapter - await bot_adapter.sign_out_user(inner_dc.context, self.connection_name) - await inner_dc.context.send_activity(MessageFactory.text("You have been signed out.")) - return await inner_dc.cancel_all_dialogs() - - return None +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogTurnResult, +) +from botbuilder.dialogs import DialogContext +from botbuilder.core import BotFrameworkAdapter, MessageFactory +from botbuilder.schema import ActivityTypes + + +class LogoutDialog(ComponentDialog): + def __init__( + self, dialog_id: str, connection_name: str, + ): + super().__init__(dialog_id) + + self.connection_name = connection_name + + async def on_begin_dialog( + self, inner_dc: DialogContext, options: object + ) -> DialogTurnResult: + result = await self._interrupt(inner_dc) + if result: + return result + + return await super().on_begin_dialog(inner_dc, options) + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + result = await self._interrupt(inner_dc) + if result: + return result + + return await super().on_continue_dialog(inner_dc) + + async def _interrupt(self, inner_dc: DialogContext): + if inner_dc.context.activity.type == ActivityTypes.message: + text = inner_dc.context.activity.text.lower() + + if text == "logout": + bot_adapter: BotFrameworkAdapter = inner_dc.context.adapter + await bot_adapter.sign_out_user(inner_dc.context, self.connection_name) + await inner_dc.context.send_activity(MessageFactory.text("You have been signed out.")) + return await inner_dc.cancel_all_dialogs() + + return None diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py similarity index 97% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py index e851bbe38..afdf3727a 100644 --- a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/dialogs/main_dialog.py @@ -1,72 +1,72 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.dialogs import ( - WaterfallDialog, - WaterfallStepContext, - DialogTurnResult, -) -from botbuilder.dialogs.prompts import ConfirmPrompt, PromptOptions, OAuthPrompt, OAuthPromptSettings -from botbuilder.core import MessageFactory -from dialogs import LogoutDialog - - -class MainDialog(LogoutDialog): - def __init__( - self, configuration, - ): - super().__init__(MainDialog.__name__, configuration.CONNECTION_NAME) - - self.add_dialog( - OAuthPrompt( - OAuthPrompt.__name__, - OAuthPromptSettings( - connection_name=self.connection_name, - text="Please Sign In", - title="Sign In", - timeout=30000, - ) - ) - ) - self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) - self.add_dialog( - WaterfallDialog( - "WFDialog", - [self.prompt_step, self.login_step, self.display_token_phase_one, self.display_token_phase_two] - ) - ) - - self.initial_dialog_id = "WFDialog" - - async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - return await step_context.begin_dialog( - OAuthPrompt.__name__ - ) - - async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - token_response = step_context.result - if token_response: - await step_context.context.send_activity(MessageFactory.text("You are now logged in.")) - return await step_context.prompt( - ConfirmPrompt.__name__, - PromptOptions(prompt=MessageFactory.text("Would you like to view your token?")) - ) - - await step_context.context.send_activity(MessageFactory.text("Login was not successful please try again.")) - return await step_context.end_dialog() - - async def display_token_phase_one(self, step_context: WaterfallStepContext) -> DialogTurnResult: - await step_context.context.send_activity(MessageFactory.text("Thank you")) - - result = step_context.result - if result: - return await step_context.begin_dialog(OAuthPrompt.__name__) - - return await step_context.end_dialog() - - async def display_token_phase_two(self, step_context: WaterfallStepContext) -> DialogTurnResult: - token_response = step_context.result - if token_response: - await step_context.context.send_activity(MessageFactory.text(f"Here is your token {token_response.token}")) - - return await step_context.end_dialog() +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.dialogs.prompts import ConfirmPrompt, PromptOptions, OAuthPrompt, OAuthPromptSettings +from botbuilder.core import MessageFactory +from dialogs import LogoutDialog + + +class MainDialog(LogoutDialog): + def __init__( + self, configuration, + ): + super().__init__(MainDialog.__name__, configuration.CONNECTION_NAME) + + self.add_dialog( + OAuthPrompt( + OAuthPrompt.__name__, + OAuthPromptSettings( + connection_name=self.connection_name, + text="Please Sign In", + title="Sign In", + timeout=30000, + ) + ) + ) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + self.add_dialog( + WaterfallDialog( + "WFDialog", + [self.prompt_step, self.login_step, self.display_token_phase_one, self.display_token_phase_two] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + return await step_context.begin_dialog( + OAuthPrompt.__name__ + ) + + async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + token_response = step_context.result + if token_response: + await step_context.context.send_activity(MessageFactory.text("You are now logged in.")) + return await step_context.prompt( + ConfirmPrompt.__name__, + PromptOptions(prompt=MessageFactory.text("Would you like to view your token?")) + ) + + await step_context.context.send_activity(MessageFactory.text("Login was not successful please try again.")) + return await step_context.end_dialog() + + async def display_token_phase_one(self, step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity(MessageFactory.text("Thank you")) + + result = step_context.result + if result: + return await step_context.begin_dialog(OAuthPrompt.__name__) + + return await step_context.end_dialog() + + async def display_token_phase_two(self, step_context: WaterfallStepContext) -> DialogTurnResult: + token_response = step_context.result + if token_response: + await step_context.context.send_activity(MessageFactory.text(f"Here is your token {token_response.token}")) + + return await step_context.end_dialog() diff --git a/samples/experimental/sso/parent/helpers/__init__.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py old mode 100755 new mode 100644 similarity index 96% rename from samples/experimental/sso/parent/helpers/__init__.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py index a824eb8f4..8dba0e6d6 --- a/samples/experimental/sso/parent/helpers/__init__.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from . import dialog_helper - -__all__ = ["dialog_helper"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import dialog_helper + +__all__ = ["dialog_helper"] diff --git a/samples/experimental/sso/parent/helpers/dialog_helper.py b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py old mode 100755 new mode 100644 similarity index 97% rename from samples/experimental/sso/parent/helpers/dialog_helper.py rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py index 6b2646b0b..062271fd8 --- a/samples/experimental/sso/parent/helpers/dialog_helper.py +++ b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/helpers/dialog_helper.py @@ -1,19 +1,19 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import StatePropertyAccessor, TurnContext -from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus - - -class DialogHelper: - @staticmethod - async def run_dialog( - dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor - ): - dialog_set = DialogSet(accessor) - dialog_set.add(dialog) - - dialog_context = await dialog_set.create_context(turn_context) - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + + +class DialogHelper: + @staticmethod + async def run_dialog( + dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor + ): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt b/tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt similarity index 100% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt rename to tests/skills/skills-prototypes/dialog-to-dialog/authentication-bot/requirements.txt diff --git a/scenarios/conversation-update/README.md b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md similarity index 97% rename from scenarios/conversation-update/README.md rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md index 40e84f525..f1a48af72 100644 --- a/scenarios/conversation-update/README.md +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md @@ -1,30 +1,30 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py similarity index 96% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py index cfa375aac..d1964743e 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py @@ -1,85 +1,85 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import sys -from datetime import datetime - -from aiohttp import web -from aiohttp.web import Request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, -) -from botbuilder.schema import Activity, ActivityTypes - -from bots import EchoBot -from config import DefaultConfig - -CONFIG = DefaultConfig() - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = EchoBot() - - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - # Main bot message handler. - if "application/json" in req.headers["Content-Type"]: - body = await req.json() - else: - return Response(status=415) - - activity = Activity().deserialize(body) - auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" - - try: - await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) - return Response(status=201) - except Exception as exception: - raise exception - - -APP = web.Application() -APP.router.add_post("/api/messages", messages) - -if __name__ == "__main__": - try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import EchoBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = EchoBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py similarity index 96% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py index f95fbbbad..e41ca32ac 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .echo_bot import EchoBot - -__all__ = ["EchoBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py similarity index 97% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py index 91c3febb0..e82cebb51 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py @@ -1,27 +1,27 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import ActivityHandler, MessageFactory, TurnContext -from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes - - -class EchoBot(ActivityHandler): - async def on_message_activity(self, turn_context: TurnContext): - if "end" in turn_context.activity.text or "exit" in turn_context.activity.text: - # Send End of conversation at the end. - await turn_context.send_activity( - MessageFactory.text("Ending conversation from the skill...") - ) - - end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) - end_of_conversation.code = EndOfConversationCodes.completed_successfully - await turn_context.send_activity(end_of_conversation) - else: - await turn_context.send_activity( - MessageFactory.text(f"Echo: {turn_context.activity.text}") - ) - await turn_context.send_activity( - MessageFactory.text( - f'Say "end" or "exit" and I\'ll end the conversation and back to the parent.' - ) - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes + + +class EchoBot(ActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + if "end" in turn_context.activity.text or "exit" in turn_context.activity.text: + # Send End of conversation at the end. + await turn_context.send_activity( + MessageFactory.text("Ending conversation from the skill...") + ) + + end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) + end_of_conversation.code = EndOfConversationCodes.completed_successfully + await turn_context.send_activity(end_of_conversation) + else: + await turn_context.send_activity( + MessageFactory.text(f"Echo: {turn_context.activity.text}") + ) + await turn_context.send_activity( + MessageFactory.text( + f'Say "end" or "exit" and I\'ll end the conversation and back to the parent.' + ) + ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py similarity index 95% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py index e007d0fa9..ed68df254 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py @@ -1,15 +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", "") +#!/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", "") diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt similarity index 100% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py similarity index 100% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py similarity index 93% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py index be7e157a7..5cf1c3615 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py @@ -1,4 +1,4 @@ -from .root_bot import RootBot - - -__all__ = ["RootBot"] +from .root_bot import RootBot + + +__all__ = ["RootBot"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py similarity index 100% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py similarity index 96% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py index f2a9e1f6e..af0df9c81 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py @@ -1,32 +1,32 @@ -#!/usr/bin/env python3 -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -from typing import Dict -from botbuilder.core.skills import BotFrameworkSkill - -""" Bot Configuration """ - - -class DefaultConfig: - """ Bot Configuration """ - - PORT = 3428 - APP_ID = os.environ.get("MicrosoftAppId", "") - APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") - SKILL_HOST_ENDPOINT = "http://localhost:3428/api/skills" - SKILLS = [ - { - "id": "SkillBot", - "app_id": "", - "skill_endpoint": "http://localhost:3978/api/messages", - }, - ] - - -class SkillConfiguration: - SKILL_HOST_ENDPOINT = DefaultConfig.SKILL_HOST_ENDPOINT - SKILLS: Dict[str, BotFrameworkSkill] = { - skill["id"]: BotFrameworkSkill(**skill) for skill in DefaultConfig.SKILLS - } +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from typing import Dict +from botbuilder.core.skills import BotFrameworkSkill + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3428 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + SKILL_HOST_ENDPOINT = "http://localhost:3428/api/skills" + SKILLS = [ + { + "id": "SkillBot", + "app_id": "", + "skill_endpoint": "http://localhost:3978/api/messages", + }, + ] + + +class SkillConfiguration: + SKILL_HOST_ENDPOINT = DefaultConfig.SKILL_HOST_ENDPOINT + SKILLS: Dict[str, BotFrameworkSkill] = { + skill["id"]: BotFrameworkSkill(**skill) for skill in DefaultConfig.SKILLS + } diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py similarity index 95% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py index c23b52ce2..b4c3cd2cf 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py @@ -1,4 +1,4 @@ -from .dummy_middleware import DummyMiddleware - - -__all__ = ["DummyMiddleware"] +from .dummy_middleware import DummyMiddleware + + +__all__ = ["DummyMiddleware"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py similarity index 96% rename from samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py rename to tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py index 4d38fe79f..82eb34707 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py +++ b/tests/skills/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py @@ -1,32 +1,32 @@ -from typing import Awaitable, Callable, List - -from botbuilder.core import Middleware, TurnContext -from botbuilder.schema import Activity, ResourceResponse - - -class DummyMiddleware(Middleware): - def __init__(self, label: str): - self._label = label - - async def on_turn( - self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] - ): - message = f"{self._label} {context.activity.type} {context.activity.text}" - print(message) - - # Register outgoing handler - context.on_send_activities(self._outgoing_handler) - - await logic() - - async def _outgoing_handler( - self, - context: TurnContext, # pylint: disable=unused-argument - activities: List[Activity], - logic: Callable[[TurnContext], Awaitable[List[ResourceResponse]]], - ): - for activity in activities: - message = f"{self._label} {activity.type} {activity.text}" - print(message) - - return await logic() +from typing import Awaitable, Callable, List + +from botbuilder.core import Middleware, TurnContext +from botbuilder.schema import Activity, ResourceResponse + + +class DummyMiddleware(Middleware): + def __init__(self, label: str): + self._label = label + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + message = f"{self._label} {context.activity.type} {context.activity.text}" + print(message) + + # Register outgoing handler + context.on_send_activities(self._outgoing_handler) + + await logic() + + async def _outgoing_handler( + self, + context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + logic: Callable[[TurnContext], Awaitable[List[ResourceResponse]]], + ): + for activity in activities: + message = f"{self._label} {activity.type} {activity.text}" + print(message) + + return await logic() diff --git a/scenarios/action-based-messaging-extension-fetch-task/app.py b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/app.py similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/app.py rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/app.py diff --git a/scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/bots/__init__.py diff --git a/scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/bots/action_based_messaging_extension_fetch_task_bot.py diff --git a/scenarios/action-based-messaging-extension-fetch-task/config.py b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/config.py similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/config.py rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/config.py diff --git a/scenarios/action-based-messaging-extension-fetch-task/example_data.py b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/example_data.py similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/example_data.py rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/example_data.py diff --git a/scenarios/action-based-messaging-extension-fetch-task/requirements.txt b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/requirements.txt similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/requirements.txt rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/requirements.txt diff --git a/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-color.png diff --git a/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-outline.png b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-outline.png similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-outline.png rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/icon-outline.png diff --git a/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/manifest.json b/tests/teams/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/manifest.json similarity index 100% rename from scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/manifest.json rename to tests/teams/scenarios/action-based-messaging-extension-fetch-task/teams_app_manifest/manifest.json diff --git a/scenarios/action-based-messaging-extension/app.py b/tests/teams/scenarios/action-based-messaging-extension/app.py similarity index 97% rename from scenarios/action-based-messaging-extension/app.py rename to tests/teams/scenarios/action-based-messaging-extension/app.py index 4643ee6af..a65ff81f1 100644 --- a/scenarios/action-based-messaging-extension/app.py +++ b/tests/teams/scenarios/action-based-messaging-extension/app.py @@ -1,89 +1,89 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import sys -from datetime import datetime - -from aiohttp import web -from aiohttp.web import Request, Response, json_response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, -) -from botbuilder.schema import Activity, ActivityTypes -from bots import TeamsMessagingExtensionsActionBot -from config import DefaultConfig - -CONFIG = DefaultConfig() - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = TeamsMessagingExtensionsActionBot() - - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - # Main bot message handler. - if "application/json" in req.headers["Content-Type"]: - body = await req.json() - else: - return Response(status=415) - - activity = Activity().deserialize(body) - auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" - - try: - invoke_response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) - if invoke_response: - return json_response(data=invoke_response.body, status=invoke_response.status) - return Response(status=201) - except PermissionError: - return Response(status=401) - except Exception: - return Response(status=500) - - -APP = web.Application() -APP.router.add_post("/api/messages", messages) - -if __name__ == "__main__": - try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes +from bots import TeamsMessagingExtensionsActionBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = TeamsMessagingExtensionsActionBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + invoke_response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if invoke_response: + return json_response(data=invoke_response.body, status=invoke_response.status) + return Response(status=201) + except PermissionError: + return Response(status=401) + except Exception: + return Response(status=500) + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/action-based-messaging-extension/bots/__init__.py b/tests/teams/scenarios/action-based-messaging-extension/bots/__init__.py similarity index 97% rename from scenarios/action-based-messaging-extension/bots/__init__.py rename to tests/teams/scenarios/action-based-messaging-extension/bots/__init__.py index daea6bcda..f67c560a6 100644 --- a/scenarios/action-based-messaging-extension/bots/__init__.py +++ b/tests/teams/scenarios/action-based-messaging-extension/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .teams_messaging_extensions_action_bot import TeamsMessagingExtensionsActionBot - -__all__ = ["TeamsMessagingExtensionsActionBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .teams_messaging_extensions_action_bot import TeamsMessagingExtensionsActionBot + +__all__ = ["TeamsMessagingExtensionsActionBot"] diff --git a/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py b/tests/teams/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py similarity index 97% rename from scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py rename to tests/teams/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py index aea850e2a..014e992a0 100644 --- a/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py +++ b/tests/teams/scenarios/action-based-messaging-extension/bots/teams_messaging_extensions_action_bot.py @@ -1,92 +1,92 @@ -# Copyright (c) Microsoft Corp. All rights reserved. -# Licensed under the MIT License. - -from typing import List -import random -from botbuilder.core import ( - CardFactory, - MessageFactory, - TurnContext, - UserState, - ConversationState, - PrivateConversationState, -) -from botbuilder.schema import ChannelAccount, HeroCard, CardAction, CardImage -from botbuilder.schema.teams import ( - MessagingExtensionAction, - MessagingExtensionActionResponse, - MessagingExtensionAttachment, - MessagingExtensionResult, -) -from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo -from botbuilder.azure import CosmosDbPartitionedStorage - - -class TeamsMessagingExtensionsActionBot(TeamsActivityHandler): - async def on_teams_messaging_extension_submit_action_dispatch( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - if action.command_id == "createCard": - return await self.create_card_command(turn_context, action) - elif action.command_id == "shareMessage": - return await self.share_message_command(turn_context, action) - - async def create_card_command( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - title = action.data["title"] - subTitle = action.data["subTitle"] - text = action.data["text"] - - card = HeroCard(title=title, subtitle=subTitle, text=text) - cardAttachment = CardFactory.hero_card(card) - attachment = MessagingExtensionAttachment( - content=card, - content_type=CardFactory.content_types.hero_card, - preview=cardAttachment, - ) - attachments = [attachment] - - extension_result = MessagingExtensionResult( - attachment_layout="list", type="result", attachments=attachments - ) - return MessagingExtensionActionResponse(compose_extension=extension_result) - - async def share_message_command( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - # The user has chosen to share a message by choosing the 'Share Message' context menu command. - - # TODO: .user is None - title = "Shared Message" # f'{action.message_payload.from_property.user.display_name} orignally sent this message:' - text = action.message_payload.body.content - card = HeroCard(title=title, text=text) - - if not action.message_payload.attachments is None: - # This sample does not add the MessagePayload Attachments. This is left as an - # exercise for the user. - card.subtitle = ( - f"({len(action.message_payload.attachments)} Attachments not included)" - ) - - # This Messaging Extension example allows the user to check a box to include an image with the - # shared message. This demonstrates sending custom parameters along with the message payload. - include_image = action.data["includeImage"] - if include_image == "true": - image = CardImage( - url="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" - ) - card.images = [image] - - cardAttachment = CardFactory.hero_card(card) - attachment = MessagingExtensionAttachment( - content=card, - content_type=CardFactory.content_types.hero_card, - preview=cardAttachment, - ) - attachments = [attachment] - - extension_result = MessagingExtensionResult( - attachment_layout="list", type="result", attachments=attachments - ) - return MessagingExtensionActionResponse(compose_extension=extension_result) +# Copyright (c) Microsoft Corp. All rights reserved. +# Licensed under the MIT License. + +from typing import List +import random +from botbuilder.core import ( + CardFactory, + MessageFactory, + TurnContext, + UserState, + ConversationState, + PrivateConversationState, +) +from botbuilder.schema import ChannelAccount, HeroCard, CardAction, CardImage +from botbuilder.schema.teams import ( + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionResult, +) +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo +from botbuilder.azure import CosmosDbPartitionedStorage + + +class TeamsMessagingExtensionsActionBot(TeamsActivityHandler): + async def on_teams_messaging_extension_submit_action_dispatch( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + if action.command_id == "createCard": + return await self.create_card_command(turn_context, action) + elif action.command_id == "shareMessage": + return await self.share_message_command(turn_context, action) + + async def create_card_command( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + title = action.data["title"] + subTitle = action.data["subTitle"] + text = action.data["text"] + + card = HeroCard(title=title, subtitle=subTitle, text=text) + cardAttachment = CardFactory.hero_card(card) + attachment = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=cardAttachment, + ) + attachments = [attachment] + + extension_result = MessagingExtensionResult( + attachment_layout="list", type="result", attachments=attachments + ) + return MessagingExtensionActionResponse(compose_extension=extension_result) + + async def share_message_command( + self, turn_context: TurnContext, action: MessagingExtensionAction + ) -> MessagingExtensionActionResponse: + # The user has chosen to share a message by choosing the 'Share Message' context menu command. + + # TODO: .user is None + title = "Shared Message" # f'{action.message_payload.from_property.user.display_name} orignally sent this message:' + text = action.message_payload.body.content + card = HeroCard(title=title, text=text) + + if not action.message_payload.attachments is None: + # This sample does not add the MessagePayload Attachments. This is left as an + # exercise for the user. + card.subtitle = ( + f"({len(action.message_payload.attachments)} Attachments not included)" + ) + + # This Messaging Extension example allows the user to check a box to include an image with the + # shared message. This demonstrates sending custom parameters along with the message payload. + include_image = action.data["includeImage"] + if include_image == "true": + image = CardImage( + url="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + ) + card.images = [image] + + cardAttachment = CardFactory.hero_card(card) + attachment = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=cardAttachment, + ) + attachments = [attachment] + + extension_result = MessagingExtensionResult( + attachment_layout="list", type="result", attachments=attachments + ) + return MessagingExtensionActionResponse(compose_extension=extension_result) diff --git a/scenarios/create-thread-in-channel/config.py b/tests/teams/scenarios/action-based-messaging-extension/config.py similarity index 95% rename from scenarios/create-thread-in-channel/config.py rename to tests/teams/scenarios/action-based-messaging-extension/config.py index 6b5116fba..d66581d4c 100644 --- a/scenarios/create-thread-in-channel/config.py +++ b/tests/teams/scenarios/action-based-messaging-extension/config.py @@ -1,13 +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", "") +#!/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/action-based-messaging-extension/requirements.txt b/tests/teams/scenarios/action-based-messaging-extension/requirements.txt similarity index 100% rename from scenarios/action-based-messaging-extension/requirements.txt rename to tests/teams/scenarios/action-based-messaging-extension/requirements.txt diff --git a/scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png b/tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png similarity index 100% rename from scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png rename to tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/icon-color.png diff --git a/scenarios/action-based-messaging-extension/teams_app_manifest/icon-outline.png b/tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/icon-outline.png similarity index 100% rename from scenarios/action-based-messaging-extension/teams_app_manifest/icon-outline.png rename to tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/icon-outline.png diff --git a/scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json b/tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json rename to tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json index 282c8f99a..1b24a5665 100644 --- a/scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/action-based-messaging-extension/teams_app_manifest/manifest.json @@ -1,78 +1,78 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0", - "id": "00000000-0000-0000-0000-000000000000", - "packageName": "com.microsoft.teams.samples", - "developer": { - "name": "Microsoft", - "websiteUrl": "https://dev.botframework.com", - "privacyUrl": "https://privacy.microsoft.com", - "termsOfUseUrl": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright/default.aspx" - }, - "name": { - "short": "Action Messaging Extension", - "full": "Microsoft Teams Action Based Messaging Extension" - }, - "description": { - "short": "Sample demonstrating an Action Based Messaging Extension", - "full": "Sample Action Messaging Extension built with the Bot Builder SDK" - }, - "icons": { - "outline": "icon-outline.png", - "color": "icon-color.png" - }, - "accentColor": "#FFFFFF", - "composeExtensions": [ - { - "botId": "00000000-0000-0000-0000-000000000000", - "commands": [ - { - "id": "createCard", - "type": "action", - "context": [ "compose" ], - "description": "Command to run action to create a Card from Compose Box", - "title": "Create Card", - "parameters": [ - { - "name": "title", - "title": "Card title", - "description": "Title for the card", - "inputType": "text" - }, - { - "name": "subTitle", - "title": "Subtitle", - "description": "Subtitle for the card", - "inputType": "text" - }, - { - "name": "text", - "title": "Text", - "description": "Text for the card", - "inputType": "textarea" - } - ] - }, - { - "id": "shareMessage", - "type": "action", - "context": [ "message" ], - "description": "Test command to run action on message context (message sharing)", - "title": "Share Message", - "parameters": [ - { - "name": "includeImage", - "title": "Include Image", - "description": "Include image in Hero Card", - "inputType": "toggle" - } - ] - } - ] - } - ], - "permissions": [ - "identity" - ] -} +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0", + "id": "00000000-0000-0000-0000-000000000000", + "packageName": "com.microsoft.teams.samples", + "developer": { + "name": "Microsoft", + "websiteUrl": "https://dev.botframework.com", + "privacyUrl": "https://privacy.microsoft.com", + "termsOfUseUrl": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright/default.aspx" + }, + "name": { + "short": "Action Messaging Extension", + "full": "Microsoft Teams Action Based Messaging Extension" + }, + "description": { + "short": "Sample demonstrating an Action Based Messaging Extension", + "full": "Sample Action Messaging Extension built with the Bot Builder SDK" + }, + "icons": { + "outline": "icon-outline.png", + "color": "icon-color.png" + }, + "accentColor": "#FFFFFF", + "composeExtensions": [ + { + "botId": "00000000-0000-0000-0000-000000000000", + "commands": [ + { + "id": "createCard", + "type": "action", + "context": [ "compose" ], + "description": "Command to run action to create a Card from Compose Box", + "title": "Create Card", + "parameters": [ + { + "name": "title", + "title": "Card title", + "description": "Title for the card", + "inputType": "text" + }, + { + "name": "subTitle", + "title": "Subtitle", + "description": "Subtitle for the card", + "inputType": "text" + }, + { + "name": "text", + "title": "Text", + "description": "Text for the card", + "inputType": "textarea" + } + ] + }, + { + "id": "shareMessage", + "type": "action", + "context": [ "message" ], + "description": "Test command to run action on message context (message sharing)", + "title": "Share Message", + "parameters": [ + { + "name": "includeImage", + "title": "Include Image", + "description": "Include image in Hero Card", + "inputType": "toggle" + } + ] + } + ] + } + ], + "permissions": [ + "identity" + ] +} diff --git a/scenarios/activity-update-and-delete/README.md b/tests/teams/scenarios/activity-update-and-delete/README.md similarity index 97% rename from scenarios/activity-update-and-delete/README.md rename to tests/teams/scenarios/activity-update-and-delete/README.md index 40e84f525..f1a48af72 100644 --- a/scenarios/activity-update-and-delete/README.md +++ b/tests/teams/scenarios/activity-update-and-delete/README.md @@ -1,30 +1,30 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/activity-update-and-delete/app.py b/tests/teams/scenarios/activity-update-and-delete/app.py similarity index 97% rename from scenarios/activity-update-and-delete/app.py rename to tests/teams/scenarios/activity-update-and-delete/app.py index 166cee39d..d897fb8e7 100644 --- a/scenarios/activity-update-and-delete/app.py +++ b/tests/teams/scenarios/activity-update-and-delete/app.py @@ -1,92 +1,92 @@ -# 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 ActivitiyUpdateAndDeleteBot - -# 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( # pylint: disable=unused-argument - self, 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 = MethodType(on_error, ADAPTER) -ACTIVITY_IDS = [] -# Create the Bot -BOT = ActivitiyUpdateAndDeleteBot(ACTIVITY_IDS) - -# 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 +# 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 ActivitiyUpdateAndDeleteBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) +ACTIVITY_IDS = [] +# Create the Bot +BOT = ActivitiyUpdateAndDeleteBot(ACTIVITY_IDS) + +# 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/activity-update-and-delete/bots/__init__.py b/tests/teams/scenarios/activity-update-and-delete/bots/__init__.py similarity index 97% rename from scenarios/activity-update-and-delete/bots/__init__.py rename to tests/teams/scenarios/activity-update-and-delete/bots/__init__.py index e6c728a12..8aa561191 100644 --- a/scenarios/activity-update-and-delete/bots/__init__.py +++ b/tests/teams/scenarios/activity-update-and-delete/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .activity_update_and_delete_bot import ActivitiyUpdateAndDeleteBot - -__all__ = ["ActivitiyUpdateAndDeleteBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .activity_update_and_delete_bot import ActivitiyUpdateAndDeleteBot + +__all__ = ["ActivitiyUpdateAndDeleteBot"] diff --git a/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py b/tests/teams/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py similarity index 97% rename from scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py rename to tests/teams/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py index 350cec8c2..1a90329a8 100644 --- a/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py +++ b/tests/teams/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py @@ -1,33 +1,33 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory, TurnContext, ActivityHandler - - -class ActivitiyUpdateAndDeleteBot(ActivityHandler): - def __init__(self, activity_ids): - self.activity_ids = activity_ids - - async def on_message_activity(self, turn_context: TurnContext): - TurnContext.remove_recipient_mention(turn_context.activity) - if turn_context.activity.text == "delete": - for activity in self.activity_ids: - await turn_context.delete_activity(activity) - - self.activity_ids = [] - else: - await self._send_message_and_log_activity_id( - turn_context, turn_context.activity.text - ) - - for activity_id in self.activity_ids: - new_activity = MessageFactory.text(turn_context.activity.text) - new_activity.id = activity_id - await turn_context.update_activity(new_activity) - - async def _send_message_and_log_activity_id( - self, turn_context: TurnContext, text: str - ): - reply_activity = MessageFactory.text(text) - resource_response = await turn_context.send_activity(reply_activity) - self.activity_ids.append(resource_response.id) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory, TurnContext, ActivityHandler + + +class ActivitiyUpdateAndDeleteBot(ActivityHandler): + def __init__(self, activity_ids): + self.activity_ids = activity_ids + + async def on_message_activity(self, turn_context: TurnContext): + TurnContext.remove_recipient_mention(turn_context.activity) + if turn_context.activity.text == "delete": + for activity in self.activity_ids: + await turn_context.delete_activity(activity) + + self.activity_ids = [] + else: + await self._send_message_and_log_activity_id( + turn_context, turn_context.activity.text + ) + + for activity_id in self.activity_ids: + new_activity = MessageFactory.text(turn_context.activity.text) + new_activity.id = activity_id + await turn_context.update_activity(new_activity) + + async def _send_message_and_log_activity_id( + self, turn_context: TurnContext, text: str + ): + reply_activity = MessageFactory.text(text) + resource_response = await turn_context.send_activity(reply_activity) + self.activity_ids.append(resource_response.id) diff --git a/scenarios/file-upload/config.py b/tests/teams/scenarios/activity-update-and-delete/config.py similarity index 95% rename from scenarios/file-upload/config.py rename to tests/teams/scenarios/activity-update-and-delete/config.py index 6b5116fba..d66581d4c 100644 --- a/scenarios/file-upload/config.py +++ b/tests/teams/scenarios/activity-update-and-delete/config.py @@ -1,13 +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", "") +#!/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/activity-update-and-delete/requirements.txt b/tests/teams/scenarios/activity-update-and-delete/requirements.txt similarity index 100% rename from scenarios/activity-update-and-delete/requirements.txt rename to tests/teams/scenarios/activity-update-and-delete/requirements.txt diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/color.png b/tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/color.png similarity index 100% rename from scenarios/activity-update-and-delete/teams_app_manifest/color.png rename to tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/color.png diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json b/tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/activity-update-and-delete/teams_app_manifest/manifest.json rename to tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json index 844969c04..697a9a3e8 100644 --- a/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json @@ -1,43 +1,43 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0.0", - "id": "", - "packageName": "com.teams.sample.conversationUpdate", - "developer": { - "name": "ConversationUpdatesBot", - "websiteUrl": "https://www.microsoft.com", - "privacyUrl": "https://www.teams.com/privacy", - "termsOfUseUrl": "https://www.teams.com/termsofuser" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "ConversationUpdatesBot", - "full": "ConversationUpdatesBot" - }, - "description": { - "short": "ConversationUpdatesBot", - "full": "ConversationUpdatesBot" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "", - "scopes": [ - "groupchat", - "team", - "personal" - ], - "supportsFiles": false, - "isNotificationOnly": false - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ], - "validDomains": [] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0.0", + "id": "", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "ConversationUpdatesBot", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "ConversationUpdatesBot", + "full": "ConversationUpdatesBot" + }, + "description": { + "short": "ConversationUpdatesBot", + "full": "ConversationUpdatesBot" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] } \ No newline at end of file diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/outline.png b/tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/outline.png similarity index 100% rename from scenarios/activity-update-and-delete/teams_app_manifest/outline.png rename to tests/teams/scenarios/activity-update-and-delete/teams_app_manifest/outline.png diff --git a/scenarios/create-thread-in-channel/README.md b/tests/teams/scenarios/conversation-update/README.md similarity index 97% rename from scenarios/create-thread-in-channel/README.md rename to tests/teams/scenarios/conversation-update/README.md index 40e84f525..f1a48af72 100644 --- a/scenarios/create-thread-in-channel/README.md +++ b/tests/teams/scenarios/conversation-update/README.md @@ -1,30 +1,30 @@ -# EchoBot - -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. - -## 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\02.echo-bot` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/conversation-update/app.py b/tests/teams/scenarios/conversation-update/app.py similarity index 97% rename from scenarios/conversation-update/app.py rename to tests/teams/scenarios/conversation-update/app.py index 8d1bc4ac0..17590f61d 100644 --- a/scenarios/conversation-update/app.py +++ b/tests/teams/scenarios/conversation-update/app.py @@ -1,92 +1,92 @@ -# 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 ConversationUpdateBot - -# 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( # pylint: disable=unused-argument - self, 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 = MethodType(on_error, ADAPTER) - -# Create the Bot -BOT = ConversationUpdateBot() - -# 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 +# 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 ConversationUpdateBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = ConversationUpdateBot() + +# 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/conversation-update/bots/__init__.py b/tests/teams/scenarios/conversation-update/bots/__init__.py similarity index 96% rename from scenarios/conversation-update/bots/__init__.py rename to tests/teams/scenarios/conversation-update/bots/__init__.py index f9e91a398..ae2bc0930 100644 --- a/scenarios/conversation-update/bots/__init__.py +++ b/tests/teams/scenarios/conversation-update/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .conversation_update_bot import ConversationUpdateBot - -__all__ = ["ConversationUpdateBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .conversation_update_bot import ConversationUpdateBot + +__all__ = ["ConversationUpdateBot"] diff --git a/scenarios/conversation-update/bots/conversation_update_bot.py b/tests/teams/scenarios/conversation-update/bots/conversation_update_bot.py similarity index 97% rename from scenarios/conversation-update/bots/conversation_update_bot.py rename to tests/teams/scenarios/conversation-update/bots/conversation_update_bot.py index ec34da0f0..6522a633f 100644 --- a/scenarios/conversation-update/bots/conversation_update_bot.py +++ b/tests/teams/scenarios/conversation-update/bots/conversation_update_bot.py @@ -1,56 +1,56 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.core.teams import TeamsActivityHandler -from botbuilder.schema.teams import ChannelInfo, TeamInfo, TeamsChannelAccount - - -class ConversationUpdateBot(TeamsActivityHandler): - async def on_teams_channel_created_activity( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ): - return await turn_context.send_activity( - MessageFactory.text( - f"The new channel is {channel_info.name}. The channel id is {channel_info.id}" - ) - ) - - async def on_teams_channel_deleted_activity( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ): - return await turn_context.send_activity( - MessageFactory.text(f"The deleted channel is {channel_info.name}") - ) - - async def on_teams_channel_renamed_activity( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ): - return await turn_context.send_activity( - MessageFactory.text(f"The new channel name is {channel_info.name}") - ) - - async def on_teams_team_renamed_activity( - self, team_info: TeamInfo, turn_context: TurnContext - ): - return await turn_context.send_activity( - MessageFactory.text(f"The new team name is {team_info.name}") - ) - - async def on_teams_members_added_activity( - self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext - ): - for member in teams_members_added: - await turn_context.send_activity( - MessageFactory.text(f"Welcome your new team member {member.id}") - ) - return - - async def on_teams_members_removed_activity( - self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext - ): - for member in teams_members_removed: - await turn_context.send_activity( - MessageFactory.text(f"Say goodbye to your team member {member.id}") - ) - return +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.core.teams import TeamsActivityHandler +from botbuilder.schema.teams import ChannelInfo, TeamInfo, TeamsChannelAccount + + +class ConversationUpdateBot(TeamsActivityHandler): + async def on_teams_channel_created_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return await turn_context.send_activity( + MessageFactory.text( + f"The new channel is {channel_info.name}. The channel id is {channel_info.id}" + ) + ) + + async def on_teams_channel_deleted_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return await turn_context.send_activity( + MessageFactory.text(f"The deleted channel is {channel_info.name}") + ) + + async def on_teams_channel_renamed_activity( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return await turn_context.send_activity( + MessageFactory.text(f"The new channel name is {channel_info.name}") + ) + + async def on_teams_team_renamed_activity( + self, team_info: TeamInfo, turn_context: TurnContext + ): + return await turn_context.send_activity( + MessageFactory.text(f"The new team name is {team_info.name}") + ) + + async def on_teams_members_added_activity( + self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext + ): + for member in teams_members_added: + await turn_context.send_activity( + MessageFactory.text(f"Welcome your new team member {member.id}") + ) + return + + async def on_teams_members_removed_activity( + self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + ): + for member in teams_members_removed: + await turn_context.send_activity( + MessageFactory.text(f"Say goodbye to your team member {member.id}") + ) + return diff --git a/scenarios/conversation-update/config.py b/tests/teams/scenarios/conversation-update/config.py similarity index 95% rename from scenarios/conversation-update/config.py rename to tests/teams/scenarios/conversation-update/config.py index 6b5116fba..d66581d4c 100644 --- a/scenarios/conversation-update/config.py +++ b/tests/teams/scenarios/conversation-update/config.py @@ -1,13 +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", "") +#!/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/conversation-update/requirements.txt b/tests/teams/scenarios/conversation-update/requirements.txt similarity index 100% rename from scenarios/conversation-update/requirements.txt rename to tests/teams/scenarios/conversation-update/requirements.txt diff --git a/scenarios/conversation-update/teams_app_manifest/color.png b/tests/teams/scenarios/conversation-update/teams_app_manifest/color.png similarity index 100% rename from scenarios/conversation-update/teams_app_manifest/color.png rename to tests/teams/scenarios/conversation-update/teams_app_manifest/color.png diff --git a/scenarios/conversation-update/teams_app_manifest/manifest.json b/tests/teams/scenarios/conversation-update/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/conversation-update/teams_app_manifest/manifest.json rename to tests/teams/scenarios/conversation-update/teams_app_manifest/manifest.json index 844969c04..697a9a3e8 100644 --- a/scenarios/conversation-update/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/conversation-update/teams_app_manifest/manifest.json @@ -1,43 +1,43 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0.0", - "id": "", - "packageName": "com.teams.sample.conversationUpdate", - "developer": { - "name": "ConversationUpdatesBot", - "websiteUrl": "https://www.microsoft.com", - "privacyUrl": "https://www.teams.com/privacy", - "termsOfUseUrl": "https://www.teams.com/termsofuser" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "ConversationUpdatesBot", - "full": "ConversationUpdatesBot" - }, - "description": { - "short": "ConversationUpdatesBot", - "full": "ConversationUpdatesBot" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "", - "scopes": [ - "groupchat", - "team", - "personal" - ], - "supportsFiles": false, - "isNotificationOnly": false - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ], - "validDomains": [] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0.0", + "id": "", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "ConversationUpdatesBot", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "ConversationUpdatesBot", + "full": "ConversationUpdatesBot" + }, + "description": { + "short": "ConversationUpdatesBot", + "full": "ConversationUpdatesBot" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] } \ No newline at end of file diff --git a/scenarios/conversation-update/teams_app_manifest/outline.png b/tests/teams/scenarios/conversation-update/teams_app_manifest/outline.png similarity index 100% rename from scenarios/conversation-update/teams_app_manifest/outline.png rename to tests/teams/scenarios/conversation-update/teams_app_manifest/outline.png diff --git a/samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/README.md b/tests/teams/scenarios/create-thread-in-channel/README.md similarity index 100% rename from samples/experimental/skills-prototypes/dialog-to-dialog/authentication-bot/README.md rename to tests/teams/scenarios/create-thread-in-channel/README.md diff --git a/scenarios/create-thread-in-channel/app.py b/tests/teams/scenarios/create-thread-in-channel/app.py similarity index 100% rename from scenarios/create-thread-in-channel/app.py rename to tests/teams/scenarios/create-thread-in-channel/app.py diff --git a/scenarios/create-thread-in-channel/bots/__init__.py b/tests/teams/scenarios/create-thread-in-channel/bots/__init__.py similarity index 100% rename from scenarios/create-thread-in-channel/bots/__init__.py rename to tests/teams/scenarios/create-thread-in-channel/bots/__init__.py diff --git a/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py b/tests/teams/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py similarity index 100% rename from scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py rename to tests/teams/scenarios/create-thread-in-channel/bots/create_thread_in_teams_bot.py diff --git a/scenarios/action-based-messaging-extension/config.py b/tests/teams/scenarios/create-thread-in-channel/config.py similarity index 100% rename from scenarios/action-based-messaging-extension/config.py rename to tests/teams/scenarios/create-thread-in-channel/config.py diff --git a/scenarios/create-thread-in-channel/requirements.txt b/tests/teams/scenarios/create-thread-in-channel/requirements.txt similarity index 100% rename from scenarios/create-thread-in-channel/requirements.txt rename to tests/teams/scenarios/create-thread-in-channel/requirements.txt diff --git a/scenarios/create-thread-in-channel/teams_app_manifest/color.png b/tests/teams/scenarios/create-thread-in-channel/teams_app_manifest/color.png similarity index 100% rename from scenarios/create-thread-in-channel/teams_app_manifest/color.png rename to tests/teams/scenarios/create-thread-in-channel/teams_app_manifest/color.png diff --git a/scenarios/create-thread-in-channel/teams_app_manifest/manifest.json b/tests/teams/scenarios/create-thread-in-channel/teams_app_manifest/manifest.json similarity index 100% rename from scenarios/create-thread-in-channel/teams_app_manifest/manifest.json rename to tests/teams/scenarios/create-thread-in-channel/teams_app_manifest/manifest.json diff --git a/scenarios/create-thread-in-channel/teams_app_manifest/outline.png b/tests/teams/scenarios/create-thread-in-channel/teams_app_manifest/outline.png similarity index 100% rename from scenarios/create-thread-in-channel/teams_app_manifest/outline.png rename to tests/teams/scenarios/create-thread-in-channel/teams_app_manifest/outline.png diff --git a/scenarios/file-upload/README.md b/tests/teams/scenarios/file-upload/README.md similarity index 97% rename from scenarios/file-upload/README.md rename to tests/teams/scenarios/file-upload/README.md index dbbb975fb..f68159779 100644 --- a/scenarios/file-upload/README.md +++ b/tests/teams/scenarios/file-upload/README.md @@ -1,119 +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) +# 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/tests/teams/scenarios/file-upload/app.py similarity index 97% rename from scenarios/file-upload/app.py rename to tests/teams/scenarios/file-upload/app.py index 048afd4c2..17cbac17b 100644 --- a/scenarios/file-upload/app.py +++ b/tests/teams/scenarios/file-upload/app.py @@ -1,91 +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 +# 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/tests/teams/scenarios/file-upload/bots/__init__.py similarity index 96% rename from scenarios/file-upload/bots/__init__.py rename to tests/teams/scenarios/file-upload/bots/__init__.py index 9c28a0532..ba9df627e 100644 --- a/scenarios/file-upload/bots/__init__.py +++ b/tests/teams/scenarios/file-upload/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .teams_file_bot import TeamsFileBot - -__all__ = ["TeamsFileBot"] +# 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/tests/teams/scenarios/file-upload/bots/teams_file_bot.py similarity index 97% rename from scenarios/file-upload/bots/teams_file_bot.py rename to tests/teams/scenarios/file-upload/bots/teams_file_bot.py index 93401d5df..39fb047a7 100644 --- a/scenarios/file-upload/bots/teams_file_bot.py +++ b/tests/teams/scenarios/file-upload/bots/teams_file_bot.py @@ -1,185 +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, - ) +# 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/activity-update-and-delete/config.py b/tests/teams/scenarios/file-upload/config.py similarity index 95% rename from scenarios/activity-update-and-delete/config.py rename to tests/teams/scenarios/file-upload/config.py index 6b5116fba..d66581d4c 100644 --- a/scenarios/activity-update-and-delete/config.py +++ b/tests/teams/scenarios/file-upload/config.py @@ -1,13 +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", "") +#!/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/tests/teams/scenarios/file-upload/files/teams-logo.png similarity index 100% rename from scenarios/file-upload/files/teams-logo.png rename to tests/teams/scenarios/file-upload/files/teams-logo.png diff --git a/scenarios/file-upload/requirements.txt b/tests/teams/scenarios/file-upload/requirements.txt similarity index 100% rename from scenarios/file-upload/requirements.txt rename to tests/teams/scenarios/file-upload/requirements.txt diff --git a/scenarios/file-upload/teams_app_manifest/color.png b/tests/teams/scenarios/file-upload/teams_app_manifest/color.png similarity index 100% rename from scenarios/file-upload/teams_app_manifest/color.png rename to tests/teams/scenarios/file-upload/teams_app_manifest/color.png diff --git a/scenarios/file-upload/teams_app_manifest/manifest.json b/tests/teams/scenarios/file-upload/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/file-upload/teams_app_manifest/manifest.json rename to tests/teams/scenarios/file-upload/teams_app_manifest/manifest.json index 8a1f2365a..f6941c176 100644 --- a/scenarios/file-upload/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/file-upload/teams_app_manifest/manifest.json @@ -1,38 +1,38 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0", - "id": "<>", - "packageName": "com.microsoft.teams.samples.fileUpload", - "developer": { - "name": "Microsoft Corp", - "websiteUrl": "https://example.azurewebsites.net", - "privacyUrl": "https://example.azurewebsites.net/privacy", - "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" - }, - "name": { - "short": "V4 File Sample", - "full": "Microsoft Teams V4 File Sample Bot" - }, - "description": { - "short": "Sample bot using V4 SDK to demo bot file features", - "full": "Sample bot using V4 Bot Builder SDK and V4 Microsoft Teams Extension SDK to demo bot file features" - }, - "icons": { - "outline": "outline.png", - "color": "color.png" - }, - "accentColor": "#abcdef", - "bots": [ - { - "botId": "<>", - "scopes": [ - "personal" - ], - "supportsFiles": true - } - ], - "validDomains": [ - "*.azurewebsites.net" - ] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0", + "id": "<>", + "packageName": "com.microsoft.teams.samples.fileUpload", + "developer": { + "name": "Microsoft Corp", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "name": { + "short": "V4 File Sample", + "full": "Microsoft Teams V4 File Sample Bot" + }, + "description": { + "short": "Sample bot using V4 SDK to demo bot file features", + "full": "Sample bot using V4 Bot Builder SDK and V4 Microsoft Teams Extension SDK to demo bot file features" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#abcdef", + "bots": [ + { + "botId": "<>", + "scopes": [ + "personal" + ], + "supportsFiles": true + } + ], + "validDomains": [ + "*.azurewebsites.net" + ] } \ No newline at end of file diff --git a/scenarios/file-upload/teams_app_manifest/outline.png b/tests/teams/scenarios/file-upload/teams_app_manifest/outline.png similarity index 100% rename from scenarios/file-upload/teams_app_manifest/outline.png rename to tests/teams/scenarios/file-upload/teams_app_manifest/outline.png diff --git a/scenarios/link-unfurling/README.md b/tests/teams/scenarios/link-unfurling/README.md similarity index 97% rename from scenarios/link-unfurling/README.md rename to tests/teams/scenarios/link-unfurling/README.md index 39f77916c..eecb8fccb 100644 --- a/scenarios/link-unfurling/README.md +++ b/tests/teams/scenarios/link-unfurling/README.md @@ -1,30 +1,30 @@ -# RosterBot - -Bot Framework v4 teams roster 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. - -## 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\roster` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/link-unfurling/app.py b/tests/teams/scenarios/link-unfurling/app.py similarity index 97% rename from scenarios/link-unfurling/app.py rename to tests/teams/scenarios/link-unfurling/app.py index 8bbf90feb..709bffd0f 100644 --- a/scenarios/link-unfurling/app.py +++ b/tests/teams/scenarios/link-unfurling/app.py @@ -1,86 +1,86 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import sys -from datetime import datetime - -from aiohttp import web -from aiohttp.web import Request, Response, json_response - -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, -) - -from botbuilder.schema import Activity, ActivityTypes -from bots import LinkUnfurlingBot -from config import DefaultConfig - -CONFIG = DefaultConfig() - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = LinkUnfurlingBot() - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - # Main bot message handler. - if "application/json" in req.headers["Content-Type"]: - body = await req.json() - else: - return Response(status=415) - - activity = Activity().deserialize(body) - auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" - - try: - response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) - if response: - return json_response(data=response.body, status=response.status) - return Response(status=201) - except Exception as exception: - raise exception - -APP = web.Application() -APP.router.add_post("/api/messages", messages) - -if __name__ == "__main__": - try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response + +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) + +from botbuilder.schema import Activity, ActivityTypes +from bots import LinkUnfurlingBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = LinkUnfurlingBot() + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + except Exception as exception: + raise exception + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/link-unfurling/bots/__init__.py b/tests/teams/scenarios/link-unfurling/bots/__init__.py similarity index 96% rename from scenarios/link-unfurling/bots/__init__.py rename to tests/teams/scenarios/link-unfurling/bots/__init__.py index 7dc2c44a9..40e14fad9 100644 --- a/scenarios/link-unfurling/bots/__init__.py +++ b/tests/teams/scenarios/link-unfurling/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .link_unfurling_bot import LinkUnfurlingBot - -__all__ = ["LinkUnfurlingBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .link_unfurling_bot import LinkUnfurlingBot + +__all__ = ["LinkUnfurlingBot"] diff --git a/scenarios/link-unfurling/bots/link_unfurling_bot.py b/tests/teams/scenarios/link-unfurling/bots/link_unfurling_bot.py similarity index 97% rename from scenarios/link-unfurling/bots/link_unfurling_bot.py rename to tests/teams/scenarios/link-unfurling/bots/link_unfurling_bot.py index 1c1888375..5dec7e21b 100644 --- a/scenarios/link-unfurling/bots/link_unfurling_bot.py +++ b/tests/teams/scenarios/link-unfurling/bots/link_unfurling_bot.py @@ -1,57 +1,57 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import CardFactory, MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment -from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse -from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo - -class LinkUnfurlingBot(TeamsActivityHandler): - async def on_teams_app_based_link_query(self, turn_context: TurnContext, query: AppBasedLinkQuery): - hero_card = ThumbnailCard( - title="Thumnnail card", - text=query.url, - images=[ - CardImage( - url="https://raw.githubusercontent.com/microsoft/botframework-sdk/master/icon.png" - ) - ] - ) - attachments = MessagingExtensionAttachment( - content_type=CardFactory.content_types.hero_card, - content=hero_card) - result = MessagingExtensionResult( - attachment_layout="list", - type="result", - attachments=[attachments] - ) - return MessagingExtensionResponse(compose_extension=result) - - async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): - if query.command_id == "searchQuery": - card = HeroCard( - title="This is a Link Unfurling Sample", - subtitle="It will unfurl links from *.botframework.com", - text="This sample demonstrates how to handle link unfurling in Teams. Please review the readme for more information." - ) - attachment = Attachment( - content_type=CardFactory.content_types.hero_card, - content=card - ) - msg_ext_atc = MessagingExtensionAttachment( - content=card, - content_type=CardFactory.content_types.hero_card, - preview=attachment - ) - msg_ext_res = MessagingExtensionResult( - attachment_layout="list", - type="result", - attachments=[msg_ext_atc] - ) - response = MessagingExtensionResponse( - compose_extension=msg_ext_res - ) - - return response - +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment +from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +class LinkUnfurlingBot(TeamsActivityHandler): + async def on_teams_app_based_link_query(self, turn_context: TurnContext, query: AppBasedLinkQuery): + hero_card = ThumbnailCard( + title="Thumnnail card", + text=query.url, + images=[ + CardImage( + url="https://raw.githubusercontent.com/microsoft/botframework-sdk/master/icon.png" + ) + ] + ) + attachments = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card) + result = MessagingExtensionResult( + attachment_layout="list", + type="result", + attachments=[attachments] + ) + return MessagingExtensionResponse(compose_extension=result) + + async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): + if query.command_id == "searchQuery": + card = HeroCard( + title="This is a Link Unfurling Sample", + subtitle="It will unfurl links from *.botframework.com", + text="This sample demonstrates how to handle link unfurling in Teams. Please review the readme for more information." + ) + attachment = Attachment( + content_type=CardFactory.content_types.hero_card, + content=card + ) + msg_ext_atc = MessagingExtensionAttachment( + content=card, + content_type=CardFactory.content_types.hero_card, + preview=attachment + ) + msg_ext_res = MessagingExtensionResult( + attachment_layout="list", + type="result", + attachments=[msg_ext_atc] + ) + response = MessagingExtensionResponse( + compose_extension=msg_ext_res + ) + + return response + raise NotImplementedError(f"Invalid command: {query.command_id}") \ No newline at end of file diff --git a/tests/teams/scenarios/link-unfurling/config.py b/tests/teams/scenarios/link-unfurling/config.py new file mode 100644 index 000000000..d66581d4c --- /dev/null +++ b/tests/teams/scenarios/link-unfurling/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/link-unfurling/requirements.txt b/tests/teams/scenarios/link-unfurling/requirements.txt similarity index 100% rename from scenarios/link-unfurling/requirements.txt rename to tests/teams/scenarios/link-unfurling/requirements.txt diff --git a/scenarios/link-unfurling/teams_app_manifest/color.png b/tests/teams/scenarios/link-unfurling/teams_app_manifest/color.png similarity index 100% rename from scenarios/link-unfurling/teams_app_manifest/color.png rename to tests/teams/scenarios/link-unfurling/teams_app_manifest/color.png diff --git a/scenarios/link-unfurling/teams_app_manifest/manifest.json b/tests/teams/scenarios/link-unfurling/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/link-unfurling/teams_app_manifest/manifest.json rename to tests/teams/scenarios/link-unfurling/teams_app_manifest/manifest.json index ad1d3c3a6..712b303b1 100644 --- a/scenarios/link-unfurling/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/link-unfurling/teams_app_manifest/manifest.json @@ -1,67 +1,67 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0", - "id": "<>", - "packageName": "com.teams.sample.linkunfurling", - "developer": { - "name": "Link Unfurling", - "websiteUrl": "https://www.microsoft.com", - "privacyUrl": "https://www.teams.com/privacy", - "termsOfUseUrl": "https://www.teams.com/termsofuser" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "Link Unfurling", - "full": "Link Unfurling" - }, - "description": { - "short": "Link Unfurling", - "full": "Link Unfurling" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "<>", - "scopes": [ "personal", "team" ] - } - ], - "composeExtensions": [ - { - "botId": "<>", - "commands": [ - { - "id": "searchQuery", - "context": [ "compose", "commandBox" ], - "description": "Test command to run query", - "title": "Search", - "type": "query", - "parameters": [ - { - "name": "searchQuery", - "title": "Search Query", - "description": "Your search query", - "inputType": "text" - } - ] - } - ], - "messageHandlers": [ - { - "type": "link", - "value": { - "domains": [ - "microsoft.com", - "github.com", - "linkedin.com", - "bing.com" - ] - } - } - ] - } - ] -} +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0", + "id": "<>", + "packageName": "com.teams.sample.linkunfurling", + "developer": { + "name": "Link Unfurling", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Link Unfurling", + "full": "Link Unfurling" + }, + "description": { + "short": "Link Unfurling", + "full": "Link Unfurling" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ "personal", "team" ] + } + ], + "composeExtensions": [ + { + "botId": "<>", + "commands": [ + { + "id": "searchQuery", + "context": [ "compose", "commandBox" ], + "description": "Test command to run query", + "title": "Search", + "type": "query", + "parameters": [ + { + "name": "searchQuery", + "title": "Search Query", + "description": "Your search query", + "inputType": "text" + } + ] + } + ], + "messageHandlers": [ + { + "type": "link", + "value": { + "domains": [ + "microsoft.com", + "github.com", + "linkedin.com", + "bing.com" + ] + } + } + ] + } + ] +} diff --git a/scenarios/link-unfurling/teams_app_manifest/manifest.zip b/tests/teams/scenarios/link-unfurling/teams_app_manifest/manifest.zip similarity index 100% rename from scenarios/link-unfurling/teams_app_manifest/manifest.zip rename to tests/teams/scenarios/link-unfurling/teams_app_manifest/manifest.zip diff --git a/scenarios/link-unfurling/teams_app_manifest/outline.png b/tests/teams/scenarios/link-unfurling/teams_app_manifest/outline.png similarity index 100% rename from scenarios/link-unfurling/teams_app_manifest/outline.png rename to tests/teams/scenarios/link-unfurling/teams_app_manifest/outline.png diff --git a/tests/teams/scenarios/mentions/README.md b/tests/teams/scenarios/mentions/README.md new file mode 100644 index 000000000..f1a48af72 --- /dev/null +++ b/tests/teams/scenarios/mentions/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/mentions/app.py b/tests/teams/scenarios/mentions/app.py similarity index 97% rename from scenarios/mentions/app.py rename to tests/teams/scenarios/mentions/app.py index 1db89f6ec..b7230468e 100644 --- a/scenarios/mentions/app.py +++ b/tests/teams/scenarios/mentions/app.py @@ -1,92 +1,92 @@ -# 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 MentionBot - -# 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( # pylint: disable=unused-argument - self, 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 = MethodType(on_error, ADAPTER) - -# Create the Bot -BOT = MentionBot() - -# 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 +# 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 MentionBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = MentionBot() + +# 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/mentions/bots/__init__.py b/tests/teams/scenarios/mentions/bots/__init__.py similarity index 96% rename from scenarios/mentions/bots/__init__.py rename to tests/teams/scenarios/mentions/bots/__init__.py index 82e97adab..7acf9b841 100644 --- a/scenarios/mentions/bots/__init__.py +++ b/tests/teams/scenarios/mentions/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .mention_bot import MentionBot - -__all__ = ["MentionBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .mention_bot import MentionBot + +__all__ = ["MentionBot"] diff --git a/scenarios/mentions/bots/mention_bot.py b/tests/teams/scenarios/mentions/bots/mention_bot.py similarity index 97% rename from scenarios/mentions/bots/mention_bot.py rename to tests/teams/scenarios/mentions/bots/mention_bot.py index f343c4584..218fb735a 100644 --- a/scenarios/mentions/bots/mention_bot.py +++ b/tests/teams/scenarios/mentions/bots/mention_bot.py @@ -1,21 +1,21 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.core.teams import TeamsActivityHandler -from botbuilder.schema import Mention - - -class MentionBot(TeamsActivityHandler): - async def on_message_activity(self, turn_context: TurnContext): - mention_data = { - "mentioned": turn_context.activity.from_property, - "text": f"{turn_context.activity.from_property.name}", - "type": "mention", - } - - mention_object = Mention(**mention_data) - - reply_activity = MessageFactory.text(f"Hello {mention_object.text}") - reply_activity.entities = [mention_object] - await turn_context.send_activity(reply_activity) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.core.teams import TeamsActivityHandler +from botbuilder.schema import Mention + + +class MentionBot(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + mention_data = { + "mentioned": turn_context.activity.from_property, + "text": f"{turn_context.activity.from_property.name}", + "type": "mention", + } + + mention_object = Mention(**mention_data) + + reply_activity = MessageFactory.text(f"Hello {mention_object.text}") + reply_activity.entities = [mention_object] + await turn_context.send_activity(reply_activity) diff --git a/tests/teams/scenarios/mentions/config.py b/tests/teams/scenarios/mentions/config.py new file mode 100644 index 000000000..d66581d4c --- /dev/null +++ b/tests/teams/scenarios/mentions/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/mentions/requirements.txt b/tests/teams/scenarios/mentions/requirements.txt similarity index 100% rename from scenarios/mentions/requirements.txt rename to tests/teams/scenarios/mentions/requirements.txt diff --git a/scenarios/mentions/teams_app_manifest/color.png b/tests/teams/scenarios/mentions/teams_app_manifest/color.png similarity index 100% rename from scenarios/mentions/teams_app_manifest/color.png rename to tests/teams/scenarios/mentions/teams_app_manifest/color.png diff --git a/scenarios/mentions/teams_app_manifest/manifest.json b/tests/teams/scenarios/mentions/teams_app_manifest/manifest.json similarity index 95% rename from scenarios/mentions/teams_app_manifest/manifest.json rename to tests/teams/scenarios/mentions/teams_app_manifest/manifest.json index b9d5b596f..035808898 100644 --- a/scenarios/mentions/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/mentions/teams_app_manifest/manifest.json @@ -1,43 +1,43 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0.0", - "id": "<>", - "packageName": "com.teams.sample.conversationUpdate", - "developer": { - "name": "MentionBot", - "websiteUrl": "https://www.microsoft.com", - "privacyUrl": "https://www.teams.com/privacy", - "termsOfUseUrl": "https://www.teams.com/termsofuser" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "MentionBot", - "full": "MentionBot" - }, - "description": { - "short": "MentionBot", - "full": "MentionBot" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "<>", - "scopes": [ - "groupchat", - "team", - "personal" - ], - "supportsFiles": false, - "isNotificationOnly": false - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ], - "validDomains": [] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0.0", + "id": "<>", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "MentionBot", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "MentionBot", + "full": "MentionBot" + }, + "description": { + "short": "MentionBot", + "full": "MentionBot" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] } \ No newline at end of file diff --git a/scenarios/mentions/teams_app_manifest/outline.png b/tests/teams/scenarios/mentions/teams_app_manifest/outline.png similarity index 100% rename from scenarios/mentions/teams_app_manifest/outline.png rename to tests/teams/scenarios/mentions/teams_app_manifest/outline.png diff --git a/tests/teams/scenarios/message-reactions/README.md b/tests/teams/scenarios/message-reactions/README.md new file mode 100644 index 000000000..f1a48af72 --- /dev/null +++ b/tests/teams/scenarios/message-reactions/README.md @@ -0,0 +1,30 @@ +# EchoBot + +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. + +## 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\02.echo-bot` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/message-reactions/activity_log.py b/tests/teams/scenarios/message-reactions/activity_log.py similarity index 96% rename from scenarios/message-reactions/activity_log.py rename to tests/teams/scenarios/message-reactions/activity_log.py index 0ef5a837d..c12276bb0 100644 --- a/scenarios/message-reactions/activity_log.py +++ b/tests/teams/scenarios/message-reactions/activity_log.py @@ -1,30 +1,30 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import MemoryStorage -from botbuilder.schema import Activity - - -class ActivityLog: - def __init__(self, storage: MemoryStorage): - self._storage = storage - - async def append(self, activity_id: str, activity: Activity): - if not activity_id: - raise TypeError("activity_id is required for ActivityLog.append") - - if not activity: - raise TypeError("activity is required for ActivityLog.append") - - obj = {} - obj[activity_id] = activity - - await self._storage.write(obj) - return - - async def find(self, activity_id: str) -> Activity: - if not activity_id: - raise TypeError("activity_id is required for ActivityLog.find") - - items = await self._storage.read([activity_id]) - return items[activity_id] if len(items) >= 1 else None +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MemoryStorage +from botbuilder.schema import Activity + + +class ActivityLog: + def __init__(self, storage: MemoryStorage): + self._storage = storage + + async def append(self, activity_id: str, activity: Activity): + if not activity_id: + raise TypeError("activity_id is required for ActivityLog.append") + + if not activity: + raise TypeError("activity is required for ActivityLog.append") + + obj = {} + obj[activity_id] = activity + + await self._storage.write(obj) + return + + async def find(self, activity_id: str) -> Activity: + if not activity_id: + raise TypeError("activity_id is required for ActivityLog.find") + + items = await self._storage.read([activity_id]) + return items[activity_id] if len(items) >= 1 else None diff --git a/scenarios/message-reactions/app.py b/tests/teams/scenarios/message-reactions/app.py similarity index 97% rename from scenarios/message-reactions/app.py rename to tests/teams/scenarios/message-reactions/app.py index f92c64c3c..93b78e957 100644 --- a/scenarios/message-reactions/app.py +++ b/tests/teams/scenarios/message-reactions/app.py @@ -1,94 +1,94 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import sys -from datetime import datetime -from types import MethodType - -from flask import Flask, request, Response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, - MemoryStorage, -) -from botbuilder.schema import Activity, ActivityTypes -from activity_log import ActivityLog -from bots import MessageReactionBot -from threading_helper import run_coroutine - -# Create the Flask app -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( # pylint: disable=unused-argument - self, 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 = MethodType(on_error, ADAPTER) - -MEMORY = MemoryStorage() -ACTIVITY_LOG = ActivityLog(MEMORY) -# Create the Bot -BOT = MessageReactionBot(ACTIVITY_LOG) - -# 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: - print("about to create task") - print("about to run until complete") - run_coroutine(ADAPTER.process_activity(activity, auth_header, BOT.on_turn)) - print("is now complete") - 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 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +from datetime import datetime +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, + MemoryStorage, +) +from botbuilder.schema import Activity, ActivityTypes +from activity_log import ActivityLog +from bots import MessageReactionBot +from threading_helper import run_coroutine + +# Create the Flask app +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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +MEMORY = MemoryStorage() +ACTIVITY_LOG = ActivityLog(MEMORY) +# Create the Bot +BOT = MessageReactionBot(ACTIVITY_LOG) + +# 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: + print("about to create task") + print("about to run until complete") + run_coroutine(ADAPTER.process_activity(activity, auth_header, BOT.on_turn)) + print("is now complete") + 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/message-reactions/bots/__init__.py b/tests/teams/scenarios/message-reactions/bots/__init__.py similarity index 96% rename from scenarios/message-reactions/bots/__init__.py rename to tests/teams/scenarios/message-reactions/bots/__init__.py index 4c417f70c..39b49a20c 100644 --- a/scenarios/message-reactions/bots/__init__.py +++ b/tests/teams/scenarios/message-reactions/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .message_reaction_bot import MessageReactionBot - -__all__ = ["MessageReactionBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .message_reaction_bot import MessageReactionBot + +__all__ = ["MessageReactionBot"] diff --git a/scenarios/message-reactions/bots/message_reaction_bot.py b/tests/teams/scenarios/message-reactions/bots/message_reaction_bot.py similarity index 97% rename from scenarios/message-reactions/bots/message_reaction_bot.py rename to tests/teams/scenarios/message-reactions/bots/message_reaction_bot.py index ce8c34cea..5b585e270 100644 --- a/scenarios/message-reactions/bots/message_reaction_bot.py +++ b/tests/teams/scenarios/message-reactions/bots/message_reaction_bot.py @@ -1,60 +1,60 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List -from botbuilder.core import MessageFactory, TurnContext, ActivityHandler -from botbuilder.schema import MessageReaction -from activity_log import ActivityLog - - -class MessageReactionBot(ActivityHandler): - def __init__(self, activity_log: ActivityLog): - self._log = activity_log - - async def on_reactions_added( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - for reaction in message_reactions: - activity = await self._log.find(turn_context.activity.reply_to_id) - if not activity: - await self._send_message_and_log_activity_id( - turn_context, - f"Activity {turn_context.activity.reply_to_id} not found in log", - ) - else: - await self._send_message_and_log_activity_id( - turn_context, - f"You added '{reaction.type}' regarding '{activity.text}'", - ) - return - - async def on_reactions_removed( - self, message_reactions: List[MessageReaction], turn_context: TurnContext - ): - for reaction in message_reactions: - activity = await self._log.find(turn_context.activity.reply_to_id) - if not activity: - await self._send_message_and_log_activity_id( - turn_context, - f"Activity {turn_context.activity.reply_to_id} not found in log", - ) - else: - await self._send_message_and_log_activity_id( - turn_context, - f"You removed '{reaction.type}' regarding '{activity.text}'", - ) - return - - async def on_message_activity(self, turn_context: TurnContext): - await self._send_message_and_log_activity_id( - turn_context, f"echo: {turn_context.activity.text}" - ) - - async def _send_message_and_log_activity_id( - self, turn_context: TurnContext, text: str - ): - reply_activity = MessageFactory.text(text) - resource_response = await turn_context.send_activity(reply_activity) - - await self._log.append(resource_response.id, reply_activity) - return +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core import MessageFactory, TurnContext, ActivityHandler +from botbuilder.schema import MessageReaction +from activity_log import ActivityLog + + +class MessageReactionBot(ActivityHandler): + def __init__(self, activity_log: ActivityLog): + self._log = activity_log + + async def on_reactions_added( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + for reaction in message_reactions: + activity = await self._log.find(turn_context.activity.reply_to_id) + if not activity: + await self._send_message_and_log_activity_id( + turn_context, + f"Activity {turn_context.activity.reply_to_id} not found in log", + ) + else: + await self._send_message_and_log_activity_id( + turn_context, + f"You added '{reaction.type}' regarding '{activity.text}'", + ) + return + + async def on_reactions_removed( + self, message_reactions: List[MessageReaction], turn_context: TurnContext + ): + for reaction in message_reactions: + activity = await self._log.find(turn_context.activity.reply_to_id) + if not activity: + await self._send_message_and_log_activity_id( + turn_context, + f"Activity {turn_context.activity.reply_to_id} not found in log", + ) + else: + await self._send_message_and_log_activity_id( + turn_context, + f"You removed '{reaction.type}' regarding '{activity.text}'", + ) + return + + async def on_message_activity(self, turn_context: TurnContext): + await self._send_message_and_log_activity_id( + turn_context, f"echo: {turn_context.activity.text}" + ) + + async def _send_message_and_log_activity_id( + self, turn_context: TurnContext, text: str + ): + reply_activity = MessageFactory.text(text) + resource_response = await turn_context.send_activity(reply_activity) + + await self._log.append(resource_response.id, reply_activity) + return diff --git a/scenarios/message-reactions/config.py b/tests/teams/scenarios/message-reactions/config.py similarity index 96% rename from scenarios/message-reactions/config.py rename to tests/teams/scenarios/message-reactions/config.py index 480b0647b..aec900d57 100644 --- a/scenarios/message-reactions/config.py +++ b/tests/teams/scenarios/message-reactions/config.py @@ -1,13 +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", "e4c570ca-189d-4fee-a81b-5466be24a557") - APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "bghqYKJV3709;creKFP8$@@") +#!/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", "e4c570ca-189d-4fee-a81b-5466be24a557") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "bghqYKJV3709;creKFP8$@@") diff --git a/scenarios/message-reactions/requirements.txt b/tests/teams/scenarios/message-reactions/requirements.txt similarity index 100% rename from scenarios/message-reactions/requirements.txt rename to tests/teams/scenarios/message-reactions/requirements.txt diff --git a/scenarios/message-reactions/teams_app_manifest/color.png b/tests/teams/scenarios/message-reactions/teams_app_manifest/color.png similarity index 100% rename from scenarios/message-reactions/teams_app_manifest/color.png rename to tests/teams/scenarios/message-reactions/teams_app_manifest/color.png diff --git a/scenarios/message-reactions/teams_app_manifest/manifest.json b/tests/teams/scenarios/message-reactions/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/message-reactions/teams_app_manifest/manifest.json rename to tests/teams/scenarios/message-reactions/teams_app_manifest/manifest.json index a3ec0ae45..2b53de7e0 100644 --- a/scenarios/message-reactions/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/message-reactions/teams_app_manifest/manifest.json @@ -1,43 +1,43 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0.0", - "id": "<>", - "packageName": "com.teams.sample.conversationUpdate", - "developer": { - "name": "MessageReactions", - "websiteUrl": "https://www.microsoft.com", - "privacyUrl": "https://www.teams.com/privacy", - "termsOfUseUrl": "https://www.teams.com/termsofuser" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "MessageReactions", - "full": "MessageReactions" - }, - "description": { - "short": "MessageReactions", - "full": "MessageReactions" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "<>", - "scopes": [ - "groupchat", - "team", - "personal" - ], - "supportsFiles": false, - "isNotificationOnly": false - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ], - "validDomains": [] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0.0", + "id": "<>", + "packageName": "com.teams.sample.conversationUpdate", + "developer": { + "name": "MessageReactions", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "MessageReactions", + "full": "MessageReactions" + }, + "description": { + "short": "MessageReactions", + "full": "MessageReactions" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "groupchat", + "team", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] } \ No newline at end of file diff --git a/scenarios/message-reactions/teams_app_manifest/outline.png b/tests/teams/scenarios/message-reactions/teams_app_manifest/outline.png similarity index 100% rename from scenarios/message-reactions/teams_app_manifest/outline.png rename to tests/teams/scenarios/message-reactions/teams_app_manifest/outline.png diff --git a/scenarios/message-reactions/threading_helper.py b/tests/teams/scenarios/message-reactions/threading_helper.py similarity index 96% rename from scenarios/message-reactions/threading_helper.py rename to tests/teams/scenarios/message-reactions/threading_helper.py index ab3316e1f..04dd20ee7 100644 --- a/scenarios/message-reactions/threading_helper.py +++ b/tests/teams/scenarios/message-reactions/threading_helper.py @@ -1,169 +1,169 @@ -import asyncio -import itertools -import logging -import threading - -# pylint: disable=invalid-name -# pylint: disable=global-statement -try: - # Python 3.8 or newer has a suitable process watcher - asyncio.ThreadedChildWatcher -except AttributeError: - # backport the Python 3.8 threaded child watcher - import os - import warnings - - # Python 3.7 preferred API - _get_running_loop = getattr(asyncio, "get_running_loop", asyncio.get_event_loop) - - class _Py38ThreadedChildWatcher(asyncio.AbstractChildWatcher): - def __init__(self): - self._pid_counter = itertools.count(0) - self._threads = {} - - def is_active(self): - return True - - def close(self): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - def __del__(self, _warn=warnings.warn): - threads = [t for t in list(self._threads.values()) if t.is_alive()] - if threads: - _warn( - f"{self.__class__} has registered but not finished child processes", - ResourceWarning, - source=self, - ) - - def add_child_handler(self, pid, callback, *args): - loop = _get_running_loop() - thread = threading.Thread( - target=self._do_waitpid, - name=f"waitpid-{next(self._pid_counter)}", - args=(loop, pid, callback, args), - daemon=True, - ) - self._threads[pid] = thread - thread.start() - - def remove_child_handler(self, pid): - # asyncio never calls remove_child_handler() !!! - # The method is no-op but is implemented because - # abstract base class requires it - return True - - def attach_loop(self, loop): - pass - - def _do_waitpid(self, loop, expected_pid, callback, args): - assert expected_pid > 0 - - try: - pid, status = os.waitpid(expected_pid, 0) - except ChildProcessError: - # The child process is already reaped - # (may happen if waitpid() is called elsewhere). - pid = expected_pid - returncode = 255 - logger.warning( - "Unknown child process pid %d, will report returncode 255", pid - ) - else: - if os.WIFSIGNALED(status): - returncode = -os.WTERMSIG(status) - elif os.WIFEXITED(status): - returncode = os.WEXITSTATUS(status) - else: - returncode = status - - if loop.get_debug(): - logger.debug( - "process %s exited with returncode %s", expected_pid, returncode - ) - - if loop.is_closed(): - logger.warning("Loop %r that handles pid %r is closed", loop, pid) - else: - loop.call_soon_threadsafe(callback, pid, returncode, *args) - - self._threads.pop(expected_pid) - - # add the watcher to the loop policy - asyncio.get_event_loop_policy().set_child_watcher(_Py38ThreadedChildWatcher()) - -__all__ = ["EventLoopThread", "get_event_loop", "stop_event_loop", "run_coroutine"] - -logger = logging.getLogger(__name__) - - -class EventLoopThread(threading.Thread): - loop = None - _count = itertools.count(0) - - def __init__(self): - name = f"{type(self).__name__}-{next(self._count)}" - super().__init__(name=name, daemon=True) - - def __repr__(self): - loop, r, c, d = self.loop, False, True, False - if loop is not None: - r, c, d = loop.is_running(), loop.is_closed(), loop.get_debug() - return ( - f"<{type(self).__name__} {self.name} id={self.ident} " - f"running={r} closed={c} debug={d}>" - ) - - def run(self): - self.loop = loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - loop.run_forever() - finally: - try: - shutdown_asyncgens = loop.shutdown_asyncgens() - except AttributeError: - pass - else: - loop.run_until_complete(shutdown_asyncgens) - loop.close() - asyncio.set_event_loop(None) - - def stop(self): - loop, self.loop = self.loop, None - if loop is None: - return - loop.call_soon_threadsafe(loop.stop) - self.join() - - -_lock = threading.Lock() -_loop_thread = None - - -def get_event_loop(): - global _loop_thread - with _lock: - if _loop_thread is None: - _loop_thread = EventLoopThread() - _loop_thread.start() - return _loop_thread.loop - - -def stop_event_loop(): - global _loop_thread - with _lock: - if _loop_thread is not None: - _loop_thread.stop() - _loop_thread = None - - -def run_coroutine(coro): - return asyncio.run_coroutine_threadsafe(coro, get_event_loop()) +import asyncio +import itertools +import logging +import threading + +# pylint: disable=invalid-name +# pylint: disable=global-statement +try: + # Python 3.8 or newer has a suitable process watcher + asyncio.ThreadedChildWatcher +except AttributeError: + # backport the Python 3.8 threaded child watcher + import os + import warnings + + # Python 3.7 preferred API + _get_running_loop = getattr(asyncio, "get_running_loop", asyncio.get_event_loop) + + class _Py38ThreadedChildWatcher(asyncio.AbstractChildWatcher): + def __init__(self): + self._pid_counter = itertools.count(0) + self._threads = {} + + def is_active(self): + return True + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def __del__(self, _warn=warnings.warn): + threads = [t for t in list(self._threads.values()) if t.is_alive()] + if threads: + _warn( + f"{self.__class__} has registered but not finished child processes", + ResourceWarning, + source=self, + ) + + def add_child_handler(self, pid, callback, *args): + loop = _get_running_loop() + thread = threading.Thread( + target=self._do_waitpid, + name=f"waitpid-{next(self._pid_counter)}", + args=(loop, pid, callback, args), + daemon=True, + ) + self._threads[pid] = thread + thread.start() + + def remove_child_handler(self, pid): + # asyncio never calls remove_child_handler() !!! + # The method is no-op but is implemented because + # abstract base class requires it + return True + + def attach_loop(self, loop): + pass + + def _do_waitpid(self, loop, expected_pid, callback, args): + assert expected_pid > 0 + + try: + pid, status = os.waitpid(expected_pid, 0) + except ChildProcessError: + # The child process is already reaped + # (may happen if waitpid() is called elsewhere). + pid = expected_pid + returncode = 255 + logger.warning( + "Unknown child process pid %d, will report returncode 255", pid + ) + else: + if os.WIFSIGNALED(status): + returncode = -os.WTERMSIG(status) + elif os.WIFEXITED(status): + returncode = os.WEXITSTATUS(status) + else: + returncode = status + + if loop.get_debug(): + logger.debug( + "process %s exited with returncode %s", expected_pid, returncode + ) + + if loop.is_closed(): + logger.warning("Loop %r that handles pid %r is closed", loop, pid) + else: + loop.call_soon_threadsafe(callback, pid, returncode, *args) + + self._threads.pop(expected_pid) + + # add the watcher to the loop policy + asyncio.get_event_loop_policy().set_child_watcher(_Py38ThreadedChildWatcher()) + +__all__ = ["EventLoopThread", "get_event_loop", "stop_event_loop", "run_coroutine"] + +logger = logging.getLogger(__name__) + + +class EventLoopThread(threading.Thread): + loop = None + _count = itertools.count(0) + + def __init__(self): + name = f"{type(self).__name__}-{next(self._count)}" + super().__init__(name=name, daemon=True) + + def __repr__(self): + loop, r, c, d = self.loop, False, True, False + if loop is not None: + r, c, d = loop.is_running(), loop.is_closed(), loop.get_debug() + return ( + f"<{type(self).__name__} {self.name} id={self.ident} " + f"running={r} closed={c} debug={d}>" + ) + + def run(self): + self.loop = loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + loop.run_forever() + finally: + try: + shutdown_asyncgens = loop.shutdown_asyncgens() + except AttributeError: + pass + else: + loop.run_until_complete(shutdown_asyncgens) + loop.close() + asyncio.set_event_loop(None) + + def stop(self): + loop, self.loop = self.loop, None + if loop is None: + return + loop.call_soon_threadsafe(loop.stop) + self.join() + + +_lock = threading.Lock() +_loop_thread = None + + +def get_event_loop(): + global _loop_thread + with _lock: + if _loop_thread is None: + _loop_thread = EventLoopThread() + _loop_thread.start() + return _loop_thread.loop + + +def stop_event_loop(): + global _loop_thread + with _lock: + if _loop_thread is not None: + _loop_thread.stop() + _loop_thread = None + + +def run_coroutine(coro): + return asyncio.run_coroutine_threadsafe(coro, get_event_loop()) diff --git a/scenarios/roster/README.md b/tests/teams/scenarios/roster/README.md similarity index 97% rename from scenarios/roster/README.md rename to tests/teams/scenarios/roster/README.md index 39f77916c..eecb8fccb 100644 --- a/scenarios/roster/README.md +++ b/tests/teams/scenarios/roster/README.md @@ -1,30 +1,30 @@ -# RosterBot - -Bot Framework v4 teams roster 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. - -## 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\roster` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/roster/app.py b/tests/teams/scenarios/roster/app.py similarity index 97% rename from scenarios/roster/app.py rename to tests/teams/scenarios/roster/app.py index f491845be..ba575e0bf 100644 --- a/scenarios/roster/app.py +++ b/tests/teams/scenarios/roster/app.py @@ -1,92 +1,92 @@ -# 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 RosterBot - -# 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( # pylint: disable=unused-argument - self, 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 = MethodType(on_error, ADAPTER) - -# Create the Bot -BOT = RosterBot() - -# 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 +# 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 RosterBot + +# 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( # pylint: disable=unused-argument + self, 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 = MethodType(on_error, ADAPTER) + +# Create the Bot +BOT = RosterBot() + +# 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/roster/bots/__init__.py b/tests/teams/scenarios/roster/bots/__init__.py similarity index 96% rename from scenarios/roster/bots/__init__.py rename to tests/teams/scenarios/roster/bots/__init__.py index a2e035b9f..44ab91a4b 100644 --- a/scenarios/roster/bots/__init__.py +++ b/tests/teams/scenarios/roster/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .roster_bot import RosterBot - -__all__ = ["RosterBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .roster_bot import RosterBot + +__all__ = ["RosterBot"] diff --git a/scenarios/roster/bots/roster_bot.py b/tests/teams/scenarios/roster/bots/roster_bot.py similarity index 97% rename from scenarios/roster/bots/roster_bot.py rename to tests/teams/scenarios/roster/bots/roster_bot.py index eab7b69e5..31cf75608 100644 --- a/scenarios/roster/bots/roster_bot.py +++ b/tests/teams/scenarios/roster/bots/roster_bot.py @@ -1,66 +1,66 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import List -from botbuilder.core import MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount -from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo - -class RosterBot(TeamsActivityHandler): - async def on_members_added_activity( - self, members_added: [ChannelAccount], turn_context: TurnContext - ): - for member in members_added: - if member.id != turn_context.activity.recipient.id: - await turn_context.send_activity( - "Hello and welcome!" - ) - - async def on_message_activity( - self, turn_context: TurnContext - ): - await turn_context.send_activity(MessageFactory.text(f"Echo: {turn_context.activity.text}")) - - text = turn_context.activity.text.strip() - if "members" in text: - await self._show_members(turn_context) - elif "channels" in text: - await self._show_channels(turn_context) - elif "details" in text: - await self._show_details(turn_context) - else: - await turn_context.send_activity(MessageFactory.text(f"Invalid command. Type \"Show channels\" to see a channel list. Type \"Show members\" to see a list of members in a team. Type \"Show details\" to see team information.")) - - async def _show_members( - self, turn_context: TurnContext - ): - members = await TeamsInfo.get_team_members(turn_context) - reply = MessageFactory.text(f"Total of {len(members)} members are currently in team") - await turn_context.send_activity(reply) - messages = list(map(lambda m: (f'{m.aad_object_id} --> {m.name} --> {m.user_principal_name}'), members)) - await self._send_in_batches(turn_context, messages) - - async def _show_channels( - self, turn_context: TurnContext - ): - channels = await TeamsInfo.get_team_channels(turn_context) - reply = MessageFactory.text(f"Total of {len(channels)} channels are currently in team") - await turn_context.send_activity(reply) - messages = list(map(lambda c: (f'{c.id} --> {c.name}'), channels)) - await self._send_in_batches(turn_context, messages) - - async def _show_details(self, turn_context: TurnContext): - team_details = await TeamsInfo.get_team_details(turn_context) - reply = MessageFactory.text(f"The team name is {team_details.name}. The team ID is {team_details.id}. The AADGroupID is {team_details.aad_group_id}.") - await turn_context.send_activity(reply) - - async def _send_in_batches(self, turn_context: TurnContext, messages: List[str]): - batch = [] - for msg in messages: - batch.append(msg) - if len(batch) == 10: - await turn_context.send_activity(MessageFactory.text("
".join(batch))) - batch = [] - - if len(batch) > 0: +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +class RosterBot(TeamsActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + "Hello and welcome!" + ) + + async def on_message_activity( + self, turn_context: TurnContext + ): + await turn_context.send_activity(MessageFactory.text(f"Echo: {turn_context.activity.text}")) + + text = turn_context.activity.text.strip() + if "members" in text: + await self._show_members(turn_context) + elif "channels" in text: + await self._show_channels(turn_context) + elif "details" in text: + await self._show_details(turn_context) + else: + await turn_context.send_activity(MessageFactory.text(f"Invalid command. Type \"Show channels\" to see a channel list. Type \"Show members\" to see a list of members in a team. Type \"Show details\" to see team information.")) + + async def _show_members( + self, turn_context: TurnContext + ): + members = await TeamsInfo.get_team_members(turn_context) + reply = MessageFactory.text(f"Total of {len(members)} members are currently in team") + await turn_context.send_activity(reply) + messages = list(map(lambda m: (f'{m.aad_object_id} --> {m.name} --> {m.user_principal_name}'), members)) + await self._send_in_batches(turn_context, messages) + + async def _show_channels( + self, turn_context: TurnContext + ): + channels = await TeamsInfo.get_team_channels(turn_context) + reply = MessageFactory.text(f"Total of {len(channels)} channels are currently in team") + await turn_context.send_activity(reply) + messages = list(map(lambda c: (f'{c.id} --> {c.name}'), channels)) + await self._send_in_batches(turn_context, messages) + + async def _show_details(self, turn_context: TurnContext): + team_details = await TeamsInfo.get_team_details(turn_context) + reply = MessageFactory.text(f"The team name is {team_details.name}. The team ID is {team_details.id}. The AADGroupID is {team_details.aad_group_id}.") + await turn_context.send_activity(reply) + + async def _send_in_batches(self, turn_context: TurnContext, messages: List[str]): + batch = [] + for msg in messages: + batch.append(msg) + if len(batch) == 10: + await turn_context.send_activity(MessageFactory.text("
".join(batch))) + batch = [] + + if len(batch) > 0: await turn_context.send_activity(MessageFactory.text("
".join(batch))) \ No newline at end of file diff --git a/tests/teams/scenarios/roster/config.py b/tests/teams/scenarios/roster/config.py new file mode 100644 index 000000000..d66581d4c --- /dev/null +++ b/tests/teams/scenarios/roster/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/roster/requirements.txt b/tests/teams/scenarios/roster/requirements.txt similarity index 100% rename from scenarios/roster/requirements.txt rename to tests/teams/scenarios/roster/requirements.txt diff --git a/scenarios/roster/teams_app_manifest/color.png b/tests/teams/scenarios/roster/teams_app_manifest/color.png similarity index 100% rename from scenarios/roster/teams_app_manifest/color.png rename to tests/teams/scenarios/roster/teams_app_manifest/color.png diff --git a/scenarios/roster/teams_app_manifest/manifest.json b/tests/teams/scenarios/roster/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/roster/teams_app_manifest/manifest.json rename to tests/teams/scenarios/roster/teams_app_manifest/manifest.json index 3078aeec2..c6b6582b0 100644 --- a/scenarios/roster/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/roster/teams_app_manifest/manifest.json @@ -1,42 +1,42 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0.0", - "id": "00000000-0000-0000-0000-000000000000", - "packageName": "com.teams.sample.roster", - "developer": { - "name": "TeamsRosterBot", - "websiteUrl": "https://www.microsoft.com", - "privacyUrl": "https://www.teams.com/privacy", - "termsOfUseUrl": "https://www.teams.com/termsofuser" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "TeamsRosterBot", - "full": "TeamsRosterBot" - }, - "description": { - "short": "TeamsRosterBot", - "full": "TeamsRosterBot" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "00000000-0000-0000-0000-000000000000", - "scopes": [ - "groupchat", - "team" - ], - "supportsFiles": false, - "isNotificationOnly": false - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ], - "validDomains": [] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0.0", + "id": "00000000-0000-0000-0000-000000000000", + "packageName": "com.teams.sample.roster", + "developer": { + "name": "TeamsRosterBot", + "websiteUrl": "https://www.microsoft.com", + "privacyUrl": "https://www.teams.com/privacy", + "termsOfUseUrl": "https://www.teams.com/termsofuser" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "TeamsRosterBot", + "full": "TeamsRosterBot" + }, + "description": { + "short": "TeamsRosterBot", + "full": "TeamsRosterBot" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "00000000-0000-0000-0000-000000000000", + "scopes": [ + "groupchat", + "team" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [] } \ No newline at end of file diff --git a/scenarios/roster/teams_app_manifest/outline.png b/tests/teams/scenarios/roster/teams_app_manifest/outline.png similarity index 100% rename from scenarios/roster/teams_app_manifest/outline.png rename to tests/teams/scenarios/roster/teams_app_manifest/outline.png diff --git a/scenarios/search-based-messaging-extension/README.md b/tests/teams/scenarios/search-based-messaging-extension/README.md similarity index 97% rename from scenarios/search-based-messaging-extension/README.md rename to tests/teams/scenarios/search-based-messaging-extension/README.md index 39f77916c..eecb8fccb 100644 --- a/scenarios/search-based-messaging-extension/README.md +++ b/tests/teams/scenarios/search-based-messaging-extension/README.md @@ -1,30 +1,30 @@ -# RosterBot - -Bot Framework v4 teams roster 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. - -## 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\roster` 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 -- Paste this URL in the emulator window - http://localhost:3978/api/messages - -## 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) +# RosterBot + +Bot Framework v4 teams roster 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. + +## 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\roster` 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 +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## 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) diff --git a/scenarios/search-based-messaging-extension/app.py b/tests/teams/scenarios/search-based-messaging-extension/app.py similarity index 97% rename from scenarios/search-based-messaging-extension/app.py rename to tests/teams/scenarios/search-based-messaging-extension/app.py index 4b0440729..62c00ce20 100644 --- a/scenarios/search-based-messaging-extension/app.py +++ b/tests/teams/scenarios/search-based-messaging-extension/app.py @@ -1,83 +1,83 @@ -import json -import sys -from datetime import datetime - -from aiohttp import web -from aiohttp.web import Request, Response, json_response - -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, -) - -from botbuilder.schema import Activity, ActivityTypes -from bots import SearchBasedMessagingExtension -from config import DefaultConfig - -CONFIG = DefaultConfig() - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = SearchBasedMessagingExtension() - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - # Main bot message handler. - if "application/json" in req.headers["Content-Type"]: - body = await req.json() - else: - return Response(status=415) - - activity = Activity().deserialize(body) - auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" - - try: - response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) - if response: - return json_response(data=response.body, status=response.status) - return Response(status=201) - except Exception as exception: - raise exception - -APP = web.Application() -APP.router.add_post("/api/messages", messages) - -if __name__ == "__main__": - try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response + +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) + +from botbuilder.schema import Activity, ActivityTypes +from bots import SearchBasedMessagingExtension +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = SearchBasedMessagingExtension() + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + if response: + return json_response(data=response.body, status=response.status) + return Response(status=201) + except Exception as exception: + raise exception + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/search-based-messaging-extension/bots/__init__.py b/tests/teams/scenarios/search-based-messaging-extension/bots/__init__.py similarity index 97% rename from scenarios/search-based-messaging-extension/bots/__init__.py rename to tests/teams/scenarios/search-based-messaging-extension/bots/__init__.py index d35ade2a7..9311de37a 100644 --- a/scenarios/search-based-messaging-extension/bots/__init__.py +++ b/tests/teams/scenarios/search-based-messaging-extension/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .search_based_messaging_extension import SearchBasedMessagingExtension - -__all__ = ["SearchBasedMessagingExtension"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .search_based_messaging_extension import SearchBasedMessagingExtension + +__all__ = ["SearchBasedMessagingExtension"] diff --git a/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py b/tests/teams/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py similarity index 97% rename from scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py rename to tests/teams/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py index ff576fd85..27db99646 100644 --- a/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py +++ b/tests/teams/scenarios/search-based-messaging-extension/bots/search_based_messaging_extension.py @@ -1,175 +1,175 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from botbuilder.core import CardFactory, MessageFactory, TurnContext -from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment, CardAction -from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse -from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo - -from typing import List -import requests - -class SearchBasedMessagingExtension(TeamsActivityHandler): - async def on_message_activity(self, turn_context: TurnContext): - await turn_context.send_activities(MessageFactory.text(f"Echo: {turn_context.activity.text}")) - - async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): - search_query = str(query.parameters[0].value) - response = requests.get(f"http://registry.npmjs.com/-/v1/search",params={"text":search_query}) - data = response.json() - - attachments = [] - - for obj in data["objects"]: - hero_card = HeroCard( - title=obj["package"]["name"], - tap=CardAction( - type="invoke", - value=obj["package"] - ), - preview=[CardImage(url=obj["package"]["links"]["npm"])] - ) - - attachment = MessagingExtensionAttachment( - content_type=CardFactory.content_types.hero_card, - content=HeroCard(title=obj["package"]["name"]), - preview=CardFactory.hero_card(hero_card) - ) - attachments.append(attachment) - return MessagingExtensionResponse( - compose_extension=MessagingExtensionResult( - type="result", - attachment_layout="list", - attachments=attachments - ) - ) - - - - async def on_teams_messaging_extension_select_item(self, turn_context: TurnContext, query) -> MessagingExtensionResponse: - hero_card = HeroCard( - title=query["name"], - subtitle=query["description"], - buttons=[ - CardAction( - type="openUrl", - value=query["links"]["npm"] - ) - ] - ) - attachment = MessagingExtensionAttachment( - content_type=CardFactory.content_types.hero_card, - content=hero_card - ) - - return MessagingExtensionResponse( - compose_extension=MessagingExtensionResult( - type="result", - attachment_layout="list", - attachments=[attachment] - ) - ) - - def _create_messaging_extension_result(self, attachments: List[MessagingExtensionAttachment]) -> MessagingExtensionResult: - return MessagingExtensionResult( - type="result", - attachment_layout="list", - attachments=attachments - ) - - def _create_search_result_attachment(self, search_query: str) -> MessagingExtensionAttachment: - card_text = f"You said {search_query}" - bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" - - button = CardAction( - type="openUrl", - title="Click for more Information", - value="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" - ) - - images = [CardImage(url=bf_logo)] - buttons = [button] - - hero_card = HeroCard( - title="You searched for:", - text=card_text, - images=images, - buttons=buttons - ) - - return MessagingExtensionAttachment( - content_type=CardFactory.content_types.hero_card, - content=hero_card, - preview=CardFactory.hero_card(hero_card) - ) - - def _create_dummy_search_result_attachment(self) -> MessagingExtensionAttachment: - card_text = "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" - bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" - - button = CardAction( - type = "openUrl", - title = "Click for more Information", - value = "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" - ) - - images = [CardImage(url=bf_logo)] - - buttons = [button] - - - hero_card = HeroCard( - title="Learn more about Teams:", - text=card_text, images=images, - buttons=buttons - ) - - preview = HeroCard( - title="Learn more about Teams:", - text=card_text, - images=images - ) - - return MessagingExtensionAttachment( - content_type = CardFactory.content_types.hero_card, - content = hero_card, - preview = CardFactory.hero_card(preview) - ) - - def _create_select_items_result_attachment(self, search_query: str) -> MessagingExtensionAttachment: - card_text = f"You said {search_query}" - bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" - - buttons = CardAction( - type="openUrl", - title="Click for more Information", - value="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" - ) - - images = [CardImage(url=bf_logo)] - buttons = [buttons] - - select_item_tap = CardAction( - type="invoke", - value={"query": search_query} - ) - - hero_card = HeroCard( - title="You searched for:", - text=card_text, - images=images, - buttons=buttons - ) - - preview = HeroCard( - title=card_text, - text=card_text, - images=images, - tap=select_item_tap - ) - - return MessagingExtensionAttachment( - content_type=CardFactory.content_types.hero_card, - content=hero_card, - preview=CardFactory.hero_card(preview) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, ThumbnailCard, CardImage, HeroCard, Attachment, CardAction +from botbuilder.schema.teams import AppBasedLinkQuery, MessagingExtensionAttachment, MessagingExtensionQuery, MessagingExtensionResult, MessagingExtensionResponse +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo + +from typing import List +import requests + +class SearchBasedMessagingExtension(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + await turn_context.send_activities(MessageFactory.text(f"Echo: {turn_context.activity.text}")) + + async def on_teams_messaging_extension_query(self, turn_context: TurnContext, query: MessagingExtensionQuery): + search_query = str(query.parameters[0].value) + response = requests.get(f"http://registry.npmjs.com/-/v1/search",params={"text":search_query}) + data = response.json() + + attachments = [] + + for obj in data["objects"]: + hero_card = HeroCard( + title=obj["package"]["name"], + tap=CardAction( + type="invoke", + value=obj["package"] + ), + preview=[CardImage(url=obj["package"]["links"]["npm"])] + ) + + attachment = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=HeroCard(title=obj["package"]["name"]), + preview=CardFactory.hero_card(hero_card) + ) + attachments.append(attachment) + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type="result", + attachment_layout="list", + attachments=attachments + ) + ) + + + + async def on_teams_messaging_extension_select_item(self, turn_context: TurnContext, query) -> MessagingExtensionResponse: + hero_card = HeroCard( + title=query["name"], + subtitle=query["description"], + buttons=[ + CardAction( + type="openUrl", + value=query["links"]["npm"] + ) + ] + ) + attachment = MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card + ) + + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type="result", + attachment_layout="list", + attachments=[attachment] + ) + ) + + def _create_messaging_extension_result(self, attachments: List[MessagingExtensionAttachment]) -> MessagingExtensionResult: + return MessagingExtensionResult( + type="result", + attachment_layout="list", + attachments=attachments + ) + + def _create_search_result_attachment(self, search_query: str) -> MessagingExtensionAttachment: + card_text = f"You said {search_query}" + bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + + button = CardAction( + type="openUrl", + title="Click for more Information", + value="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + ) + + images = [CardImage(url=bf_logo)] + buttons = [button] + + hero_card = HeroCard( + title="You searched for:", + text=card_text, + images=images, + buttons=buttons + ) + + return MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card, + preview=CardFactory.hero_card(hero_card) + ) + + def _create_dummy_search_result_attachment(self) -> MessagingExtensionAttachment: + card_text = "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + + button = CardAction( + type = "openUrl", + title = "Click for more Information", + value = "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + ) + + images = [CardImage(url=bf_logo)] + + buttons = [button] + + + hero_card = HeroCard( + title="Learn more about Teams:", + text=card_text, images=images, + buttons=buttons + ) + + preview = HeroCard( + title="Learn more about Teams:", + text=card_text, + images=images + ) + + return MessagingExtensionAttachment( + content_type = CardFactory.content_types.hero_card, + content = hero_card, + preview = CardFactory.hero_card(preview) + ) + + def _create_select_items_result_attachment(self, search_query: str) -> MessagingExtensionAttachment: + card_text = f"You said {search_query}" + bf_logo = "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU" + + buttons = CardAction( + type="openUrl", + title="Click for more Information", + value="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/bots/bots-overview" + ) + + images = [CardImage(url=bf_logo)] + buttons = [buttons] + + select_item_tap = CardAction( + type="invoke", + value={"query": search_query} + ) + + hero_card = HeroCard( + title="You searched for:", + text=card_text, + images=images, + buttons=buttons + ) + + preview = HeroCard( + title=card_text, + text=card_text, + images=images, + tap=select_item_tap + ) + + return MessagingExtensionAttachment( + content_type=CardFactory.content_types.hero_card, + content=hero_card, + preview=CardFactory.hero_card(preview) ) \ No newline at end of file diff --git a/tests/teams/scenarios/search-based-messaging-extension/config.py b/tests/teams/scenarios/search-based-messaging-extension/config.py new file mode 100644 index 000000000..d66581d4c --- /dev/null +++ b/tests/teams/scenarios/search-based-messaging-extension/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/search-based-messaging-extension/requirements.txt b/tests/teams/scenarios/search-based-messaging-extension/requirements.txt similarity index 100% rename from scenarios/search-based-messaging-extension/requirements.txt rename to tests/teams/scenarios/search-based-messaging-extension/requirements.txt diff --git a/scenarios/search-based-messaging-extension/teams_app_manifest/color.png b/tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/color.png similarity index 100% rename from scenarios/search-based-messaging-extension/teams_app_manifest/color.png rename to tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/color.png diff --git a/scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json b/tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json similarity index 97% rename from scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json rename to tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json index 02c8164fc..98bb01282 100644 --- a/scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/manifest.json @@ -1,49 +1,49 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0", - "id": "<>", - "packageName": "com.microsoft.teams.samples.searchExtension", - "developer": { - "name": "Microsoft Corp", - "websiteUrl": "https://example.azurewebsites.net", - "privacyUrl": "https://example.azurewebsites.net/privacy", - "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" - }, - "name": { - "short": "search-extension-settings", - "full": "Microsoft Teams V4 Search Messaging Extension Bot and settings" - }, - "description": { - "short": "Microsoft Teams V4 Search Messaging Extension Bot and settings", - "full": "Sample Search Messaging Extension Bot using V4 Bot Builder SDK and V4 Microsoft Teams Extension SDK" - }, - "icons": { - "outline": "icon-outline.png", - "color": "icon-color.png" - }, - "accentColor": "#abcdef", - "composeExtensions": [ - { - "botId": "<>", - "canUpdateConfiguration": true, - "commands": [ - { - "id": "searchQuery", - "context": [ "compose", "commandBox" ], - "description": "Test command to run query", - "title": "Search", - "type": "query", - "parameters": [ - { - "name": "searchQuery", - "title": "Search Query", - "description": "Your search query", - "inputType": "text" - } - ] - } - ] - } - ] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0", + "id": "<>", + "packageName": "com.microsoft.teams.samples.searchExtension", + "developer": { + "name": "Microsoft Corp", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "name": { + "short": "search-extension-settings", + "full": "Microsoft Teams V4 Search Messaging Extension Bot and settings" + }, + "description": { + "short": "Microsoft Teams V4 Search Messaging Extension Bot and settings", + "full": "Sample Search Messaging Extension Bot using V4 Bot Builder SDK and V4 Microsoft Teams Extension SDK" + }, + "icons": { + "outline": "icon-outline.png", + "color": "icon-color.png" + }, + "accentColor": "#abcdef", + "composeExtensions": [ + { + "botId": "<>", + "canUpdateConfiguration": true, + "commands": [ + { + "id": "searchQuery", + "context": [ "compose", "commandBox" ], + "description": "Test command to run query", + "title": "Search", + "type": "query", + "parameters": [ + { + "name": "searchQuery", + "title": "Search Query", + "description": "Your search query", + "inputType": "text" + } + ] + } + ] + } + ] } \ No newline at end of file diff --git a/scenarios/search-based-messaging-extension/teams_app_manifest/outline.png b/tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/outline.png similarity index 100% rename from scenarios/search-based-messaging-extension/teams_app_manifest/outline.png rename to tests/teams/scenarios/search-based-messaging-extension/teams_app_manifest/outline.png diff --git a/scenarios/task-module/app.py b/tests/teams/scenarios/task-module/app.py similarity index 96% rename from scenarios/task-module/app.py rename to tests/teams/scenarios/task-module/app.py index 4fa136703..b5abfad28 100644 --- a/scenarios/task-module/app.py +++ b/tests/teams/scenarios/task-module/app.py @@ -1,93 +1,93 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import json -import sys -from datetime import datetime - -from aiohttp import web -from aiohttp.web import Request, Response, json_response -from botbuilder.core import ( - BotFrameworkAdapterSettings, - TurnContext, - BotFrameworkAdapter, -) -from botbuilder.schema import Activity, ActivityTypes -from bots import TaskModuleBot -from config import DefaultConfig - -CONFIG = DefaultConfig() - -# Create adapter. -# See https://aka.ms/about-bot-adapter to learn more about how bots work. -SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = TaskModuleBot() - - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - # Main bot message handler. - if "application/json" in req.headers["Content-Type"]: - body = await req.json() - else: - return Response(status=415) - - activity = Activity().deserialize(body) - auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" - - try: - invoke_response = await ADAPTER.process_activity( - activity, auth_header, BOT.on_turn - ) - if invoke_response: - return json_response( - data=invoke_response.body, status=invoke_response.status - ) - return Response(status=201) - except PermissionError: - return Response(status=401) - except Exception: - return Response(status=500) - - -APP = web.Application() -APP.router.add_post("/api/messages", messages) - -if __name__ == "__main__": - try: - web.run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response, json_response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes +from bots import TaskModuleBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, 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 = TaskModuleBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + invoke_response = await ADAPTER.process_activity( + activity, auth_header, BOT.on_turn + ) + if invoke_response: + return json_response( + data=invoke_response.body, status=invoke_response.status + ) + return Response(status=201) + except PermissionError: + return Response(status=401) + except Exception: + return Response(status=500) + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/scenarios/task-module/bots/__init__.py b/tests/teams/scenarios/task-module/bots/__init__.py similarity index 96% rename from scenarios/task-module/bots/__init__.py rename to tests/teams/scenarios/task-module/bots/__init__.py index 464ebfcd1..550d3aaf8 100644 --- a/scenarios/task-module/bots/__init__.py +++ b/tests/teams/scenarios/task-module/bots/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from .teams_task_module_bot import TaskModuleBot - -__all__ = ["TaskModuleBot"] +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .teams_task_module_bot import TaskModuleBot + +__all__ = ["TaskModuleBot"] diff --git a/scenarios/task-module/bots/teams_task_module_bot.py b/tests/teams/scenarios/task-module/bots/teams_task_module_bot.py similarity index 97% rename from scenarios/task-module/bots/teams_task_module_bot.py rename to tests/teams/scenarios/task-module/bots/teams_task_module_bot.py index be0e8bf08..3c4cbde5d 100644 --- a/scenarios/task-module/bots/teams_task_module_bot.py +++ b/tests/teams/scenarios/task-module/bots/teams_task_module_bot.py @@ -1,90 +1,90 @@ -# Copyright (c) Microsoft Corp. All rights reserved. -# Licensed under the MIT License. - -import json -from typing import List -import random -from botbuilder.core import CardFactory, MessageFactory, TurnContext -from botbuilder.schema import ( - ChannelAccount, - HeroCard, - CardAction, - CardImage, - Attachment, -) -from botbuilder.schema.teams import ( - MessagingExtensionAction, - MessagingExtensionActionResponse, - MessagingExtensionAttachment, - MessagingExtensionResult, - TaskModuleResponse, - TaskModuleResponseBase, - TaskModuleContinueResponse, - TaskModuleMessageResponse, - TaskModuleTaskInfo, - TaskModuleRequest, -) -from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo -from botbuilder.azure import CosmosDbPartitionedStorage -from botbuilder.core.teams.teams_helper import deserializer_helper - -class TaskModuleBot(TeamsActivityHandler): - async def on_message_activity(self, turn_context: TurnContext): - reply = MessageFactory.attachment(self._get_task_module_hero_card()) - await turn_context.send_activity(reply) - - def _get_task_module_hero_card(self) -> Attachment: - task_module_action = CardAction( - type="invoke", - title="Adaptive Card", - value={"type": "task/fetch", "data": "adaptivecard"}, - ) - card = HeroCard( - title="Task Module Invocation from Hero Card", - subtitle="This is a hero card with a Task Module Action button. Click the button to show an Adaptive Card within a Task Module.", - buttons=[task_module_action], - ) - return CardFactory.hero_card(card) - - async def on_teams_task_module_fetch( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - reply = MessageFactory.text( - f"OnTeamsTaskModuleFetchAsync TaskModuleRequest: {json.dumps(task_module_request.data)}" - ) - await turn_context.send_activity(reply) - - # base_response = TaskModuleResponseBase(type='continue') - card = CardFactory.adaptive_card( - { - "version": "1.0.0", - "type": "AdaptiveCard", - "body": [ - {"type": "TextBlock", "text": "Enter Text Here",}, - { - "type": "Input.Text", - "id": "usertext", - "placeholder": "add some text and submit", - "IsMultiline": "true", - }, - ], - "actions": [{"type": "Action.Submit", "title": "Submit",}], - } - ) - - task_info = TaskModuleTaskInfo( - card=card, title="Adaptive Card: Inputs", height=200, width=400 - ) - continue_response = TaskModuleContinueResponse(type="continue", value=task_info) - return TaskModuleResponse(task=continue_response) - - async def on_teams_task_module_submit( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - reply = MessageFactory.text( - f"on_teams_messaging_extension_submit_action_activity MessagingExtensionAction: {json.dumps(task_module_request.data)}" - ) - await turn_context.send_activity(reply) - - message_response = TaskModuleMessageResponse(type="message", value="Thanks!") - return TaskModuleResponse(task=message_response) +# Copyright (c) Microsoft Corp. All rights reserved. +# Licensed under the MIT License. + +import json +from typing import List +import random +from botbuilder.core import CardFactory, MessageFactory, TurnContext +from botbuilder.schema import ( + ChannelAccount, + HeroCard, + CardAction, + CardImage, + Attachment, +) +from botbuilder.schema.teams import ( + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionResult, + TaskModuleResponse, + TaskModuleResponseBase, + TaskModuleContinueResponse, + TaskModuleMessageResponse, + TaskModuleTaskInfo, + TaskModuleRequest, +) +from botbuilder.core.teams import TeamsActivityHandler, TeamsInfo +from botbuilder.azure import CosmosDbPartitionedStorage +from botbuilder.core.teams.teams_helper import deserializer_helper + +class TaskModuleBot(TeamsActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + reply = MessageFactory.attachment(self._get_task_module_hero_card()) + await turn_context.send_activity(reply) + + def _get_task_module_hero_card(self) -> Attachment: + task_module_action = CardAction( + type="invoke", + title="Adaptive Card", + value={"type": "task/fetch", "data": "adaptivecard"}, + ) + card = HeroCard( + title="Task Module Invocation from Hero Card", + subtitle="This is a hero card with a Task Module Action button. Click the button to show an Adaptive Card within a Task Module.", + buttons=[task_module_action], + ) + return CardFactory.hero_card(card) + + async def on_teams_task_module_fetch( + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ) -> TaskModuleResponse: + reply = MessageFactory.text( + f"OnTeamsTaskModuleFetchAsync TaskModuleRequest: {json.dumps(task_module_request.data)}" + ) + await turn_context.send_activity(reply) + + # base_response = TaskModuleResponseBase(type='continue') + card = CardFactory.adaptive_card( + { + "version": "1.0.0", + "type": "AdaptiveCard", + "body": [ + {"type": "TextBlock", "text": "Enter Text Here",}, + { + "type": "Input.Text", + "id": "usertext", + "placeholder": "add some text and submit", + "IsMultiline": "true", + }, + ], + "actions": [{"type": "Action.Submit", "title": "Submit",}], + } + ) + + task_info = TaskModuleTaskInfo( + card=card, title="Adaptive Card: Inputs", height=200, width=400 + ) + continue_response = TaskModuleContinueResponse(type="continue", value=task_info) + return TaskModuleResponse(task=continue_response) + + async def on_teams_task_module_submit( + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ) -> TaskModuleResponse: + reply = MessageFactory.text( + f"on_teams_messaging_extension_submit_action_activity MessagingExtensionAction: {json.dumps(task_module_request.data)}" + ) + await turn_context.send_activity(reply) + + message_response = TaskModuleMessageResponse(type="message", value="Thanks!") + return TaskModuleResponse(task=message_response) diff --git a/scenarios/task-module/config.py b/tests/teams/scenarios/task-module/config.py similarity index 95% rename from scenarios/task-module/config.py rename to tests/teams/scenarios/task-module/config.py index 9496963d3..42a571bcf 100644 --- a/scenarios/task-module/config.py +++ b/tests/teams/scenarios/task-module/config.py @@ -1,15 +1,15 @@ -#!/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", "" - ) +#!/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/task-module/requirements.txt b/tests/teams/scenarios/task-module/requirements.txt similarity index 100% rename from scenarios/task-module/requirements.txt rename to tests/teams/scenarios/task-module/requirements.txt diff --git a/scenarios/task-module/teams_app_manifest/icon-color.png b/tests/teams/scenarios/task-module/teams_app_manifest/icon-color.png similarity index 100% rename from scenarios/task-module/teams_app_manifest/icon-color.png rename to tests/teams/scenarios/task-module/teams_app_manifest/icon-color.png diff --git a/scenarios/task-module/teams_app_manifest/icon-outline.png b/tests/teams/scenarios/task-module/teams_app_manifest/icon-outline.png similarity index 100% rename from scenarios/task-module/teams_app_manifest/icon-outline.png rename to tests/teams/scenarios/task-module/teams_app_manifest/icon-outline.png diff --git a/scenarios/task-module/teams_app_manifest/manifest.json b/tests/teams/scenarios/task-module/teams_app_manifest/manifest.json similarity index 96% rename from scenarios/task-module/teams_app_manifest/manifest.json rename to tests/teams/scenarios/task-module/teams_app_manifest/manifest.json index 19d241ba5..21600fcd6 100644 --- a/scenarios/task-module/teams_app_manifest/manifest.json +++ b/tests/teams/scenarios/task-module/teams_app_manifest/manifest.json @@ -1,42 +1,42 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", - "manifestVersion": "1.5", - "version": "1.0.0", - "id": "<>", - "packageName": "com.microsoft.teams.samples", - "developer": { - "name": "Microsoft", - "websiteUrl": "https://example.azurewebsites.net", - "privacyUrl": "https://example.azurewebsites.net/privacy", - "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "Task Module", - "full": "Simple Task Module" - }, - "description": { - "short": "Test Task Module Scenario", - "full": "Simple Task Module Scenario Test" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "<>", - "scopes": [ - "personal", - "team", - "groupchat" - ], - "supportsFiles": false, - "isNotificationOnly": false - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ] +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json", + "manifestVersion": "1.5", + "version": "1.0.0", + "id": "<>", + "packageName": "com.microsoft.teams.samples", + "developer": { + "name": "Microsoft", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Task Module", + "full": "Simple Task Module" + }, + "description": { + "short": "Test Task Module Scenario", + "full": "Simple Task Module Scenario Test" + }, + "accentColor": "#FFFFFF", + "bots": [ + { + "botId": "<>", + "scopes": [ + "personal", + "team", + "groupchat" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ] } \ No newline at end of file From e7199ed94df3930d2d3414fbc5834556fe1d89a1 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Apr 2020 08:32:38 -0500 Subject: [PATCH 369/616] BotFrameworkHttpClient is now using MicrosfotGovernmentAppCredentials. --- .../aiohttp/bot_framework_http_client.py | 27 ++++++++++--------- .../microsoft_government_app_credentials.py | 5 +++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 3fa2f448d..cb8d7e3b8 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -20,6 +20,8 @@ CredentialProvider, GovernmentConstants, MicrosoftAppCredentials, + AppCredentials, + MicrosoftGovernmentAppCredentials, ) @@ -147,27 +149,28 @@ async def post_buffered_activity( async def _get_app_credentials( self, app_id: str, oauth_scope: str - ) -> MicrosoftAppCredentials: + ) -> AppCredentials: if not app_id: - return MicrosoftAppCredentials(None, None) + return MicrosoftAppCredentials.empty() + # in the cache? cache_key = f"{app_id}{oauth_scope}" app_credentials = BotFrameworkHttpClient._APP_CREDENTIALS_CACHE.get(cache_key) - if app_credentials: return app_credentials + # create a new AppCredentials app_password = await self._credential_provider.get_app_password(app_id) - app_credentials = MicrosoftAppCredentials( - app_id, app_password, oauth_scope=oauth_scope - ) - if self._channel_provider and self._channel_provider.is_government(): - app_credentials.oauth_endpoint = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL - ) - app_credentials.oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + + app_credentials = ( + MicrosoftGovernmentAppCredentials( + app_id, app_password, scope=oauth_scope ) + if self._credential_provider and self._channel_provider.is_government() + else MicrosoftAppCredentials(app_id, app_password, oauth_scope=oauth_scope) + ) + # put it in the cache BotFrameworkHttpClient._APP_CREDENTIALS_CACHE[cache_key] = app_credentials + return app_credentials diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py index 17403b414..eb59fe941 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py @@ -14,10 +14,13 @@ def __init__( app_id: str, app_password: str, channel_auth_tenant: str = None, - scope: str = GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + scope: str = None, ): super().__init__(app_id, app_password, channel_auth_tenant, scope) self.oauth_endpoint = GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + self.oauth_scope = ( + scope if scope else GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) @staticmethod def empty(): From 1e284a02642659451e9608c44507c399a967316c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Apr 2020 08:55:32 -0500 Subject: [PATCH 370/616] black --- .../integration/aiohttp/bot_framework_http_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index cb8d7e3b8..245af5ac5 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -163,9 +163,7 @@ async def _get_app_credentials( app_password = await self._credential_provider.get_app_password(app_id) app_credentials = ( - MicrosoftGovernmentAppCredentials( - app_id, app_password, scope=oauth_scope - ) + MicrosoftGovernmentAppCredentials(app_id, app_password, scope=oauth_scope) if self._credential_provider and self._channel_provider.is_government() else MicrosoftAppCredentials(app_id, app_password, oauth_scope=oauth_scope) ) From 8847d0c901172e8969f21ad3f1326dfaeb0ba63f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Apr 2020 09:00:29 -0500 Subject: [PATCH 371/616] pylint --- .../botbuilder/integration/aiohttp/bot_framework_http_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 245af5ac5..2604e0d8e 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -18,7 +18,6 @@ from botframework.connector.auth import ( ChannelProvider, CredentialProvider, - GovernmentConstants, MicrosoftAppCredentials, AppCredentials, MicrosoftGovernmentAppCredentials, From df46e753a668dd061306066c0bac8c37ea7b5261 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 10:24:53 -0700 Subject: [PATCH 372/616] Reference doc update - 194592 --- .../botbuilder-dialogs/botbuilder/dialogs/component_dialog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 34c84fbff..f063b4827 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -164,7 +164,6 @@ async def end_dialog( :param context: The context object for this turn. :type context: :class:`botbuilder.core.TurnContext` :param instance: State information associated with the instance of this component dialog. - on its parent's dialog stack. :type instance: :class:`botbuilder.dialogs.DialogInstance` :param reason: Reason why the dialog ended. :type reason: :class:`botbuilder.dialogs.DialogReason` From 257f43092d9f283467273b3ad35507fd6213da38 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 10:43:43 -0700 Subject: [PATCH 373/616] Update dialog_reason.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py index 471646e84..4383ab0d4 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -13,8 +13,7 @@ class DialogReason(Enum): :vartype ContinueCalled: int :var EndCalled: A dialog ended normally through a call to :meth:`DialogContext.end_dialog() :vartype EndCalled: int - :var ReplaceCalled: A dialog is ending because it's being replaced through a call to - :meth:``DialogContext.replace_dialog()`. + :var ReplaceCalled: A dialog is ending and replaced through a call to :meth:``DialogContext.replace_dialog()`. :vartype ReplacedCalled: int :var CancelCalled: A dialog was cancelled as part of a call to :meth:`DialogContext.cancel_all_dialogs()`. :vartype CancelCalled: int From 301c17b6c23c6e630823dedfe9385a5ed353f995 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 12:07:06 -0700 Subject: [PATCH 374/616] Update dialog_state.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index a00c78701..8201225e5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -15,7 +15,7 @@ def __init__(self, stack: List[DialogInstance] = None): Initializes a new instance of the :class:`DialogState` class. :param stack: The state information to initialize the stack with. - :type stack: :class:`typing.List[:class:`DialogInstance`]` + :type stack: :class:`typing.List` """ if stack is None: self._dialog_stack = [] @@ -28,7 +28,7 @@ def dialog_stack(self): Initializes a new instance of the :class:`DialogState` class. :return: The state information to initialize the stack with. - :rtype: :class:`typing.List[:class:`DialogInstance`]` + :rtype: :class:`typing.List` """ return self._dialog_stack From 86fee3be442cdab3850fb681d7044bd402e4e3fd Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 13:13:02 -0700 Subject: [PATCH 375/616] Update waterfall_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index bced214fb..2dc4df3f9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -32,6 +32,7 @@ def __init__(self, dialog_id: str, steps: [Coroutine] = None): def add_step(self, step): """ Adds a new step to the waterfall. + :param step: Step to add :return: Waterfall dialog for fluent calls to `add_step()`. """ From 1502f134c49c22a43ebd85a6087e3808eaf0c88d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 13:21:25 -0700 Subject: [PATCH 376/616] Revert "Update waterfall_dialog.py" This reverts commit 86fee3be442cdab3850fb681d7044bd402e4e3fd. --- .../botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 2dc4df3f9..bced214fb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -32,7 +32,6 @@ def __init__(self, dialog_id: str, steps: [Coroutine] = None): def add_step(self, step): """ Adds a new step to the waterfall. - :param step: Step to add :return: Waterfall dialog for fluent calls to `add_step()`. """ From ca8f0b104bf50334ee481c6059545f152bf22097 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 13:27:20 -0700 Subject: [PATCH 377/616] Update dialog_set.py --- libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index d6870128a..1285e21b3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -37,6 +37,7 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): def add(self, dialog: Dialog): """ Adds a new dialog to the set and returns the added dialog. + :param dialog: The dialog to add. """ if dialog is None or not isinstance(dialog, Dialog): @@ -71,6 +72,7 @@ async def create_context(self, turn_context: TurnContext) -> DialogContext: async def find(self, dialog_id: str) -> Dialog: """ Finds a dialog that was previously added to the set using add(dialog) + :param dialog_id: ID of the dialog/prompt to look up. :return: The dialog if found, otherwise null. """ From ea682076ac021970e8be2566c573afbaf49ab204 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 13:56:31 -0700 Subject: [PATCH 378/616] Revert "Update dialog_set.py" This reverts commit ca8f0b104bf50334ee481c6059545f152bf22097. --- libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index 1285e21b3..d6870128a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -37,7 +37,6 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): def add(self, dialog: Dialog): """ Adds a new dialog to the set and returns the added dialog. - :param dialog: The dialog to add. """ if dialog is None or not isinstance(dialog, Dialog): @@ -72,7 +71,6 @@ async def create_context(self, turn_context: TurnContext) -> DialogContext: async def find(self, dialog_id: str) -> Dialog: """ Finds a dialog that was previously added to the set using add(dialog) - :param dialog_id: ID of the dialog/prompt to look up. :return: The dialog if found, otherwise null. """ From e77b3cca474228cd51ca76cdbfd53e57a158893c Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 14:12:46 -0700 Subject: [PATCH 379/616] Update prompt.py --- .../botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 268ede9ab..b385d2bc9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -211,8 +211,7 @@ async def on_prompt( :type turn_context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:`Dict` - :param options: A prompt options object constructed from the options initially provided - in the call :meth:`DialogContext.prompt()` + :param options: A prompt options object constructed from:meth:`DialogContext.prompt()` :type options: :class:`PromptOptions` :param is_retry: true if is the first time the user for input; otherwise, false :type is_retry: bool @@ -235,8 +234,7 @@ async def on_recognize( :type turn_context: :class:`botbuilder.core.TurnContext` :param state: Contains state for the current instance of the prompt on the dialog stack :type state: :class:`Dict` - :param options: A prompt options object constructed from the options initially provided - in the call to :meth:`DialogContext.prompt()` + :param options: A prompt options object constructed from :meth:`DialogContext.prompt()` :type options: :class:`PromptOptions` :return: A task representing the asynchronous operation. From cf7395f740e6e20eb322f377324a0c1f530f4560 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 14:22:37 -0700 Subject: [PATCH 380/616] Update waterfall_dialog.py --- .../botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index bced214fb..2dc4df3f9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -32,6 +32,7 @@ def __init__(self, dialog_id: str, steps: [Coroutine] = None): def add_step(self, step): """ Adds a new step to the waterfall. + :param step: Step to add :return: Waterfall dialog for fluent calls to `add_step()`. """ From 0908138db04be743e3d32243c8683824e20bfe98 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 14:44:33 -0700 Subject: [PATCH 381/616] Revert "Update waterfall_dialog.py" This reverts commit cf7395f740e6e20eb322f377324a0c1f530f4560. --- .../botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 2dc4df3f9..bced214fb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -32,7 +32,6 @@ def __init__(self, dialog_id: str, steps: [Coroutine] = None): def add_step(self, step): """ Adds a new step to the waterfall. - :param step: Step to add :return: Waterfall dialog for fluent calls to `add_step()`. """ From 821e7f5eb933dbf44a7a485266e9973028a8c1a2 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 14:44:42 -0700 Subject: [PATCH 382/616] Update dialog_context.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_context.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 2862acfb0..33321ef54 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -64,9 +64,9 @@ def active_dialog(self): return None async def begin_dialog(self, dialog_id: str, options: object = None): - """ - Pushes a new dialog onto the dialog stack. - :param dialog_id: ID of the dialog to start.. + """Pushes a new dialog onto the dialog stack. + + :param dialog_id: ID of the dialog to start. :param options: (Optional) additional argument(s) to pass to the dialog being started. """ if not dialog_id: From 895758092b0f605c481e9e4ee4151883bb54235b Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 14:55:29 -0700 Subject: [PATCH 383/616] Revert "Update dialog_context.py" This reverts commit 821e7f5eb933dbf44a7a485266e9973028a8c1a2. --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_context.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 33321ef54..2862acfb0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -64,9 +64,9 @@ def active_dialog(self): return None async def begin_dialog(self, dialog_id: str, options: object = None): - """Pushes a new dialog onto the dialog stack. - - :param dialog_id: ID of the dialog to start. + """ + Pushes a new dialog onto the dialog stack. + :param dialog_id: ID of the dialog to start.. :param options: (Optional) additional argument(s) to pass to the dialog being started. """ if not dialog_id: From d5a79014e95666f54d078044d2fedeea22a07ba1 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 15:07:32 -0700 Subject: [PATCH 384/616] Update dialog_context.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 2862acfb0..b081cdea5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -66,7 +66,7 @@ def active_dialog(self): async def begin_dialog(self, dialog_id: str, options: object = None): """ Pushes a new dialog onto the dialog stack. - :param dialog_id: ID of the dialog to start.. + :param dialog_id: ID of the dialog to start :param options: (Optional) additional argument(s) to pass to the dialog being started. """ if not dialog_id: From b266ab8ce85bc5e6fab3d35a601a0bac1d78d6bc Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 15:14:23 -0700 Subject: [PATCH 385/616] Update dialog_context.py --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index b081cdea5..b47274af4 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -64,8 +64,8 @@ def active_dialog(self): return None async def begin_dialog(self, dialog_id: str, options: object = None): - """ - Pushes a new dialog onto the dialog stack. + """Pushes a new dialog onto the dialog stack. + :param dialog_id: ID of the dialog to start :param options: (Optional) additional argument(s) to pass to the dialog being started. """ From 5e58c259475ef6a0f844d8c7c2b585c27d7641e5 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 1 Apr 2020 15:29:56 -0700 Subject: [PATCH 386/616] Revert "Update dialog_context.py" This reverts commit b266ab8ce85bc5e6fab3d35a601a0bac1d78d6bc. --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index b47274af4..b081cdea5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -64,8 +64,8 @@ def active_dialog(self): return None async def begin_dialog(self, dialog_id: str, options: object = None): - """Pushes a new dialog onto the dialog stack. - + """ + Pushes a new dialog onto the dialog stack. :param dialog_id: ID of the dialog to start :param options: (Optional) additional argument(s) to pass to the dialog being started. """ From eeca8715bd1cf4e08a3871c419d2075d19622234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 8 Apr 2020 11:30:47 -0700 Subject: [PATCH 387/616] fix for creating response with empty content in bfhttpadapter (#886) Co-authored-by: tracyboehrer --- .../integration/aiohttp/bot_framework_http_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 3fa2f448d..7b1c02bd0 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -115,8 +115,7 @@ async def post_activity( data = (await resp.read()).decode() content = json.loads(data) if data else None - if content: - return InvokeResponse(status=resp.status, body=content) + return InvokeResponse(status=resp.status, body=content) finally: # Restore activity properties. From 065542832b5d87172e5a143ce82908ce6e4e9c90 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 10 Apr 2020 17:01:37 -0700 Subject: [PATCH 388/616] Updating badges in rst --- libraries/botbuilder-ai/README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/README.rst b/libraries/botbuilder-ai/README.rst index aef9094cc..c4a4269b9 100644 --- a/libraries/botbuilder-ai/README.rst +++ b/libraries/botbuilder-ai/README.rst @@ -3,8 +3,8 @@ BotBuilder-AI SDK for Python ============================ -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botbuilder-ai.svg From 8b58d1d0106fd20a360f042928e2b2622d862cd3 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 13 Apr 2020 08:39:58 -0500 Subject: [PATCH 389/616] Added "Ocp-Apim-Subscription-Key" --- .../botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py index 47290c491..c1d0035e5 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py @@ -80,6 +80,7 @@ def _get_headers(self, endpoint: QnAMakerEndpoint): "Content-Type": "application/json", "User-Agent": self._get_user_agent(), "Authorization": f"EndpointKey {endpoint.endpoint_key}", + "Ocp-Apim-Subscription-Key": f"EndpointKey {endpoint.endpoint_key}", } return headers From e200c1f2b798157be7ccbdda8d57df90bce67ec4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 16 Apr 2020 09:53:50 -0500 Subject: [PATCH 390/616] Updates for CallerId --- .../botbuilder/core/bot_framework_adapter.py | 20 +++--- .../botbuilder/core/skills/skill_handler.py | 5 ++ .../tests/skills/test_skill_handler.py | 66 +++++++++++++++++-- .../tests/test_bot_framework_adapter.py | 4 ++ .../aiohttp/bot_framework_http_client.py | 6 -- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 7edf9da2e..8066d6928 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -440,14 +440,18 @@ async def process_activity_with_identity( context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = identity context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = logic - # To create the correct cache key, provide the OAuthScope when calling CreateConnectorClientAsync. - # The OAuthScope is also stored on the TurnState to get the correct AppCredentials if fetching a token - # is required. - scope = ( - self.__get_botframework_oauth_scope() - if not SkillValidation.is_skill_claim(identity.claims) - else JwtTokenValidation.get_app_id_from_claims(identity.claims) - ) + # To create the correct cache key, provide the OAuthScope when calling 'create_connector_client' + if not SkillValidation.is_skill_claim(identity.claims): + scope = self.__get_botframework_oauth_scope() + else: + # For activities received from another bot, the appropriate audience is obtained from the claims. + scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) + + # For skill calls we set the caller ID property in the activity based on the appId in the claims. + activity.caller_id = f"urn:botframework:aadappid:{scope}" + + # The OAuthScope is also stored on the TurnState to get the correct AppCredentials if fetching + # a token is required. context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = scope client = await self.create_connector_client( diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 0aabdc9f3..f87ecfbcb 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -17,6 +17,7 @@ ClaimsIdentity, CredentialProvider, GovernmentConstants, + JwtTokenValidation, ) from .skill_conversation_reference import SkillConversationReference from .conversation_id_factory import ConversationIdFactoryBase @@ -156,6 +157,10 @@ async def callback(context: TurnContext): SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY ] = activity_conversation_reference TurnContext.apply_conversation_reference(activity, conversation_reference) + + app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + activity.caller_id = f"urn:botframework:aadappid:{app_id}" + context.activity.id = reply_to_activity_id if activity.type == ActivityTypes.end_of_conversation: diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index cbe61d0d0..e6699fb47 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -57,7 +57,7 @@ async def get_conversation_reference( return conversation_reference async def delete_conversation_reference(self, skill_conversation_id: str): - raise NotImplementedError() + pass class SkillHandlerInstanceForTests(SkillHandler): @@ -165,15 +165,15 @@ async def test_on_upload_attachment( class TestSkillHandler(aiounittest.AsyncTestCase): @classmethod def setUpClass(cls): - bot_id = str(uuid4()) - skill_id = str(uuid4()) + cls.bot_id = str(uuid4()) + cls.skill_id = str(uuid4()) cls._test_id_factory = ConversationIdFactoryForTest() cls._claims_identity = ClaimsIdentity({}, False) - cls._claims_identity.claims[AuthenticationConstants.AUDIENCE_CLAIM] = bot_id - cls._claims_identity.claims[AuthenticationConstants.APP_ID_CLAIM] = skill_id + cls._claims_identity.claims[AuthenticationConstants.AUDIENCE_CLAIM] = cls.bot_id + cls._claims_identity.claims[AuthenticationConstants.APP_ID_CLAIM] = cls.skill_id cls._claims_identity.claims[ AuthenticationConstants.SERVICE_URL_CLAIM ] = "http://testbot.com/api/messages" @@ -183,9 +183,13 @@ def setUpClass(cls): ) def create_skill_handler_for_testing(self, adapter) -> SkillHandlerInstanceForTests: + mock_bot = Mock() + mock_bot.on_turn = MagicMock(return_value=Future()) + mock_bot.on_turn.return_value.set_result(Mock()) + return SkillHandlerInstanceForTests( adapter, - Mock(), + mock_bot, self._test_id_factory, Mock(), AuthenticationConfiguration(), @@ -199,12 +203,16 @@ async def test_on_send_to_conversation(self): mock_adapter = Mock() mock_adapter.continue_conversation = MagicMock(return_value=Future()) mock_adapter.continue_conversation.return_value.set_result(Mock()) + mock_adapter.send_activities = MagicMock(return_value=Future()) + mock_adapter.send_activities.return_value.set_result([]) sut = self.create_skill_handler_for_testing(mock_adapter) activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) TurnContext.apply_conversation_reference(activity, self._conversation_reference) + assert not activity.caller_id + await sut.test_on_send_to_conversation( self._claims_identity, self._conversation_id, activity ) @@ -215,6 +223,9 @@ async def test_on_send_to_conversation(self): assert callable(args[1]) assert isinstance(kwargs["claims_identity"], ClaimsIdentity) + await args[1](TurnContext(mock_adapter, activity)) + assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + async def test_on_reply_to_activity(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( self._conversation_reference @@ -223,6 +234,8 @@ async def test_on_reply_to_activity(self): mock_adapter = Mock() mock_adapter.continue_conversation = MagicMock(return_value=Future()) mock_adapter.continue_conversation.return_value.set_result(Mock()) + mock_adapter.send_activities = MagicMock(return_value=Future()) + mock_adapter.send_activities.return_value.set_result([]) sut = self.create_skill_handler_for_testing(mock_adapter) @@ -240,6 +253,9 @@ async def test_on_reply_to_activity(self): assert callable(args[1]) assert isinstance(kwargs["claims_identity"], ClaimsIdentity) + await args[1](TurnContext(mock_adapter, activity)) + assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + async def test_on_update_activity(self): self._conversation_id = "" @@ -366,3 +382,41 @@ async def test_on_upload_attachment(self): await sut.test_on_upload_attachment( self._claims_identity, self._conversation_id, attachment_data ) + + async def test_event_activity(self): + activity = Activity(type=ActivityTypes.event) + await self.__activity_callback_test(activity) + assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + + async def test_eoc_activity(self): + activity = Activity(type=ActivityTypes.end_of_conversation) + await self.__activity_callback_test(activity) + assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + + async def __activity_callback_test(self, activity: Activity): + self._conversation_id = await self._test_id_factory.create_skill_conversation_id( + self._conversation_reference + ) + + mock_adapter = Mock() + mock_adapter.continue_conversation = MagicMock(return_value=Future()) + mock_adapter.continue_conversation.return_value.set_result(Mock()) + mock_adapter.send_activities = MagicMock(return_value=Future()) + mock_adapter.send_activities.return_value.set_result([]) + + sut = self.create_skill_handler_for_testing(mock_adapter) + + activity_id = str(uuid4()) + TurnContext.apply_conversation_reference(activity, self._conversation_reference) + + await sut.test_on_reply_to_activity( + self._claims_identity, self._conversation_id, activity_id, activity + ) + + args, kwargs = mock_adapter.continue_conversation.call_args_list[0] + + assert isinstance(args[0], ConversationReference) + assert callable(args[1]) + assert isinstance(kwargs["claims_identity"], ClaimsIdentity) + + await args[1](TurnContext(mock_adapter, activity)) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 891ecdeb5..8a5ba3523 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -409,6 +409,7 @@ async def callback(context: TurnContext): scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope + assert not context.activity.caller_id settings = BotFrameworkAdapterSettings(bot_app_id) sut = BotFrameworkAdapter(settings) @@ -442,6 +443,9 @@ async def callback(context: TurnContext): scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] assert bot_app_id == scope + assert ( + context.activity.caller_id == f"urn:botframework:aadappid:{bot_app_id}" + ) settings = BotFrameworkAdapterSettings(bot_app_id) sut = BotFrameworkAdapter(settings) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 7b1c02bd0..09ccef309 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -70,15 +70,11 @@ async def post_activity( ) # Capture current activity settings before changing them. - # TODO: DO we need to set the activity ID? (events that are created manually don't have it). original_conversation_id = activity.conversation.id original_service_url = activity.service_url - original_caller_id = activity.caller_id original_relates_to = activity.relates_to try: - # TODO: The relato has to be ported to the adapter in the new integration library when - # resolving conflicts in merge activity.relates_to = ConversationReference( service_url=activity.service_url, activity_id=activity.id, @@ -97,7 +93,6 @@ async def post_activity( ) activity.conversation.id = conversation_id activity.service_url = service_url - activity.caller_id = f"urn:botframework:aadappid:{from_bot_id}" headers_dict = { "Content-type": "application/json; charset=utf-8", @@ -121,7 +116,6 @@ async def post_activity( # Restore activity properties. activity.conversation.id = original_conversation_id activity.service_url = original_service_url - activity.caller_id = original_caller_id activity.relates_to = original_relates_to async def post_buffered_activity( From 9fed14f74434048836a302a91aa649063a29e8e9 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 16 Apr 2020 10:29:27 -0500 Subject: [PATCH 391/616] Teams tenantId correction (#959) --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 2 +- libraries/botbuilder-core/tests/test_bot_framework_adapter.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 7edf9da2e..e80489c9d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -351,7 +351,7 @@ async def create_conversation( if reference.conversation and reference.conversation.tenant_id: # Putting tenant_id in channel_data is a temporary while we wait for the Teams API to be updated parameters.channel_data = { - "tenant": {"id": reference.conversation.tenant_id} + "tenant": {"tenantId": reference.conversation.tenant_id} } # Permanent solution is to put tenant_id in parameters.tenant_id diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 891ecdeb5..1007be5ee 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -303,7 +303,7 @@ async def aux_func_assert_valid_conversation(context): "request has invalid tenant_id on conversation", ) self.assertEqual( - context.activity.channel_data["tenant"]["id"], + context.activity.channel_data["tenant"]["tenantId"], tenant_id, "request has invalid tenant_id in channel_data", ) From 27aa2a778e1c7a786e737a074a33827a0d431028 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 16 Apr 2020 10:38:09 -0500 Subject: [PATCH 392/616] Fix errors when From is null in telemetry (#924) * Fix errors when From is null in telemetry #3436 * black Co-authored-by: Eric Dahlvang --- .../botbuilder/core/adapters/test_adapter.py | 14 ++++++ .../core/telemetry_logger_middleware.py | 12 +++-- .../tests/test_telemetry_middleware.py | 50 +++++++++++++++++-- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 95048695f..f8f02fcc3 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -7,6 +7,7 @@ import asyncio import inspect +import uuid from datetime import datetime from uuid import uuid4 from typing import Awaitable, Coroutine, Dict, List, Callable, Union @@ -215,6 +216,19 @@ async def continue_conversation( reference, callback, bot_id, claims_identity, audience ) + async def create_conversation( + self, channel_id: str, callback: Callable # pylint: disable=unused-argument + ): + self.activity_buffer.clear() + update = Activity( + type=ActivityTypes.conversation_update, + members_added=[], + members_removed=[], + conversation=ConversationAccount(id=str(uuid.uuid4())), + ) + context = TurnContext(self, update) + return await callback(context) + async def receive_activity(self, activity): """ INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. diff --git a/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py b/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py index 251bf7fb7..f1539f48c 100644 --- a/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py @@ -160,15 +160,21 @@ async def fill_receive_event_properties( BotTelemetryClient.track_event method for the BotMessageReceived event. """ properties = { - TelemetryConstants.FROM_ID_PROPERTY: activity.from_property.id, + TelemetryConstants.FROM_ID_PROPERTY: activity.from_property.id + if activity.from_property + else None, TelemetryConstants.CONVERSATION_NAME_PROPERTY: activity.conversation.name, TelemetryConstants.LOCALE_PROPERTY: activity.locale, TelemetryConstants.RECIPIENT_ID_PROPERTY: activity.recipient.id, - TelemetryConstants.RECIPIENT_NAME_PROPERTY: activity.from_property.name, + TelemetryConstants.RECIPIENT_NAME_PROPERTY: activity.recipient.name, } if self.log_personal_information: - if activity.from_property.name and activity.from_property.name.strip(): + if ( + activity.from_property + and activity.from_property.name + and activity.from_property.name.strip() + ): properties[ TelemetryConstants.FROM_NAME_PROPERTY ] = activity.from_property.name diff --git a/libraries/botbuilder-core/tests/test_telemetry_middleware.py b/libraries/botbuilder-core/tests/test_telemetry_middleware.py index 7128dab8d..eca0c0fcf 100644 --- a/libraries/botbuilder-core/tests/test_telemetry_middleware.py +++ b/libraries/botbuilder-core/tests/test_telemetry_middleware.py @@ -3,6 +3,7 @@ # pylint: disable=line-too-long,missing-docstring,unused-variable import copy +import uuid from typing import Dict from unittest.mock import Mock import aiounittest @@ -28,6 +29,47 @@ async def test_create_middleware(self): my_logger = TelemetryLoggerMiddleware(telemetry, True) assert my_logger + async def test_do_not_throw_on_null_from(self): + telemetry = Mock() + my_logger = TelemetryLoggerMiddleware(telemetry, False) + + adapter = TestAdapter( + template_or_conversation=Activity( + channel_id="test", + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount(id=str(uuid.uuid4())), + ) + ) + adapter.use(my_logger) + + async def send_proactive(context: TurnContext): + await context.send_activity("proactive") + + async def logic(context: TurnContext): + await adapter.create_conversation( + context.activity.channel_id, send_proactive, + ) + + adapter.logic = logic + + test_flow = TestFlow(None, adapter) + await test_flow.send("foo") + await test_flow.assert_reply("proactive") + + telemetry_calls = [ + ( + TelemetryLoggerConstants.BOT_MSG_RECEIVE_EVENT, + { + "fromId": None, + "conversationName": None, + "locale": None, + "recipientId": "bot", + "recipientName": "Bot", + }, + ), + ] + self.assert_telemetry_calls(telemetry, telemetry_calls) + async def test_should_send_receive(self): telemetry = Mock() my_logger = TelemetryLoggerMiddleware(telemetry, True) @@ -55,7 +97,7 @@ async def logic(context: TurnContext): "conversationName": None, "locale": None, "recipientId": "bot", - "recipientName": "user", + "recipientName": "Bot", }, ), ( @@ -76,7 +118,7 @@ async def logic(context: TurnContext): "conversationName": None, "locale": None, "recipientId": "bot", - "recipientName": "user", + "recipientName": "Bot", "fromName": "user", }, ), @@ -147,7 +189,7 @@ async def process(context: TurnContext) -> None: "conversationName": None, "locale": None, "recipientId": "bot", - "recipientName": "user", + "recipientName": "Bot", }, ), ( @@ -169,7 +211,7 @@ async def process(context: TurnContext) -> None: "conversationName": None, "locale": None, "recipientId": "bot", - "recipientName": "user", + "recipientName": "Bot", "fromName": "user", }, ), From 4dbc68073aca5338c570f6af204c6d8dae6c5b3c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 16 Apr 2020 15:37:05 -0500 Subject: [PATCH 393/616] Added need default value to ObjectPath.get_path_value --- .../botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py index ef2314177..9b51b90c7 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -395,6 +395,7 @@ async def __check_for_multiturn_prompt(self, step_context: WaterfallStepContext) previous_context_data = ObjectPath.get_path_value( step_context.active_dialog.state, QnAMakerDialog.KEY_QNA_CONTEXT_DATA, + {} ) for prompt in answer.context.prompts: previous_context_data[prompt.display_text] = prompt.qna_id From 912ea5f582fd238688f3413ab1c0056c1ddac2db Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 16 Apr 2020 15:40:54 -0500 Subject: [PATCH 394/616] Added another missed default in call to ObjectPath.get_path_value --- .../botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py index 9b51b90c7..adc421d56 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -445,7 +445,7 @@ async def __display_qna_result(self, step_context: WaterfallStepContext): # If previous QnAId is present, replace the dialog previous_qna_id = ObjectPath.get_path_value( - step_context.active_dialog.state, QnAMakerDialog.KEY_PREVIOUS_QNA_ID + step_context.active_dialog.state, QnAMakerDialog.KEY_PREVIOUS_QNA_ID, 0 ) if previous_qna_id > 0: return await super().run_step( From f5392c918a70029a000a4d1af1f62764efe26e99 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 16 Apr 2020 15:57:06 -0500 Subject: [PATCH 395/616] black --- .../botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py index adc421d56..0254dcd5e 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -395,7 +395,7 @@ async def __check_for_multiturn_prompt(self, step_context: WaterfallStepContext) previous_context_data = ObjectPath.get_path_value( step_context.active_dialog.state, QnAMakerDialog.KEY_QNA_CONTEXT_DATA, - {} + {}, ) for prompt in answer.context.prompts: previous_context_data[prompt.display_text] = prompt.qna_id From 800f742200b024b34e26a8056f5ff65c768d8772 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 08:25:06 -0500 Subject: [PATCH 396/616] Results were not being deserialized correctly. --- .../ai/qna/models/feedback_record.py | 20 ++----- .../ai/qna/models/feedback_records.py | 16 +---- .../models/generate_answer_request_body.py | 58 +++---------------- .../botbuilder/ai/qna/models/metadata.py | 16 +---- .../botbuilder/ai/qna/models/prompt.py | 33 ++--------- .../ai/qna/models/qna_request_context.py | 18 +----- .../ai/qna/models/qna_response_context.py | 10 +--- .../botbuilder/ai/qna/models/query_result.py | 52 ++++------------- .../ai/qna/models/train_request_body.py | 15 +---- .../ai/qna/utils/generate_answer_utils.py | 4 +- 10 files changed, 42 insertions(+), 200 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_record.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_record.py index 74a78d5d0..9b9b1b4ce 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_record.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_record.py @@ -13,20 +13,8 @@ class FeedbackRecord(Model): "qna_id": {"key": "qnaId", "type": "int"}, } - def __init__(self, user_id: str, user_question: str, qna_id: int, **kwargs): - """ - Parameters: - ----------- - - user_id: ID of the user. - - user_question: User question. - - qna_id: QnA ID. - """ - + def __init__(self, **kwargs): super().__init__(**kwargs) - - self.user_id = user_id - self.user_question = user_question - self.qna_id = qna_id + self.user_id = kwargs.get("user_id", None) + self.user_question = kwargs.get("user_question", None) + self.qna_id = kwargs.get("qna_id", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_records.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_records.py index 62f3983c4..97f9dc776 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_records.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/feedback_records.py @@ -1,26 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - from msrest.serialization import Model -from .feedback_record import FeedbackRecord - class FeedbackRecords(Model): """ Active learning feedback records. """ _attribute_map = {"records": {"key": "records", "type": "[FeedbackRecord]"}} - def __init__(self, records: List[FeedbackRecord], **kwargs): - """ - Parameter(s): - ------------- - - records: List of feedback records. - """ - + def __init__(self, **kwargs): super().__init__(**kwargs) - - self.records = records + self.records = kwargs.get("records", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py index 20162a08f..4a4e9fdd7 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py @@ -1,14 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List - from msrest.serialization import Model -from .metadata import Metadata -from .qna_request_context import QnARequestContext -from .ranker_types import RankerTypes - class GenerateAnswerRequestBody(Model): """ Question used as the payload body for QnA Maker's Generate Answer API. """ @@ -24,47 +18,13 @@ class GenerateAnswerRequestBody(Model): "ranker_type": {"key": "rankerType", "type": "RankerTypes"}, } - def __init__( - self, - question: str, - top: int, - score_threshold: float, - strict_filters: List[Metadata], - context: QnARequestContext = None, - qna_id: int = None, - is_test: bool = False, - ranker_type: str = RankerTypes.DEFAULT, - **kwargs - ): - """ - Parameters: - ----------- - - question: The user question to query against the knowledge base. - - top: Max number of answers to be returned for the question. - - score_threshold: Threshold for answers returned based on score. - - strict_filters: Find answers that contains these metadata. - - context: The context from which the QnA was extracted. - - qna_id: Id of the current question asked. - - is_test: (Optional) A value indicating whether to call test or prod environment of knowledgebase. - - ranker_types: (Optional) Ranker types. - - """ - + def __init__(self, **kwargs): super().__init__(**kwargs) - - self.question = question - self.top = top - self.score_threshold = score_threshold - self.strict_filters = strict_filters - self.context = context - self.qna_id = qna_id - self.is_test = is_test - self.ranker_type = ranker_type + self.question = kwargs.get("question", None) + self.top = kwargs.get("top", None) + self.score_threshold = kwargs.get("score_threshold", None) + self.strict_filters = kwargs.get("strict_filters", None) + self.context = kwargs.get("context", None) + self.qna_id = kwargs.get("qna_id", None) + self.is_test = kwargs.get("is_test", None) + self.ranker_type = kwargs.get("ranker_type", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/metadata.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/metadata.py index af0f1f00b..60f52f18a 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/metadata.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/metadata.py @@ -12,17 +12,7 @@ class Metadata(Model): "value": {"key": "value", "type": "str"}, } - def __init__(self, name: str, value: str, **kwargs): - """ - Parameters: - ----------- - - name: Metadata name. Max length: 100. - - value: Metadata value. Max length: 100. - """ - + def __init__(self, **kwargs): super().__init__(**kwargs) - - self.name = name - self.value = value + self.name = kwargs.get("name", None) + self.value = kwargs.get("value", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py index d7f090c87..0865c2d22 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py @@ -14,32 +14,9 @@ class Prompt(Model): "display_text": {"key": "displayText", "type": "str"}, } - def __init__( - self, - *, - display_order: int, - qna_id: int, - display_text: str, - qna: object = None, - **kwargs - ): - """ - Parameters: - ----------- - - display_order: Index of the prompt - used in ordering of the prompts. - - qna_id: QnA ID. - - display_text: Text displayed to represent a follow up question prompt. - - qna: The QnA object returned from the API (Optional). - - """ - + def __init__(self, **kwargs): super(Prompt, self).__init__(**kwargs) - - self.display_order = display_order - self.qna_id = qna_id - self.display_text = display_text - self.qna = qna + self.display_order = kwargs.get("display_order", None) + self.qna_id = kwargs.get("qna_id", None) + self.display_text = kwargs.get("display_text", None) + self.qna = kwargs.get("qna", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py index dcac807a1..ff85afc99 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_request_context.py @@ -15,19 +15,7 @@ class QnARequestContext(Model): "previous_user_query": {"key": "previousUserQuery", "type": "string"}, } - def __init__( - self, previous_qna_id: int = None, previous_user_query: str = None, **kwargs - ): - """ - Parameters: - ----------- - - previous_qna_id: The previous QnA Id that was returned. - - previous_user_query: The previous user query/question. - """ - + def __init__(self, **kwargs): super().__init__(**kwargs) - - self.previous_qna_id = previous_qna_id - self.previous_user_query = previous_user_query + self.previous_qna_id = kwargs.get("previous_qna_id", None) + self.previous_user_query = kwargs.get("previous_user_query", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py index 537bf09db..e3814cca9 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py @@ -1,9 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List from msrest.serialization import Model -from .prompt import Prompt class QnAResponseContext(Model): @@ -17,9 +15,7 @@ class QnAResponseContext(Model): "prompts": {"key": "prompts", "type": "[Prompt]"}, } - def __init__( - self, *, is_context_only: bool = False, prompts: List[Prompt] = None, **kwargs - ): + def __init__(self, **kwargs): """ Parameters: ----------- @@ -31,5 +27,5 @@ def __init__( """ super(QnAResponseContext, self).__init__(**kwargs) - self.is_context_only = is_context_only - self.prompts = prompts + self.is_context_only = kwargs.get("is_context_only", None) + self.prompts = kwargs.get("prompts", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py index 321ea64cf..f91febf5f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py @@ -1,10 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List from msrest.serialization import Model -from .metadata import Metadata -from .qna_response_context import QnAResponseContext class QueryResult(Model): @@ -14,47 +11,18 @@ class QueryResult(Model): "questions": {"key": "questions", "type": "[str]"}, "answer": {"key": "answer", "type": "str"}, "score": {"key": "score", "type": "float"}, - "metadata": {"key": "metadata", "type": "object"}, + "metadata": {"key": "metadata", "type": "[Metadata]"}, "source": {"key": "source", "type": "str"}, "id": {"key": "id", "type": "int"}, - "context": {"key": "context", "type": "object"}, + "context": {"key": "context", "type": "QnAResponseContext"}, } - def __init__( - self, - *, - questions: List[str], - answer: str, - score: float, - metadata: object = None, - source: str = None, - id: int = None, # pylint: disable=invalid-name - context: QnAResponseContext = None, - **kwargs - ): - """ - Parameters: - ----------- - - questions: The list of questions indexed in the QnA Service for the given answer (if any). - - answer: Answer from the knowledge base. - - score: Confidence on a scale from 0.0 to 1.0 that the answer matches the user's intent. - - metadata: Metadata associated with the answer (if any). - - source: The source from which the QnA was extracted (if any). - - id: The index of the answer in the knowledge base. V3 uses 'qnaId', V4 uses 'id' (if any). - - context: The context from which the QnA was extracted. - """ + def __init__(self, **kwargs): super(QueryResult, self).__init__(**kwargs) - self.questions = questions - self.answer = answer - self.score = score - self.metadata = list(map(lambda meta: Metadata(**meta), metadata)) - self.source = source - self.context = QnAResponseContext(**context) if context is not None else None - self.id = id # pylint: disable=invalid-name + self.questions = kwargs.get("questions", None) + self.answer = kwargs.get("answer", None) + self.score = kwargs.get("score", None) + self.metadata = kwargs.get("metadata", None) + self.source = kwargs.get("source", None) + self.context = kwargs.get("context", None) + self.id = kwargs.get("id", None) # pylint: disable=invalid-name diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/train_request_body.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/train_request_body.py index 2ce267831..252f8ae81 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/train_request_body.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/train_request_body.py @@ -1,11 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List from msrest.serialization import Model -from .feedback_record import FeedbackRecord - class TrainRequestBody(Model): """ Class the models the request body that is sent as feedback to the Train API. """ @@ -14,14 +11,6 @@ class TrainRequestBody(Model): "feedback_records": {"key": "feedbackRecords", "type": "[FeedbackRecord]"} } - def __init__(self, feedback_records: List[FeedbackRecord], **kwargs): - """ - Parameters: - ----------- - - feedback_records: List of feedback records. - """ - + def __init__(self, **kwargs): super().__init__(**kwargs) - - self.feedback_records = feedback_records + self.feedback_records = kwargs.get("feedback_records", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py index 3852f1365..c5158cb65 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py @@ -212,9 +212,7 @@ async def _format_qna_result( answers_within_threshold, key=lambda ans: ans["score"], reverse=True ) - answers_as_query_results = list( - map(lambda answer: QueryResult(**answer), sorted_answers) - ) + answers_as_query_results = [QueryResult().deserialize(answer) for answer in sorted_answers] active_learning_enabled = ( json_res["activeLearningEnabled"] From 33a2b82ef5175e7c916bb7fc4e62fd1984d1b57e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 09:47:22 -0500 Subject: [PATCH 397/616] CallerId v2 --- .../botbuilder/core/bot_framework_adapter.py | 43 ++++-- .../botbuilder/core/skills/skill_handler.py | 4 +- .../tests/skills/test_skill_handler.py | 9 +- .../tests/test_bot_framework_adapter.py | 126 ++++++++++++------ .../botbuilder/schema/__init__.py | 2 + .../botbuilder/schema/callerid_constants.py | 25 ++++ 6 files changed, 154 insertions(+), 55 deletions(-) create mode 100644 libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 8066d6928..28b95a4a8 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -48,6 +48,7 @@ TokenResponse, ResourceResponse, DeliveryModes, + CallerIdConstants, ) from . import __version__ @@ -437,21 +438,18 @@ async def process_activity_with_identity( self, activity: Activity, identity: ClaimsIdentity, logic: Callable ): context = self._create_context(activity) + + activity.caller_id = await self.__generate_callerid(identity) context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = identity context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = logic - # To create the correct cache key, provide the OAuthScope when calling 'create_connector_client' - if not SkillValidation.is_skill_claim(identity.claims): - scope = self.__get_botframework_oauth_scope() - else: - # For activities received from another bot, the appropriate audience is obtained from the claims. - scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) - - # For skill calls we set the caller ID property in the activity based on the appId in the claims. - activity.caller_id = f"urn:botframework:aadappid:{scope}" - # The OAuthScope is also stored on the TurnState to get the correct AppCredentials if fetching # a token is required. + scope = ( + JwtTokenValidation.get_app_id_from_claims(identity.claims) + if SkillValidation.is_skill_claim(identity.claims) + else self.__get_botframework_oauth_scope() + ) context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = scope client = await self.create_connector_client( @@ -497,6 +495,29 @@ async def process_activity_with_identity( return None + async def __generate_callerid(self, claims_identity: ClaimsIdentity) -> str: + # Is the bot accepting all incoming messages? + is_auth_disabled = await self._credential_provider.is_authentication_disabled() + if is_auth_disabled: + # Return None so that the callerId is cleared. + return None + + # Is the activity from another bot? + if SkillValidation.is_skill_claim(claims_identity.claims): + app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + return f"{CallerIdConstants.bot_to_bot_prefix}{app_id}" + + # Is the activity from Public Azure? + if not self._channel_provider or self._channel_provider.is_public_azure(): + return CallerIdConstants.public_azure_channel + + # Is the activity from Azure Gov? + if self._channel_provider and self._channel_provider.is_government(): + return CallerIdConstants.us_gov_channel + + # Return None so that the callerId is cleared. + return None + async def _authenticate_request( self, request: Activity, auth_header: str ) -> ClaimsIdentity: @@ -1225,7 +1246,7 @@ async def exchange_token_from_credentials( @staticmethod def key_for_connector_client(service_url: str, app_id: str, scope: str): - return f"{service_url}:{app_id}:{scope}" + return f"{service_url if service_url else ''}:{app_id if app_id else ''}:{scope if scope else ''}" async def _create_token_api_client( self, context: TurnContext, oauth_app_credentials: AppCredentials = None, diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index f87ecfbcb..b5f350754 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -9,6 +9,7 @@ ActivityTypes, ConversationReference, ResourceResponse, + CallerIdConstants, ) from botframework.connector.auth import ( AuthenticationConfiguration, @@ -125,7 +126,6 @@ async def _process_activity( conversation_id ) - oauth_scope = None conversation_reference = None if isinstance(conversation_reference_result, SkillConversationReference): oauth_scope = conversation_reference_result.oauth_scope @@ -159,7 +159,7 @@ async def callback(context: TurnContext): TurnContext.apply_conversation_reference(activity, conversation_reference) app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) - activity.caller_id = f"urn:botframework:aadappid:{app_id}" + activity.caller_id = f"{CallerIdConstants.bot_to_bot_prefix}{app_id}" context.activity.id = reply_to_activity_id diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index e6699fb47..093430763 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -22,6 +22,7 @@ PagedMembersResult, ResourceResponse, Transcript, + CallerIdConstants, ) from botframework.connector.auth import ( AuthenticationConfiguration, @@ -224,7 +225,7 @@ async def test_on_send_to_conversation(self): assert isinstance(kwargs["claims_identity"], ClaimsIdentity) await args[1](TurnContext(mock_adapter, activity)) - assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" async def test_on_reply_to_activity(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( @@ -254,7 +255,7 @@ async def test_on_reply_to_activity(self): assert isinstance(kwargs["claims_identity"], ClaimsIdentity) await args[1](TurnContext(mock_adapter, activity)) - assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" async def test_on_update_activity(self): self._conversation_id = "" @@ -386,12 +387,12 @@ async def test_on_upload_attachment(self): async def test_event_activity(self): activity = Activity(type=ActivityTypes.event) await self.__activity_callback_test(activity) - assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" async def test_eoc_activity(self): activity = Activity(type=ActivityTypes.end_of_conversation) await self.__activity_callback_test(activity) - assert activity.caller_id == f"urn:botframework:aadappid:{self.skill_id}" + assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" async def __activity_callback_test(self, activity: Activity): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 8a5ba3523..796f16bee 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -21,6 +21,7 @@ ChannelAccount, DeliveryModes, ExpectedReplies, + CallerIdConstants, ) from botframework.connector.aio import ConnectorClient from botframework.connector.auth import ( @@ -28,6 +29,9 @@ AuthenticationConstants, AppCredentials, CredentialProvider, + SimpleChannelProvider, + GovernmentConstants, + SimpleCredentialProvider, ) REFERENCE = ConversationReference( @@ -318,22 +322,23 @@ def get_creds_and_assert_values( turn_context: TurnContext, expected_app_id: str, expected_scope: str, - creds_count: int = None, + creds_count: int, ): - # pylint: disable=protected-access - credential_cache = turn_context.adapter._app_credential_map - cache_key = BotFrameworkAdapter.key_for_app_credentials( - expected_app_id, expected_scope - ) - credentials = credential_cache.get(cache_key) - assert credentials + if creds_count > 0: + # pylint: disable=protected-access + credential_cache = turn_context.adapter._app_credential_map + cache_key = BotFrameworkAdapter.key_for_app_credentials( + expected_app_id, expected_scope + ) + credentials = credential_cache.get(cache_key) + assert credentials - TestBotFrameworkAdapter.assert_credentials_values( - credentials, expected_app_id, expected_scope - ) + TestBotFrameworkAdapter.assert_credentials_values( + credentials, expected_app_id, expected_scope + ) - if creds_count: - assert creds_count == len(credential_cache) + if creds_count: + assert creds_count == len(credential_cache) @staticmethod def get_client_and_assert_values( @@ -341,22 +346,21 @@ def get_client_and_assert_values( expected_app_id: str, expected_scope: str, expected_url: str, - client_count: int = None, + client_count: int, ): # pylint: disable=protected-access client_cache = turn_context.adapter._connector_client_cache cache_key = BotFrameworkAdapter.key_for_connector_client( expected_url, expected_app_id, expected_scope ) - client = client_cache[cache_key] + client = client_cache.get(cache_key) assert client TestBotFrameworkAdapter.assert_connectorclient_vaules( client, expected_app_id, expected_url, expected_scope ) - if client_count: - assert client_count == len(client_cache) + assert client_count == len(client_cache) @staticmethod def assert_connectorclient_vaules( @@ -366,9 +370,17 @@ def assert_connectorclient_vaules( expected_scope=AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, ): creds = client.config.credentials - assert expected_app_id == creds.microsoft_app_id - assert expected_scope == creds.oauth_scope - assert expected_service_url == client.config.base_url + assert TestBotFrameworkAdapter.__str_equal( + expected_app_id, creds.microsoft_app_id + ) + assert TestBotFrameworkAdapter.__str_equal(expected_scope, creds.oauth_scope) + assert TestBotFrameworkAdapter.__str_equal( + expected_service_url, client.config.base_url + ) + + @staticmethod + def __str_equal(str1: str, str2: str) -> bool: + return (str1 if str1 is not None else "") == (str2 if str2 is not None else "") @staticmethod def assert_credentials_values( @@ -379,39 +391,77 @@ def assert_credentials_values( assert expected_app_id == credentials.microsoft_app_id assert expected_scope == credentials.oauth_scope - async def test_process_activity_creates_correct_creds_and_client(self): - bot_app_id = "00000000-0000-0000-0000-000000000001" - identity = ClaimsIdentity( - claims={ + async def test_process_activity_creates_correct_creds_and_client_channel_to_bot( + self, + ): + await self.__process_activity_creates_correct_creds_and_client( + None, + None, + None, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 0, + 1, + ) + + async def test_process_activity_creates_correct_creds_and_client_public_azure(self): + await self.__process_activity_creates_correct_creds_and_client( + "00000000-0000-0000-0000-000000000001", + CallerIdConstants.public_azure_channel, + None, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + 1, + ) + + async def test_process_activity_creates_correct_creds_and_client_us_gov(self): + await self.__process_activity_creates_correct_creds_and_client( + "00000000-0000-0000-0000-000000000001", + CallerIdConstants.us_gov_channel, + GovernmentConstants.CHANNEL_SERVICE, + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + 1, + ) + + async def __process_activity_creates_correct_creds_and_client( + self, + bot_app_id: str, + expected_caller_id: str, + channel_service: str, + expected_scope: str, + expected_app_credentials_count: int, + expected_client_credentials_count: int, + ): + identity = ClaimsIdentity({}, True) + if bot_app_id: + identity.claims = { AuthenticationConstants.AUDIENCE_CLAIM: bot_app_id, AuthenticationConstants.APP_ID_CLAIM: bot_app_id, AuthenticationConstants.VERSION_CLAIM: "1.0", - }, - is_authenticated=True, - ) + } + credential_provider = SimpleCredentialProvider(bot_app_id, None) service_url = "https://smba.trafficmanager.net/amer/" async def callback(context: TurnContext): TestBotFrameworkAdapter.get_creds_and_assert_values( - context, - bot_app_id, - AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, - 1, + context, bot_app_id, expected_scope, expected_app_credentials_count, ) TestBotFrameworkAdapter.get_client_and_assert_values( context, bot_app_id, - AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + expected_scope, service_url, - 1, + expected_client_credentials_count, ) - scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] - assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope - assert not context.activity.caller_id + assert context.activity.caller_id == expected_caller_id - settings = BotFrameworkAdapterSettings(bot_app_id) + settings = BotFrameworkAdapterSettings( + bot_app_id, + credential_provider=credential_provider, + channel_provider=SimpleChannelProvider(channel_service), + ) sut = BotFrameworkAdapter(settings) await sut.process_activity_with_identity( Activity(channel_id="emulator", service_url=service_url, text="test",), @@ -444,7 +494,7 @@ async def callback(context: TurnContext): scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] assert bot_app_id == scope assert ( - context.activity.caller_id == f"urn:botframework:aadappid:{bot_app_id}" + context.activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{bot_app_id}" ) settings = BotFrameworkAdapterSettings(bot_app_id) diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index 78a35caa5..bab8a9444 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -124,6 +124,7 @@ ) from ._sign_in_enums import SignInConstants +from .callerid_constants import CallerIdConstants __all__ = [ "Activity", @@ -190,4 +191,5 @@ "DeliveryModes", "ContactRelationUpdateActionTypes", "InstallationUpdateActionTypes", + "CallerIdConstants", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py b/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py new file mode 100644 index 000000000..7954a5213 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py @@ -0,0 +1,25 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from enum import Enum + + +class CallerIdConstants(str, Enum): + public_azure_channel = "urn:botframework:azure" + """ + The caller ID for any Bot Framework channel. + """ + + us_gov_channel = "urn:botframework:azureusgov" + """ + The caller ID for any Bot Framework US Government cloud channel. + """ + + bot_to_bot_prefix = "urn:botframework:aadappid:" + """ + The caller ID prefix when a bot initiates a request to another bot. + This prefix will be followed by the Azure Active Directory App ID of the bot that initiated the call. + """ From 41acd026ddcf52829a290d435996375389e39137 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 09:52:57 -0500 Subject: [PATCH 398/616] Updated QnA tests for model changes --- libraries/botbuilder-ai/tests/qna/test_qna.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index e56111577..2d6313d64 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -109,7 +109,7 @@ def test_options_passed_to_ctor(self): score_threshold=0.8, timeout=9000, top=5, - strict_filters=[Metadata("movie", "disney")], + strict_filters=[Metadata(**{"movie": "disney"})], ) qna_with_options = QnAMaker(self.tests_endpoint, options) @@ -118,7 +118,7 @@ def test_options_passed_to_ctor(self): expected_threshold = 0.8 expected_timeout = 9000 expected_top = 5 - expected_strict_filters = [Metadata("movie", "disney")] + expected_strict_filters = [Metadata(**{"movie": "disney"})] self.assertEqual(expected_threshold, actual_options.score_threshold) self.assertEqual(expected_timeout, actual_options.timeout) @@ -168,7 +168,7 @@ async def test_returns_answer_using_options(self): question: str = "up" response_path: str = "AnswerWithOptions.json" options = QnAMakerOptions( - score_threshold=0.8, top=5, strict_filters=[Metadata("movie", "disney")] + score_threshold=0.8, top=5, strict_filters=[Metadata(**{"movie": "disney"})] ) # Act From aab4b4e888d012a6fe640b35a059c0e655739117 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 09:54:13 -0500 Subject: [PATCH 399/616] black --- .../tests/skills/test_skill_handler.py | 20 +++++++++++++++---- .../tests/test_bot_framework_adapter.py | 3 ++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 093430763..16f98be35 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -225,7 +225,10 @@ async def test_on_send_to_conversation(self): assert isinstance(kwargs["claims_identity"], ClaimsIdentity) await args[1](TurnContext(mock_adapter, activity)) - assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + assert ( + activity.caller_id + == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ) async def test_on_reply_to_activity(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( @@ -255,7 +258,10 @@ async def test_on_reply_to_activity(self): assert isinstance(kwargs["claims_identity"], ClaimsIdentity) await args[1](TurnContext(mock_adapter, activity)) - assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + assert ( + activity.caller_id + == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ) async def test_on_update_activity(self): self._conversation_id = "" @@ -387,12 +393,18 @@ async def test_on_upload_attachment(self): async def test_event_activity(self): activity = Activity(type=ActivityTypes.event) await self.__activity_callback_test(activity) - assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + assert ( + activity.caller_id + == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ) async def test_eoc_activity(self): activity = Activity(type=ActivityTypes.end_of_conversation) await self.__activity_callback_test(activity) - assert activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + assert ( + activity.caller_id + == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ) async def __activity_callback_test(self, activity: Activity): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 796f16bee..d78ae111f 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -494,7 +494,8 @@ async def callback(context: TurnContext): scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] assert bot_app_id == scope assert ( - context.activity.caller_id == f"{CallerIdConstants.bot_to_bot_prefix}{bot_app_id}" + context.activity.caller_id + == f"{CallerIdConstants.bot_to_bot_prefix}{bot_app_id}" ) settings = BotFrameworkAdapterSettings(bot_app_id) From a1ece418fca26a883493bd1fd6452fab24b70139 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 11:55:24 -0500 Subject: [PATCH 400/616] black --- .../botbuilder/ai/qna/utils/generate_answer_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py index c5158cb65..aaed7fbca 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py @@ -212,7 +212,9 @@ async def _format_qna_result( answers_within_threshold, key=lambda ans: ans["score"], reverse=True ) - answers_as_query_results = [QueryResult().deserialize(answer) for answer in sorted_answers] + answers_as_query_results = [ + QueryResult().deserialize(answer) for answer in sorted_answers + ] active_learning_enabled = ( json_res["activeLearningEnabled"] From 5f9cfa03cb02e5ab56e23a71384c488018154cce Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 13:05:20 -0500 Subject: [PATCH 401/616] Allow sending public certificate for CertificateAppCredentials (x5c) --- .../auth/certificate_app_credentials.py | 17 +++++++++++++++++ .../botframework-connector/requirements.txt | 2 +- libraries/botframework-connector/setup.py | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py index 298bb581f..a458ce5bb 100644 --- a/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/certificate_app_credentials.py @@ -23,7 +23,20 @@ def __init__( certificate_private_key: str, channel_auth_tenant: str = None, oauth_scope: str = None, + certificate_public: str = None, ): + """ + AppCredentials implementation using a certificate. + + :param app_id: + :param certificate_thumbprint: + :param certificate_private_key: + :param channel_auth_tenant: + :param oauth_scope: + :param certificate_public: public_certificate (optional) is public key certificate which will be sent + through ‘x5c’ JWT header only for subject name and issuer authentication to support cert auto rolls. + """ + # super will set proper scope and endpoint. super().__init__( app_id=app_id, @@ -35,6 +48,7 @@ def __init__( self.app = None self.certificate_thumbprint = certificate_thumbprint self.certificate_private_key = certificate_private_key + self.certificate_public = certificate_public def get_access_token(self, force_refresh: bool = False) -> str: """ @@ -63,6 +77,9 @@ def __get_msal_app(self): client_credential={ "thumbprint": self.certificate_thumbprint, "private_key": self.certificate_private_key, + "public_certificate": self.certificate_public + if self.certificate_public + else None, }, ) diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 7358f97e1..10b0b51a4 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -3,4 +3,4 @@ botbuilder-schema>=4.7.1 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8.0 -msal==1.1.0 +msal==1.2.0 diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 537d6f60b..792c5d374 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -12,7 +12,7 @@ "PyJWT==1.5.3", "botbuilder-schema>=4.7.1", "adal==1.2.1", - "msal==1.1.0", + "msal==1.2.0", ] root = os.path.abspath(os.path.dirname(__file__)) From 5db04a7584f3ccc4dabaae1c89b799d38b4ddcdf Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 14:05:59 -0500 Subject: [PATCH 402/616] Added AppBasedQuery state property. (#963) --- .../botbuilder/schema/teams/_models_py3.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 181cbd367..9cea699cd 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -39,15 +39,19 @@ class AppBasedLinkQuery(Model): :param url: Url queried by user :type url: str + :param state: The magic code for OAuth Flow + :type state: str """ _attribute_map = { "url": {"key": "url", "type": "str"}, + "state": {"key": "state", "type": "str"}, } - def __init__(self, *, url: str = None, **kwargs) -> None: + def __init__(self, *, url: str = None, state: str = None, **kwargs) -> None: super(AppBasedLinkQuery, self).__init__(**kwargs) self.url = url + self.state = state class ChannelInfo(Model): From b08c5e0dd1e3264a116b7663b591a154acc30d85 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 17 Apr 2020 14:56:14 -0500 Subject: [PATCH 403/616] SkillHandler CallerId updates --- .../botbuilder/core/skills/skill_handler.py | 8 +++-- .../tests/skills/test_skill_handler.py | 30 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index b5f350754..7eb5e2da5 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -156,12 +156,14 @@ async def callback(context: TurnContext): context.turn_state[ SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY ] = activity_conversation_reference + TurnContext.apply_conversation_reference(activity, conversation_reference) + context.activity.id = reply_to_activity_id app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) - activity.caller_id = f"{CallerIdConstants.bot_to_bot_prefix}{app_id}" - - context.activity.id = reply_to_activity_id + context.activity.caller_id = ( + f"{CallerIdConstants.bot_to_bot_prefix}{app_id}" + ) if activity.type == ActivityTypes.end_of_conversation: await self._conversation_id_factory.delete_conversation_reference( diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 16f98be35..6fd9e1225 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -7,7 +7,11 @@ from unittest.mock import Mock, MagicMock import aiounittest -from botbuilder.core import TurnContext, BotActionNotImplementedError +from botbuilder.core import ( + TurnContext, + BotActionNotImplementedError, + conversation_reference_extension, +) from botbuilder.core.skills import ConversationIdFactoryBase, SkillHandler from botbuilder.schema import ( Activity, @@ -224,11 +228,15 @@ async def test_on_send_to_conversation(self): assert callable(args[1]) assert isinstance(kwargs["claims_identity"], ClaimsIdentity) - await args[1](TurnContext(mock_adapter, activity)) - assert ( - activity.caller_id - == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + await args[1]( + TurnContext( + mock_adapter, + conversation_reference_extension.get_continuation_activity( + self._conversation_reference + ), + ) ) + assert activity.caller_id is None async def test_on_reply_to_activity(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( @@ -257,11 +265,15 @@ async def test_on_reply_to_activity(self): assert callable(args[1]) assert isinstance(kwargs["claims_identity"], ClaimsIdentity) - await args[1](TurnContext(mock_adapter, activity)) - assert ( - activity.caller_id - == f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + await args[1]( + TurnContext( + mock_adapter, + conversation_reference_extension.get_continuation_activity( + self._conversation_reference + ), + ) ) + assert activity.caller_id is None async def test_on_update_activity(self): self._conversation_id = "" From 534ac7aeb6099a1872073b8c879c46d80d66c654 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 17 Apr 2020 18:41:10 -0700 Subject: [PATCH 404/616] follow up rst updates --- libraries/botbuilder-adapters-slack/README.rst | 14 +++++++------- .../botbuilder-applicationinsights/README.rst | 4 ++-- libraries/botbuilder-azure/README.rst | 8 ++++---- libraries/botbuilder-core/README.rst | 4 ++-- libraries/botbuilder-dialogs/README.rst | 4 ++-- .../botbuilder-integration-aiohttp/README.rst | 8 ++++---- .../README.rst | 10 +++++----- libraries/botbuilder-schema/README.rst | 4 ++-- libraries/botbuilder-testing/README.rst | 4 ++-- libraries/botframework-connector/README.rst | 4 ++-- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/README.rst b/libraries/botbuilder-adapters-slack/README.rst index a3813c8b3..9465f3997 100644 --- a/libraries/botbuilder-adapters-slack/README.rst +++ b/libraries/botbuilder-adapters-slack/README.rst @@ -1,14 +1,14 @@ -================================= +================================== BotBuilder-Adapters SDK for Python -================================= +================================== -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch -.. image:: https://badge.fury.io/py/botbuilder-dialogs.svg - :target: https://badge.fury.io/py/botbuilder-dialogs +.. image:: https://badge.fury.io/py/botbuilder-adapters-slack.svg + :target: https://badge.fury.io/py/botbuilder-adapters-slack :alt: Latest PyPI package version A dialog stack based conversation manager for Microsoft BotBuilder. @@ -18,7 +18,7 @@ How to Install .. code-block:: python - pip install botbuilder-dialogs + pip install botbuilder-adapters-slack Documentation/Wiki diff --git a/libraries/botbuilder-applicationinsights/README.rst b/libraries/botbuilder-applicationinsights/README.rst index 43f6046da..6e5c9c0df 100644 --- a/libraries/botbuilder-applicationinsights/README.rst +++ b/libraries/botbuilder-applicationinsights/README.rst @@ -3,8 +3,8 @@ BotBuilder-ApplicationInsights SDK for Python ============================================= -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botbuilder-applicationinsights.svg diff --git a/libraries/botbuilder-azure/README.rst b/libraries/botbuilder-azure/README.rst index 04af9dacc..ab937de70 100644 --- a/libraries/botbuilder-azure/README.rst +++ b/libraries/botbuilder-azure/README.rst @@ -3,12 +3,12 @@ BotBuilder-Azure SDK for Python =============================== -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch -.. image:: https://badge.fury.io/py/botbuilder-dialogs.svg - :target: https://badge.fury.io/py/botbuilder-dialogs +.. image:: https://badge.fury.io/py/botbuilder-azure.svg + :target: https://badge.fury.io/py/botbuilder-azure :alt: Latest PyPI package version Azure extensions for Microsoft BotBuilder. diff --git a/libraries/botbuilder-core/README.rst b/libraries/botbuilder-core/README.rst index 2fc7b5fe5..7cfe8e3f0 100644 --- a/libraries/botbuilder-core/README.rst +++ b/libraries/botbuilder-core/README.rst @@ -3,8 +3,8 @@ BotBuilder-Core SDK for Python ============================== -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botbuilder-core.svg diff --git a/libraries/botbuilder-dialogs/README.rst b/libraries/botbuilder-dialogs/README.rst index 6c8208769..f76dc1983 100644 --- a/libraries/botbuilder-dialogs/README.rst +++ b/libraries/botbuilder-dialogs/README.rst @@ -3,8 +3,8 @@ BotBuilder-Dialogs SDK for Python ================================= -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botbuilder-dialogs.svg diff --git a/libraries/botbuilder-integration-aiohttp/README.rst b/libraries/botbuilder-integration-aiohttp/README.rst index f92429436..eb32dd702 100644 --- a/libraries/botbuilder-integration-aiohttp/README.rst +++ b/libraries/botbuilder-integration-aiohttp/README.rst @@ -3,12 +3,12 @@ BotBuilder-Integration-Aiohttp for Python ========================================= -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch -.. image:: https://badge.fury.io/py/botbuilder-core.svg - :target: https://badge.fury.io/py/botbuilder-core +.. image:: https://badge.fury.io/py/botbuilder-integration-aiohttp.svg + :target: https://badge.fury.io/py/botbuilder-integration-aiohttp :alt: Latest PyPI package version Within the Bot Framework, This library enables you to integrate your bot within an aiohttp web application. diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst b/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst index 8479d7ea1..1b2e58f0f 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/README.rst @@ -3,12 +3,12 @@ BotBuilder-ApplicationInsights SDK extension for aiohttp ======================================================== -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch -.. image:: https://badge.fury.io/py/botbuilder-applicationinsights.svg - :target: https://badge.fury.io/py/botbuilder-applicationinsights +.. image:: https://badge.fury.io/py/botbuilder-integration-applicationinsights-aiohttp.svg + :target: https://badge.fury.io/py/botbuilder-integration-applicationinsights-aiohttp :alt: Latest PyPI package version Within the Bot Framework, BotBuilder-ApplicationInsights enables the Azure Application Insights service. @@ -22,7 +22,7 @@ How to Install .. code-block:: python - pip install botbuilder-applicationinsights-aiohttp + pip install botbuilder-integration-applicationinsights-aiohttp Documentation/Wiki diff --git a/libraries/botbuilder-schema/README.rst b/libraries/botbuilder-schema/README.rst index abf3ae738..ff3eac173 100644 --- a/libraries/botbuilder-schema/README.rst +++ b/libraries/botbuilder-schema/README.rst @@ -3,8 +3,8 @@ BotBuilder-Schema ================= -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botbuilder-schema.svg diff --git a/libraries/botbuilder-testing/README.rst b/libraries/botbuilder-testing/README.rst index 128ee8a8a..10ded9adb 100644 --- a/libraries/botbuilder-testing/README.rst +++ b/libraries/botbuilder-testing/README.rst @@ -3,8 +3,8 @@ BotBuilder-Testing SDK for Python ================================= -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botbuilder-testing.svg diff --git a/libraries/botframework-connector/README.rst b/libraries/botframework-connector/README.rst index aec6b1e92..d19a47bba 100644 --- a/libraries/botframework-connector/README.rst +++ b/libraries/botframework-connector/README.rst @@ -3,8 +3,8 @@ Microsoft Bot Framework Connector for Python ============================================ -.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master - :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI +.. image:: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master + :target: https://dev.azure.com/FuseLabs/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master :align: right :alt: Azure DevOps status for master branch .. image:: https://badge.fury.io/py/botframework-connector.svg From 1b3542b8ba1994e228501b42bfa7d88332b608c4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 21 Apr 2020 17:04:58 -0500 Subject: [PATCH 405/616] Setting default Recipient on BotFrameworkHttpClient.post_activity (#976) --- .../aiohttp/bot_framework_http_client.py | 42 ++++++++----- .../tests/test_bot_framework_http_client.py | 63 +++++++++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 97879f1f4..877e5db24 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -14,6 +14,7 @@ ExpectedReplies, ConversationReference, ConversationAccount, + ChannelAccount, ) from botframework.connector.auth import ( ChannelProvider, @@ -74,6 +75,7 @@ async def post_activity( original_conversation_id = activity.conversation.id original_service_url = activity.service_url original_relates_to = activity.relates_to + original_recipient = activity.recipient try: activity.relates_to = ConversationReference( @@ -94,30 +96,38 @@ async def post_activity( ) activity.conversation.id = conversation_id activity.service_url = service_url + if not activity.recipient: + activity.recipient = ChannelAccount() - headers_dict = { - "Content-type": "application/json; charset=utf-8", - } - if token: - headers_dict.update( - {"Authorization": f"Bearer {token}",} - ) - - json_content = json.dumps(activity.serialize()) - resp = await self._session.post( - to_url, data=json_content.encode("utf-8"), headers=headers_dict, - ) - resp.raise_for_status() - data = (await resp.read()).decode() - content = json.loads(data) if data else None + status, content = await self._post_content(to_url, token, activity) - return InvokeResponse(status=resp.status, body=content) + return InvokeResponse(status=status, body=content) finally: # Restore activity properties. activity.conversation.id = original_conversation_id activity.service_url = original_service_url activity.relates_to = original_relates_to + activity.recipient = original_recipient + + async def _post_content( + self, to_url: str, token: str, activity: Activity + ) -> (int, object): + headers_dict = { + "Content-type": "application/json; charset=utf-8", + } + if token: + headers_dict.update( + {"Authorization": f"Bearer {token}",} + ) + + json_content = json.dumps(activity.serialize()) + resp = await self._session.post( + to_url, data=json_content.encode("utf-8"), headers=headers_dict, + ) + resp.raise_for_status() + data = (await resp.read()).decode() + return resp.status, json.loads(data) if data else None async def post_buffered_activity( self, diff --git a/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py index 7e01f390b..0197b29b5 100644 --- a/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py @@ -1,8 +1,71 @@ +from unittest.mock import Mock + import aiounittest +from botbuilder.schema import ConversationAccount, ChannelAccount from botbuilder.integration.aiohttp import BotFrameworkHttpClient +from botframework.connector.auth import CredentialProvider, Activity class TestBotFrameworkHttpClient(aiounittest.AsyncTestCase): async def test_should_create_connector_client(self): with self.assertRaises(TypeError): BotFrameworkHttpClient(None) + + async def test_adds_recipient_and_sets_it_back_to_null(self): + mock_credential_provider = Mock(spec=CredentialProvider) + + # pylint: disable=unused-argument + async def _mock_post_content( + to_url: str, token: str, activity: Activity + ) -> (int, object): + nonlocal self + self.assertIsNotNone(activity.recipient) + return 200, None + + client = BotFrameworkHttpClient(credential_provider=mock_credential_provider) + client._post_content = _mock_post_content # pylint: disable=protected-access + + activity = Activity(conversation=ConversationAccount()) + + await client.post_activity( + None, + None, + "https://skillbot.com/api/messages", + "https://parentbot.com/api/messages", + "NewConversationId", + activity, + ) + + assert activity.recipient is None + + async def test_does_not_overwrite_non_null_recipient_values(self): + skill_recipient_id = "skillBot" + mock_credential_provider = Mock(spec=CredentialProvider) + + # pylint: disable=unused-argument + async def _mock_post_content( + to_url: str, token: str, activity: Activity + ) -> (int, object): + nonlocal self + self.assertIsNotNone(activity.recipient) + self.assertEqual(skill_recipient_id, activity.recipient.id) + return 200, None + + client = BotFrameworkHttpClient(credential_provider=mock_credential_provider) + client._post_content = _mock_post_content # pylint: disable=protected-access + + activity = Activity( + conversation=ConversationAccount(), + recipient=ChannelAccount(id=skill_recipient_id), + ) + + await client.post_activity( + None, + None, + "https://skillbot.com/api/messages", + "https://parentbot.com/api/messages", + "NewConversationId", + activity, + ) + + assert activity.recipient.id == skill_recipient_id From 9bf70067cd86aa4b272ddf3e89c672bb01fda7ff Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 21 Apr 2020 22:22:26 -0700 Subject: [PATCH 406/616] Fix'NoneType' object has no attribute 'is_government' --- .../botbuilder/integration/aiohttp/bot_framework_http_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 877e5db24..eac0ecaa4 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -166,7 +166,7 @@ async def _get_app_credentials( app_credentials = ( MicrosoftGovernmentAppCredentials(app_id, app_password, scope=oauth_scope) - if self._credential_provider and self._channel_provider.is_government() + if self._channel_provider and self._channel_provider.is_government() else MicrosoftAppCredentials(app_id, app_password, oauth_scope=oauth_scope) ) From 84537c18cb0a44729079587b2034bc2d09c5379c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 22 Apr 2020 00:29:52 -0500 Subject: [PATCH 407/616] Updated run_dialog to avoid sending EoC from the RootBot to the channel (#952) * Ported DialogExtension changes and unit tests from C# * Added comment to clarify ConversationIdFactor.get_conversation_reference return value handling. Co-authored-by: Eric Dahlvang --- .../botbuilder/core/__init__.py | 4 + .../botbuilder/core/adapter_extensions.py | 58 +++++ .../botbuilder/core/adapters/test_adapter.py | 14 ++ .../core/register_class_middleware.py | 30 +++ .../botbuilder/core/skills/skill_handler.py | 49 ++-- .../botbuilder/core/transcript_logger.py | 12 +- .../botbuilder/dialogs/dialog_extensions.py | 146 ++++++----- .../tests/test_dialogextensions.py | 227 ++++++++++++++++++ 8 files changed, 452 insertions(+), 88 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/adapter_extensions.py create mode 100644 libraries/botbuilder-core/botbuilder/core/register_class_middleware.py create mode 100644 libraries/botbuilder-dialogs/tests/test_dialogextensions.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index f9a846ea5..c45484e6f 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -40,9 +40,12 @@ from .turn_context import TurnContext from .user_state import UserState from .user_token_provider import UserTokenProvider +from .register_class_middleware import RegisterClassMiddleware +from .adapter_extensions import AdapterExtensions __all__ = [ "ActivityHandler", + "AdapterExtensions", "AnonymousReceiveMiddleware", "AutoSaveStateMiddleware", "Bot", @@ -69,6 +72,7 @@ "MiddlewareSet", "NullTelemetryClient", "PrivateConversationState", + "RegisterClassMiddleware", "Recognizer", "RecognizerResult", "Severity", diff --git a/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py b/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py new file mode 100644 index 000000000..335394c8d --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botbuilder.core import ( + BotAdapter, + Storage, + RegisterClassMiddleware, + UserState, + ConversationState, + AutoSaveStateMiddleware, +) + + +class AdapterExtensions: + @staticmethod + def use_storage(adapter: BotAdapter, storage: Storage) -> BotAdapter: + """ + Registers a storage layer with the adapter. The storage object will be available via the turn context's + `turn_state` property. + + :param adapter: The BotAdapter on which to register the storage object. + :param storage: The Storage object to register. + :return: The BotAdapter + """ + return adapter.use(RegisterClassMiddleware(storage)) + + @staticmethod + def use_state( + adapter: BotAdapter, + user_state: UserState, + conversation_state: ConversationState, + auto: bool = True, + ) -> BotAdapter: + """ + Registers user and conversation state objects with the adapter. These objects will be available via + the turn context's `turn_state` property. + + :param adapter: The BotAdapter on which to register the state objects. + :param user_state: The UserState object to register. + :param conversation_state: The ConversationState object to register. + :param auto: True to automatically persist state each turn. + :return: The BotAdapter + """ + if not adapter: + raise TypeError("BotAdapter is required") + + if not user_state: + raise TypeError("UserState is required") + + if not conversation_state: + raise TypeError("ConversationState is required") + + adapter.use(RegisterClassMiddleware(user_state)) + adapter.use(RegisterClassMiddleware(conversation_state)) + + if auto: + adapter.use(AutoSaveStateMiddleware([user_state, conversation_state])) + + return adapter diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index f8f02fcc3..8215e70bd 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -301,6 +301,20 @@ async def tests(self, *args): timeout = arg[3] await self.test(arg[0], arg[1], description, timeout) + @staticmethod + def create_conversation_reference( + name: str, user: str = "User1", bot: str = "Bot" + ) -> ConversationReference: + return ConversationReference( + channel_id="test", + service_url="https://test.com", + conversation=ConversationAccount( + is_group=False, conversation_type=name, id=name, + ), + user=ChannelAccount(id=user.lower(), name=user.lower(),), + bot=ChannelAccount(id=bot.lower(), name=bot.lower(),), + ) + def add_user_token( self, connection_name: str, diff --git a/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py b/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py new file mode 100644 index 000000000..332f56077 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Callable, Awaitable + +from botbuilder.core import Middleware, TurnContext + + +class RegisterClassMiddleware(Middleware): + """ + Middleware for adding an object to or registering a service with the current turn context. + """ + + def __init__(self, service): + self.service = service + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + # C# has TurnStateCollection with has overrides for adding items + # to TurnState. Python does not. In C#'s case, there is an 'Add' + # to handle adding object, and that uses the fully qualified class name. + context.turn_state[self.fullname(self.service)] = self.service + await logic() + + @staticmethod + def fullname(obj): + module = obj.__class__.__module__ + if module is None or module == str.__class__.__module__: + return obj.__class__.__name__ # Avoid reporting __builtin__ + return module + "." + obj.__class__.__name__ diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 7eb5e2da5..abbdb0187 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -7,7 +7,6 @@ from botbuilder.schema import ( Activity, ActivityTypes, - ConversationReference, ResourceResponse, CallerIdConstants, ) @@ -122,42 +121,40 @@ async def _process_activity( reply_to_activity_id: str, activity: Activity, ) -> ResourceResponse: + # Get the SkillsConversationReference conversation_reference_result = await self._conversation_id_factory.get_conversation_reference( conversation_id ) - conversation_reference = None + # ConversationIdFactory can return either a SkillConversationReference (the newer way), + # or a ConversationReference (the old way, but still here for compatibility). If a + # ConversationReference is returned, build a new SkillConversationReference to simplify + # the remainder of this method. + skill_conversation_reference: SkillConversationReference = None if isinstance(conversation_reference_result, SkillConversationReference): - oauth_scope = conversation_reference_result.oauth_scope - conversation_reference = ( - conversation_reference_result.conversation_reference - ) + skill_conversation_reference = conversation_reference_result else: - conversation_reference = conversation_reference_result - oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - if self._channel_provider and self._channel_provider.is_government() - else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + skill_conversation_reference = SkillConversationReference( + conversation_reference=conversation_reference_result, + oauth_scope=( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + if self._channel_provider and self._channel_provider.is_government() + else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ), ) - if not conversation_reference: - raise KeyError("ConversationReference not found") - - activity_conversation_reference = ConversationReference( - activity_id=activity.id, - user=activity.from_property, - bot=activity.recipient, - conversation=activity.conversation, - channel_id=activity.channel_id, - service_url=activity.service_url, - ) + if not skill_conversation_reference: + raise KeyError("SkillConversationReference not found") async def callback(context: TurnContext): context.turn_state[ SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY - ] = activity_conversation_reference + ] = skill_conversation_reference + + TurnContext.apply_conversation_reference( + activity, skill_conversation_reference.conversation_reference + ) - TurnContext.apply_conversation_reference(activity, conversation_reference) context.activity.id = reply_to_activity_id app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) @@ -178,10 +175,10 @@ async def callback(context: TurnContext): await context.send_activity(activity) await self._adapter.continue_conversation( - conversation_reference, + skill_conversation_reference.conversation_reference, callback, claims_identity=claims_identity, - audience=oauth_scope, + audience=skill_conversation_reference.oauth_scope, ) return ResourceResponse(id=str(uuid4())) diff --git a/libraries/botbuilder-core/botbuilder/core/transcript_logger.py b/libraries/botbuilder-core/botbuilder/core/transcript_logger.py index a35d18d75..91aba2ac1 100644 --- a/libraries/botbuilder-core/botbuilder/core/transcript_logger.py +++ b/libraries/botbuilder-core/botbuilder/core/transcript_logger.py @@ -48,7 +48,7 @@ async def on_turn( if activity: if not activity.from_property.role: activity.from_property.role = "user" - self.log_activity(transcript, copy.copy(activity)) + await self.log_activity(transcript, copy.copy(activity)) # hook up onSend pipeline # pylint: disable=unused-argument @@ -61,7 +61,7 @@ async def send_activities_handler( responses = await next_send() for index, activity in enumerate(activities): cloned_activity = copy.copy(activity) - if index < len(responses): + if responses and index < len(responses): cloned_activity.id = responses[index].id # For certain channels, a ResourceResponse with an id is not always sent to the bot. @@ -79,7 +79,7 @@ async def send_activities_handler( reference = datetime.datetime.today() delta = (reference - epoch).total_seconds() * 1000 cloned_activity.id = f"{prefix}{delta}" - self.log_activity(transcript, cloned_activity) + await self.log_activity(transcript, cloned_activity) return responses context.on_send_activities(send_activities_handler) @@ -92,7 +92,7 @@ async def update_activity_handler( response = await next_update() update_activity = copy.copy(activity) update_activity.type = ActivityTypes.message_update - self.log_activity(transcript, update_activity) + await self.log_activity(transcript, update_activity) return response context.on_update_activity(update_activity_handler) @@ -112,7 +112,7 @@ async def delete_activity_handler( deleted_activity: Activity = TurnContext.apply_conversation_reference( delete_msg, reference, False ) - self.log_activity(transcript, deleted_activity) + await self.log_activity(transcript, deleted_activity) context.on_delete_activity(delete_activity_handler) @@ -127,7 +127,7 @@ async def delete_activity_handler( await self.logger.log_activity(activity) transcript.task_done() - def log_activity(self, transcript: Queue, activity: Activity) -> None: + async def log_activity(self, transcript: Queue, activity: Activity) -> None: """Logs the activity. :param transcript: transcript. :param activity: Activity to log. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py index 808bf2b1a..090069783 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. from botbuilder.core import BotAdapter, StatePropertyAccessor, TurnContext +from botbuilder.core.skills import SkillHandler, SkillConversationReference + from botbuilder.dialogs import ( Dialog, DialogEvents, @@ -9,7 +11,12 @@ DialogTurnStatus, ) from botbuilder.schema import Activity, ActivityTypes -from botframework.connector.auth import ClaimsIdentity, SkillValidation +from botframework.connector.auth import ( + ClaimsIdentity, + SkillValidation, + AuthenticationConstants, + GovernmentConstants, +) class DialogExtensions: @@ -17,73 +24,100 @@ class DialogExtensions: async def run_dialog( dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor ): + """ + Creates a dialog stack and starts a dialog, pushing it onto the stack. + """ + dialog_set = DialogSet(accessor) dialog_set.add(dialog) dialog_context = await dialog_set.create_context(turn_context) - claims = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) - if isinstance(claims, ClaimsIdentity) and SkillValidation.is_skill_claim( - claims.claims - ): - # The bot is running as a skill. - if ( - turn_context.activity.type == ActivityTypes.end_of_conversation - and dialog_context.stack - and DialogExtensions.__is_eoc_coming_from_parent(turn_context) - ): + # Handle EoC and Reprompt event from a parent bot (can be root bot to skill or skill to skill) + if DialogExtensions.__is_from_parent_to_skill(turn_context): + # Handle remote cancellation request from parent. + if turn_context.activity.type == ActivityTypes.end_of_conversation: + if not dialog_context.stack: + # No dialogs to cancel, just return. + return + remote_cancel_text = "Skill was canceled through an EndOfConversation activity from the parent." await turn_context.send_trace_activity( f"Extension {Dialog.__name__}.run_dialog", label=remote_cancel_text, ) + # Send cancellation message to the dialog to ensure all the parents are canceled + # in the right order. await dialog_context.cancel_all_dialogs() - else: - # Process a reprompt event sent from the parent. - if ( - turn_context.activity.type == ActivityTypes.event - and turn_context.activity.name == DialogEvents.reprompt_dialog - and dialog_context.stack - ): - await dialog_context.reprompt_dialog() + return + + # Handle a reprompt event sent from the parent. + if ( + turn_context.activity.type == ActivityTypes.event + and turn_context.activity.name == DialogEvents.reprompt_dialog + ): + if not dialog_context.stack: + # No dialogs to reprompt, just return. return - # Run the Dialog with the new message Activity and capture the results - # so we can send end of conversation if needed. - result = await dialog_context.continue_dialog() - if result.status == DialogTurnStatus.Empty: - start_message_text = f"Starting {dialog.id}" - await turn_context.send_trace_activity( - f"Extension {Dialog.__name__}.run_dialog", - label=start_message_text, - ) - result = await dialog_context.begin_dialog(dialog.id) - - # Send end of conversation if it is completed or cancelled. - if ( - result.status == DialogTurnStatus.Complete - or result.status == DialogTurnStatus.Cancelled - ): - end_message_text = f"Dialog {dialog.id} has **completed**. Sending EndOfConversation." - await turn_context.send_trace_activity( - f"Extension {Dialog.__name__}.run_dialog", - label=end_message_text, - value=result.result, - ) - - activity = Activity( - type=ActivityTypes.end_of_conversation, value=result.result - ) - await turn_context.send_activity(activity) - - else: - # The bot is running as a standard bot. - results = await dialog_context.continue_dialog() - if results.status == DialogTurnStatus.Empty: - await dialog_context.begin_dialog(dialog.id) + await dialog_context.reprompt_dialog() + return + + # Continue or start the dialog. + result = await dialog_context.continue_dialog() + if result.status == DialogTurnStatus.Empty: + result = await dialog_context.begin_dialog(dialog.id) + + # Skills should send EoC when the dialog completes. + if ( + result.status == DialogTurnStatus.Complete + or result.status == DialogTurnStatus.Cancelled + ): + if DialogExtensions.__send_eoc_to_parent(turn_context): + end_message_text = ( + f"Dialog {dialog.id} has **completed**. Sending EndOfConversation." + ) + await turn_context.send_trace_activity( + f"Extension {Dialog.__name__}.run_dialog", + label=end_message_text, + value=result.result, + ) + + activity = Activity( + type=ActivityTypes.end_of_conversation, value=result.result + ) + await turn_context.send_activity(activity) + + @staticmethod + def __is_from_parent_to_skill(turn_context: TurnContext) -> bool: + if turn_context.turn_state.get(SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY): + return False + + claims_identity = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) + return isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims) @staticmethod - def __is_eoc_coming_from_parent(turn_context: TurnContext) -> bool: - # To determine the direction we check callerId property which is set to the parent bot - # by the BotFrameworkHttpClient on outgoing requests. - return bool(turn_context.activity.caller_id) + def __send_eoc_to_parent(turn_context: TurnContext) -> bool: + claims_identity = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) + if isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims): + # EoC Activities returned by skills are bounced back to the bot by SkillHandler. + # In those cases we will have a SkillConversationReference instance in state. + skill_conversation_reference: SkillConversationReference = turn_context.turn_state.get( + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ) + if skill_conversation_reference: + # If the skillConversationReference.OAuthScope is for one of the supported channels, + # we are at the root and we should not send an EoC. + return ( + skill_conversation_reference.oauth_scope + != AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + and skill_conversation_reference.oauth_scope + != GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + return True + + return False diff --git a/libraries/botbuilder-dialogs/tests/test_dialogextensions.py b/libraries/botbuilder-dialogs/tests/test_dialogextensions.py new file mode 100644 index 000000000..2899b859c --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_dialogextensions.py @@ -0,0 +1,227 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# pylint: disable=ungrouped-imports +import enum +import uuid + +import aiounittest + +from botbuilder.core import ( + TurnContext, + MessageFactory, + MemoryStorage, + ConversationState, + UserState, + AdapterExtensions, + BotAdapter, +) +from botbuilder.core.adapters import ( + TestFlow, + TestAdapter, +) +from botbuilder.core.skills import ( + SkillHandler, + SkillConversationReference, +) +from botbuilder.core.transcript_logger import ( + TranscriptLoggerMiddleware, + ConsoleTranscriptLogger, +) +from botbuilder.schema import ActivityTypes, Activity +from botframework.connector.auth import ClaimsIdentity, AuthenticationConstants +from botbuilder.dialogs import ( + ComponentDialog, + TextPrompt, + WaterfallDialog, + DialogInstance, + DialogReason, + WaterfallStepContext, + PromptOptions, + Dialog, + DialogExtensions, + DialogEvents, +) + + +class SimpleComponentDialog(ComponentDialog): + def __init__(self): + super().__init__("SimpleComponentDialog") + + self.add_dialog(TextPrompt("TextPrompt")) + self.add_dialog( + WaterfallDialog("WaterfallDialog", [self.prompt_for_name, self.final_step]) + ) + + self.initial_dialog_id = "WaterfallDialog" + self.end_reason = DialogReason.BeginCalled + + async def end_dialog( + self, context: TurnContext, instance: DialogInstance, reason: DialogReason + ) -> None: + self.end_reason = reason + return await super().end_dialog(context, instance, reason) + + async def prompt_for_name(self, step_context: WaterfallStepContext): + return await step_context.prompt( + "TextPrompt", + PromptOptions( + prompt=MessageFactory.text("Hello, what is your name?"), + retry_prompt=MessageFactory.text("Hello, what is your name again?"), + ), + ) + + async def final_step(self, step_context: WaterfallStepContext): + await step_context.context.send_activity( + f"Hello {step_context.result}, nice to meet you!" + ) + return await step_context.end_dialog(step_context.result) + + +class FlowTestCase(enum.Enum): + root_bot_only = 1 + root_bot_consuming_skill = 2 + middle_skill = 3 + leaf_skill = 4 + + +class DialogExtensionsTests(aiounittest.AsyncTestCase): + def __init__(self, methodName): + super().__init__(methodName) + self.eoc_sent: Activity = None + self.skill_bot_id = str(uuid.uuid4()) + self.parent_bot_id = str(uuid.uuid4()) + + async def handles_bot_and_skills_test_cases( + self, test_case: FlowTestCase, send_eoc: bool + ): + dialog = SimpleComponentDialog() + + test_flow = self.create_test_flow(dialog, test_case) + + await test_flow.send("Hi") + await test_flow.assert_reply("Hello, what is your name?") + await test_flow.send("SomeName") + await test_flow.assert_reply("Hello SomeName, nice to meet you!") + + assert dialog.end_reason == DialogReason.EndCalled + + if send_eoc: + self.assertIsNotNone( + self.eoc_sent, "Skills should send EndConversation to channel" + ) + assert ActivityTypes.end_of_conversation == self.eoc_sent.type + assert self.eoc_sent.value == "SomeName" + else: + self.assertIsNone( + self.eoc_sent, "Root bot should not send EndConversation to channel" + ) + + async def test_handles_root_bot_only(self): + return await self.handles_bot_and_skills_test_cases( + FlowTestCase.root_bot_only, False + ) + + async def test_handles_root_bot_consuming_skill(self): + return await self.handles_bot_and_skills_test_cases( + FlowTestCase.root_bot_consuming_skill, False + ) + + async def test_handles_middle_skill(self): + return await self.handles_bot_and_skills_test_cases( + FlowTestCase.middle_skill, True + ) + + async def test_handles_leaf_skill(self): + return await self.handles_bot_and_skills_test_cases( + FlowTestCase.leaf_skill, True + ) + + async def test_skill_handles_eoc_from_parent(self): + dialog = SimpleComponentDialog() + test_flow = self.create_test_flow(dialog, FlowTestCase.leaf_skill) + + await test_flow.send("Hi") + await test_flow.assert_reply("Hello, what is your name?") + await test_flow.send( + Activity( + type=ActivityTypes.end_of_conversation, caller_id=self.parent_bot_id, + ) + ) + + self.assertIsNone( + self.eoc_sent, + "Skill should not send back EoC when an EoC is sent from a parent", + ) + assert dialog.end_reason == DialogReason.CancelCalled + + async def test_skill_handles_reprompt_from_parent(self): + dialog = SimpleComponentDialog() + test_flow = self.create_test_flow(dialog, FlowTestCase.leaf_skill) + + await test_flow.send("Hi") + await test_flow.assert_reply("Hello, what is your name?") + await test_flow.send( + Activity( + type=ActivityTypes.event, + caller_id=self.parent_bot_id, + name=DialogEvents.reprompt_dialog, + ) + ) + await test_flow.assert_reply("Hello, what is your name?") + + assert dialog.end_reason == DialogReason.BeginCalled + + def create_test_flow(self, dialog: Dialog, test_case: FlowTestCase) -> TestFlow: + conversation_id = str(uuid.uuid4()) + storage = MemoryStorage() + convo_state = ConversationState(storage) + user_state = UserState(storage) + + async def logic(context: TurnContext): + if test_case != FlowTestCase.root_bot_only: + claims_identity = ClaimsIdentity( + { + AuthenticationConstants.VERSION_CLAIM: "2.0", + AuthenticationConstants.AUDIENCE_CLAIM: self.skill_bot_id, + AuthenticationConstants.AUTHORIZED_PARTY: self.parent_bot_id, + }, + True, + ) + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + + if test_case == FlowTestCase.root_bot_consuming_skill: + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = SkillConversationReference( + None, AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + if test_case == FlowTestCase.middle_skill: + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = SkillConversationReference(None, self.parent_bot_id) + + async def capture_eoc( + inner_context: TurnContext, activities: [], next + ): # pylint: disable=unused-argument + for activity in activities: + if activity.type == ActivityTypes.end_of_conversation: + self.eoc_sent = activity + break + return await next() + + context.on_send_activities(capture_eoc) + + await DialogExtensions.run_dialog( + dialog, context, convo_state.create_property("DialogState") + ) + + adapter = TestAdapter( + logic, TestAdapter.create_conversation_reference(conversation_id) + ) + AdapterExtensions.use_storage(adapter, storage) + AdapterExtensions.use_state(adapter, user_state, convo_state) + adapter.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) + + return TestFlow(None, adapter) From 3f80bf9eecf5b0033be1cca22dacd2499bd207c5 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 22 Apr 2020 10:47:49 -0500 Subject: [PATCH 408/616] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e4347845d..35ddc6df8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ [![roadmap badge](https://img.shields.io/badge/visit%20the-roadmap-blue.svg)](https://github.com/Microsoft/botbuilder-python/wiki/Roadmap) [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Microsoft/botbuilder-python/blob/master/LICENSE) +[![Gitter](https://img.shields.io/gitter/room/Microsoft/BotBuilder.svg)](https://gitter.im/Microsoft/BotBuilder) This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botbuilder). The Bot Framework SDK v4 enables developers to model conversation and build sophisticated bot applications using Python. This Python version is GA and ready for production usage. From 9f0f34a35a526e39f9520dea3f245b10dbf29dbe Mon Sep 17 00:00:00 2001 From: Sergii Gromovyi Date: Fri, 24 Apr 2020 14:16:54 +0300 Subject: [PATCH 409/616] Fix for refreshing open id metadata issue. See the description in https://github.com/microsoft/botbuilder-python/issues/990 --- .../botframework/connector/auth/jwt_token_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py index 4a0850763..529ad00cb 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_extractor.py @@ -126,7 +126,7 @@ def __init__(self, url): async def get(self, key_id: str): # If keys are more than 5 days old, refresh them - if self.last_updated < (datetime.now() + timedelta(days=5)): + if self.last_updated < (datetime.now() - timedelta(days=5)): await self._refresh() return self._find(key_id) From 2e5338f0b02e71c67ccaf1e8f7ec049683a31783 Mon Sep 17 00:00:00 2001 From: Victor Kironde Date: Fri, 24 Apr 2020 22:01:16 +0300 Subject: [PATCH 410/616] match method name in error message --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 718aa2427..f87515c50 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -124,7 +124,7 @@ async def begin_dialog( """ if dialog_context is None: raise TypeError( - f"OAuthPrompt.begin_dialog: Expected DialogContext but got NoneType instead" + f"OAuthPrompt.begin_dialog(): Expected DialogContext but got NoneType instead" ) options = options or PromptOptions() @@ -149,7 +149,7 @@ async def begin_dialog( if not isinstance(dialog_context.context.adapter, UserTokenProvider): raise TypeError( - "OAuthPrompt.get_user_token(): not supported by the current adapter" + "OAuthPrompt.begin_dialog(): not supported by the current adapter" ) output = await dialog_context.context.adapter.get_user_token( @@ -357,7 +357,7 @@ async def _send_oauth_card( ): if not hasattr(context.adapter, "get_oauth_sign_in_link"): raise Exception( - "OAuthPrompt.send_oauth_card(): get_oauth_sign_in_link() not supported by the current adapter" + "OAuthPrompt._send_oauth_card(): get_oauth_sign_in_link() not supported by the current adapter" ) link = await context.adapter.get_oauth_sign_in_link( @@ -455,7 +455,7 @@ async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult ) raise AttributeError( - "OAuthPrompt.recognize(): not supported by the current adapter." + "OAuthPrompt._recognize_token(): not supported by the current adapter." ) else: # No errors. Proceed with token exchange. From f2aec77ef23efaa402c936f5853be470a87a4043 Mon Sep 17 00:00:00 2001 From: Victor Kironde Date: Fri, 24 Apr 2020 22:02:21 +0300 Subject: [PATCH 411/616] match interface name in error message --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index f87515c50..4ce769442 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -450,7 +450,7 @@ async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult self._get_token_exchange_invoke_response( int(HTTPStatus.BAD_GATEWAY), "The bot's BotAdapter does not support token exchange operations." - " Ensure the bot's Adapter supports the ITokenExchangeProvider interface.", + " Ensure the bot's Adapter supports the ExtendedUserTokenProvider interface.", ) ) From 59776279820575cade3461872403d236dfb8b70f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 27 Apr 2020 10:27:07 -0500 Subject: [PATCH 412/616] Getting bots oauth scope instead of TurnState value in create_token_api_client. --- .../botbuilder-core/botbuilder/core/bot_framework_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 9eac4cbdd..f081a425c 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -1259,7 +1259,7 @@ async def _create_token_api_client( self._is_emulating_oauth_cards = True app_id = self.__get_app_id(context) - scope = context.turn_state.get(BotAdapter.BOT_OAUTH_SCOPE_KEY) + scope = self.__get_botframework_oauth_scope() app_credentials = oauth_app_credentials or await self.__get_app_credentials( app_id, scope ) From 141e3b2c2e8b47ffc2277b5d67c142291abafe95 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 27 Apr 2020 11:04:41 -0500 Subject: [PATCH 413/616] Pinned pylint to 2.4.4 --- pipelines/botbuilder-python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index 22f4edbb8..4723d96a3 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -57,7 +57,7 @@ jobs: pip install -r ./libraries/botframework-connector/tests/requirements.txt pip install -r ./libraries/botbuilder-core/tests/requirements.txt pip install coveralls - pip install pylint + pip install pylint==2.4.4 pip install black displayName: 'Install dependencies' From 027ebf0800dcfa5185e9e62b4a364e88ffd5fea1 Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Mon, 13 Apr 2020 10:36:56 -0700 Subject: [PATCH 414/616] add locale for EoC events --- .../botbuilder-core/botbuilder/core/skills/skill_handler.py | 2 ++ .../botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index abbdb0187..fcd9e9ca7 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -193,6 +193,7 @@ def _apply_eoc_to_turn_context_activity( context.activity.reply_to_id = end_of_conversation_activity.reply_to_id context.activity.value = end_of_conversation_activity.value context.activity.entities = end_of_conversation_activity.entities + context.activity.locale = end_of_conversation_activity.locale context.activity.local_timestamp = end_of_conversation_activity.local_timestamp context.activity.timestamp = end_of_conversation_activity.timestamp context.activity.channel_data = end_of_conversation_activity.channel_data @@ -212,6 +213,7 @@ def _apply_event_to_turn_context_activity( context.activity.reply_to_id = event_activity.reply_to_id context.activity.value = event_activity.value context.activity.entities = event_activity.entities + context.activity.locale = event_activity.locale context.activity.local_timestamp = event_activity.local_timestamp context.activity.timestamp = event_activity.timestamp context.activity.channel_data = event_activity.channel_data diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py index 090069783..e3eebee80 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py @@ -84,7 +84,7 @@ async def run_dialog( ) activity = Activity( - type=ActivityTypes.end_of_conversation, value=result.result + type=ActivityTypes.end_of_conversation, value=result.result, locale=turn_context.activity.locale ) await turn_context.send_activity(activity) From 85ddb04fe551d38216f95d92edd148f52d426918 Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Mon, 27 Apr 2020 10:28:59 -0700 Subject: [PATCH 415/616] added locale to ConversationReference --- .../botbuilder-core/botbuilder/core/turn_context.py | 2 ++ libraries/botbuilder-core/tests/test_turn_context.py | 4 ++++ libraries/botbuilder-schema/botbuilder/schema/_models.py | 8 ++++++++ .../botbuilder-schema/botbuilder/schema/_models_py3.py | 9 +++++++++ 4 files changed, 23 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index e679907ba..9f719363e 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -325,6 +325,7 @@ def get_conversation_reference(activity: Activity) -> ConversationReference: bot=copy(activity.recipient), conversation=copy(activity.conversation), channel_id=activity.channel_id, + locale=activity.locale, service_url=activity.service_url, ) @@ -342,6 +343,7 @@ def apply_conversation_reference( :return: """ activity.channel_id = reference.channel_id + activity.locale = reference.locale activity.service_url = reference.service_url activity.conversation = reference.conversation if is_incoming: diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 5f3668844..115f3b06d 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -23,6 +23,7 @@ recipient=ChannelAccount(id="bot", name="Bot Name"), conversation=ConversationAccount(id="convo", name="Convo Name"), channel_id="UnitTest", + locale="en-uS", # Intentionally oddly-cased to check that it isn't defaulted somewhere, but tests stay in English service_url="https://example.org", ) @@ -257,6 +258,7 @@ def test_get_conversation_reference_should_return_valid_reference(self): assert reference.bot == ACTIVITY.recipient assert reference.conversation == ACTIVITY.conversation assert reference.channel_id == ACTIVITY.channel_id + assert reference.locale == ACTIVITY.locale assert reference.service_url == ACTIVITY.service_url def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( @@ -270,6 +272,7 @@ def test_apply_conversation_reference_should_return_prepare_reply_when_is_incomi assert reply.recipient == ACTIVITY.from_property assert reply.from_property == ACTIVITY.recipient assert reply.conversation == ACTIVITY.conversation + assert reply.locale == ACTIVITY.locale assert reply.service_url == ACTIVITY.service_url assert reply.channel_id == ACTIVITY.channel_id @@ -284,6 +287,7 @@ def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepar assert reply.recipient == ACTIVITY.recipient assert reply.from_property == ACTIVITY.from_property assert reply.conversation == ACTIVITY.conversation + assert reply.locale == ACTIVITY.locale assert reply.service_url == ACTIVITY.service_url assert reply.channel_id == ACTIVITY.channel_id diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py index 1b9599a61..2dabab91f 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models.py @@ -736,6 +736,12 @@ class ConversationReference(Model): :type conversation: ~botframework.connector.models.ConversationAccount :param channel_id: Channel ID :type channel_id: str + :param locale: A locale name for the contents of the text field. + The locale name is a combination of an ISO 639 two- or three-letter + culture code associated with a language and an ISO 3166 two-letter + subculture code associated with a country or region. + The locale name can also correspond to a valid BCP-47 language tag. + :type locale: str :param service_url: Service endpoint where operations concerning the referenced conversation may be performed :type service_url: str @@ -747,6 +753,7 @@ class ConversationReference(Model): "bot": {"key": "bot", "type": "ChannelAccount"}, "conversation": {"key": "conversation", "type": "ConversationAccount"}, "channel_id": {"key": "channelId", "type": "str"}, + "locale": {"key": "locale", "type": "str"}, "service_url": {"key": "serviceUrl", "type": "str"}, } @@ -757,6 +764,7 @@ def __init__(self, **kwargs): self.bot = kwargs.get("bot", None) self.conversation = kwargs.get("conversation", None) self.channel_id = kwargs.get("channel_id", None) + self.locale = kwargs.get("locale", None) self.service_url = kwargs.get("service_url", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index f95185d1e..81b23c977 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -889,6 +889,12 @@ class ConversationReference(Model): :type conversation: ~botframework.connector.models.ConversationAccount :param channel_id: Channel ID :type channel_id: str + :param locale: A locale name for the contents of the text field. + The locale name is a combination of an ISO 639 two- or three-letter + culture code associated with a language and an ISO 3166 two-letter + subculture code associated with a country or region. + The locale name can also correspond to a valid BCP-47 language tag. + :type locale: str :param service_url: Service endpoint where operations concerning the referenced conversation may be performed :type service_url: str @@ -900,6 +906,7 @@ class ConversationReference(Model): "bot": {"key": "bot", "type": "ChannelAccount"}, "conversation": {"key": "conversation", "type": "ConversationAccount"}, "channel_id": {"key": "channelId", "type": "str"}, + "locale": {"key": "locale", "type": "str"}, "service_url": {"key": "serviceUrl", "type": "str"}, } @@ -911,6 +918,7 @@ def __init__( bot=None, conversation=None, channel_id: str = None, + locale: str = None, service_url: str = None, **kwargs ) -> None: @@ -920,6 +928,7 @@ def __init__( self.bot = bot self.conversation = conversation self.channel_id = channel_id + self.locale = locale self.service_url = service_url From 3ff5730d1a60d7e536788b0693c8925dbe0fe6dd Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Mon, 27 Apr 2020 10:30:21 -0700 Subject: [PATCH 416/616] added locale to adapter test --- libraries/botbuilder-core/tests/test_bot_framework_adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 25262d1a3..222d2c651 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -37,6 +37,7 @@ REFERENCE = ConversationReference( activity_id="1234", channel_id="test", + locale="en-uS", # Intentionally oddly-cased to check that it isn't defaulted somewhere, but tests stay in English service_url="https://example.org/channel", user=ChannelAccount(id="user", name="User Name"), bot=ChannelAccount(id="bot", name="Bot Name"), From 0cdd922b0de135df08007db723393a32ed8bd7ff Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Mon, 27 Apr 2020 10:52:12 -0700 Subject: [PATCH 417/616] black compliance --- libraries/botbuilder-core/tests/test_bot_framework_adapter.py | 2 +- libraries/botbuilder-core/tests/test_turn_context.py | 2 +- .../botbuilder/dialogs/dialog_extensions.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 222d2c651..8c6c98867 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -37,7 +37,7 @@ REFERENCE = ConversationReference( activity_id="1234", channel_id="test", - locale="en-uS", # Intentionally oddly-cased to check that it isn't defaulted somewhere, but tests stay in English + locale="en-uS", # Intentionally oddly-cased to check that it isn't defaulted somewhere, but tests stay in English service_url="https://example.org/channel", user=ChannelAccount(id="user", name="User Name"), bot=ChannelAccount(id="bot", name="Bot Name"), diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 115f3b06d..8f4d3b6b6 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -23,7 +23,7 @@ recipient=ChannelAccount(id="bot", name="Bot Name"), conversation=ConversationAccount(id="convo", name="Convo Name"), channel_id="UnitTest", - locale="en-uS", # Intentionally oddly-cased to check that it isn't defaulted somewhere, but tests stay in English + locale="en-uS", # Intentionally oddly-cased to check that it isn't defaulted somewhere, but tests stay in English service_url="https://example.org", ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py index e3eebee80..fc8faead0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py @@ -84,7 +84,9 @@ async def run_dialog( ) activity = Activity( - type=ActivityTypes.end_of_conversation, value=result.result, locale=turn_context.activity.locale + type=ActivityTypes.end_of_conversation, + value=result.result, + locale=turn_context.activity.locale, ) await turn_context.send_activity(activity) From da42c5f3761e20e9267ad2b923daf176bbedbf4f Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 27 Apr 2020 15:12:50 -0700 Subject: [PATCH 418/616] Fix parameters for get_paged_members --- .../botbuilder/core/teams/teams_info.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 3593650c4..e3ca332b6 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -123,10 +123,7 @@ async def get_paged_team_members( connector_client = await TeamsInfo._get_connector_client(turn_context) return await TeamsInfo._get_paged_members( - connector_client, - turn_context.activity.conversation.id, - continuation_token, - page_size, + connector_client, team_id, continuation_token, page_size, ) @staticmethod @@ -142,7 +139,9 @@ async def get_paged_members( connector_client, conversation_id, continuation_token, page_size ) - return await TeamsInfo.get_paged_team_members(turn_context, team_id, page_size) + return await TeamsInfo.get_paged_team_members( + turn_context, team_id, continuation_token, page_size + ) @staticmethod async def get_team_member( @@ -245,7 +244,7 @@ async def _get_paged_members( ) return await connector_client.conversations.get_teams_conversation_paged_members( - conversation_id, continuation_token, page_size + conversation_id, page_size, continuation_token ) @staticmethod From 53444263d8b0276d6abdc8ed28576b55375142eb Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 27 Apr 2020 17:00:06 -0700 Subject: [PATCH 419/616] Change onTeamsMemberAdded for Teams to use getMember() --- .../core/teams/teams_activity_handler.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 4c767d40a..72cd23022 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from http import HTTPStatus -from botbuilder.schema import ChannelAccount, SignInConstants +from botbuilder.schema import ChannelAccount, ErrorResponseException, SignInConstants from botbuilder.core import ActivityHandler, InvokeResponse from botbuilder.core.activity_handler import _InvokeResponseException from botbuilder.core.turn_context import TurnContext @@ -348,7 +348,6 @@ async def on_teams_members_added_dispatch( # pylint: disable=unused-argument turn_context: TurnContext, ): - team_members = {} team_members_added = [] for member in members_added: if member.additional_properties != {}: @@ -356,20 +355,25 @@ async def on_teams_members_added_dispatch( # pylint: disable=unused-argument deserializer_helper(TeamsChannelAccount, member) ) else: - if team_members == {}: - result = await TeamsInfo.get_members(turn_context) - team_members = {i.id: i for i in result} - - if member.id in team_members: - team_members_added.append(member) - else: - new_teams_channel_account = TeamsChannelAccount( - id=member.id, - name=member.name, - aad_object_id=member.aad_object_id, - role=member.role, - ) - team_members_added.append(new_teams_channel_account) + team_member = None + try: + team_member = await TeamsInfo.get_member(turn_context, member.id) + team_members_added.append(team_member) + except ErrorResponseException as ex: + if ( + ex.error + and ex.error.error + and ex.error.error.code == "ConversationNotFound" + ): + new_teams_channel_account = TeamsChannelAccount( + id=member.id, + name=member.name, + aad_object_id=member.aad_object_id, + role=member.role, + ) + team_members_added.append(new_teams_channel_account) + else: + raise ex return await self.on_teams_members_added( team_members_added, team_info, turn_context From 260bba4dd37f29bbb24a40d25b246a88fa756b33 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 27 Apr 2020 17:02:55 -0700 Subject: [PATCH 420/616] add test for on_teams_members_added --- .../botbuilder-core/tests/simple_adapter.py | 14 ++++++ .../teams/test_teams_activity_handler.py | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index 0ded46f45..e7b61669b 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -10,6 +10,7 @@ ResourceResponse, ConversationParameters, ) +from botbuilder.schema.teams import TeamsChannelAccount class SimpleAdapter(BotAdapter): @@ -79,3 +80,16 @@ async def update_activity(self, context: TurnContext, activity: Activity): async def process_request(self, activity, handler): context = TurnContext(self, activity) return await self.run_pipeline(context, handler) + + async def create_connector_client(self, service_url: str): + return TestConnectorClient() + + +class TestConnectorClient: + def __init__(self) -> None: + self.conversations = TestConversations() + + +class TestConversations: + async def get_conversation_member(self, conversation_id, member_id): + return TeamsChannelAccount(id=member_id) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index f2dc9f0ce..2ad52f76e 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -7,6 +7,7 @@ Activity, ActivityTypes, ChannelAccount, + ConversationAccount, ConversationReference, ResourceResponse, ) @@ -37,6 +38,17 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): self.record.append("on_conversation_update_activity") return await super().on_conversation_update_activity(turn_context) + async def on_teams_members_added( # pylint: disable=unused-argument + self, + teams_members_added: [TeamsChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ): + self.record.append("on_teams_members_added") + return await super().on_teams_members_added( + teams_members_added, team_info, turn_context + ) + async def on_teams_members_removed( self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext ): @@ -342,6 +354,37 @@ async def test_on_teams_team_renamed_activity(self): assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_team_renamed_activity" + async def test_on_teams_members_added_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamMemberAdded", + "team": {"id": "team_id_1", "name": "new_team_name"}, + }, + members_added=[ + ChannelAccount( + id="123", + name="test_user", + aad_object_id="asdfqwerty", + role="tester", + ) + ], + channel_id=Channels.ms_teams, + conversation=ConversationAccount(id="456"), + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_added" + async def test_on_teams_members_removed_activity(self): # arrange activity = Activity( From 7e4ba8bfa69c2bc80535f7f91c5dc17c49bbd541 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 27 Apr 2020 17:14:10 -0700 Subject: [PATCH 421/616] pylint fix --- libraries/botbuilder-core/tests/simple_adapter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/tests/simple_adapter.py b/libraries/botbuilder-core/tests/simple_adapter.py index e7b61669b..b8dd3c404 100644 --- a/libraries/botbuilder-core/tests/simple_adapter.py +++ b/libraries/botbuilder-core/tests/simple_adapter.py @@ -91,5 +91,7 @@ def __init__(self) -> None: class TestConversations: - async def get_conversation_member(self, conversation_id, member_id): + async def get_conversation_member( # pylint: disable=unused-argument + self, conversation_id, member_id + ): return TeamsChannelAccount(id=member_id) From f39c980305eb224f875e0285101c87a2589459fd Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Apr 2020 16:13:20 -0500 Subject: [PATCH 422/616] Skills use the right connector client when receiving tokens from TokenService --- .../botbuilder/core/__init__.py | 3 +- .../botbuilder/core/adapters/test_adapter.py | 2 +- .../botbuilder/core/bot_framework_adapter.py | 12 +- .../botbuilder/core/oauth/__init__.py | 12 + .../core/oauth/connector_client_builder.py | 26 ++ .../extended_user_token_provider.py | 2 +- .../core/{ => oauth}/user_token_provider.py | 225 +++++++++--------- .../dialogs/prompts/oauth_prompt.py | 99 ++++++-- 8 files changed, 245 insertions(+), 136 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/oauth/__init__.py create mode 100644 libraries/botbuilder-core/botbuilder/core/oauth/connector_client_builder.py rename libraries/botbuilder-core/botbuilder/core/{ => oauth}/extended_user_token_provider.py (99%) rename libraries/botbuilder-core/botbuilder/core/{ => oauth}/user_token_provider.py (96%) diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index c45484e6f..48f8d4de2 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -19,7 +19,7 @@ from .card_factory import CardFactory from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler from .conversation_state import ConversationState -from .extended_user_token_provider import ExtendedUserTokenProvider +from botbuilder.core.oauth.extended_user_token_provider import ExtendedUserTokenProvider from .intent_score import IntentScore from .invoke_response import InvokeResponse from .memory_storage import MemoryStorage @@ -39,7 +39,6 @@ from .telemetry_logger_middleware import TelemetryLoggerMiddleware from .turn_context import TurnContext from .user_state import UserState -from .user_token_provider import UserTokenProvider from .register_class_middleware import RegisterClassMiddleware from .adapter_extensions import AdapterExtensions diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 8215e70bd..7e91d89dc 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -30,7 +30,7 @@ ) from ..bot_adapter import BotAdapter from ..turn_context import TurnContext -from ..extended_user_token_provider import ExtendedUserTokenProvider +from botbuilder.core.oauth.extended_user_token_provider import ExtendedUserTokenProvider class UserToken: diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index f081a425c..d1d8bfe5d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -53,8 +53,11 @@ from . import __version__ from .bot_adapter import BotAdapter +from .oauth import ( + ConnectorClientBuilder, + ExtendedUserTokenProvider, +) from .turn_context import TurnContext -from .extended_user_token_provider import ExtendedUserTokenProvider from .invoke_response import InvokeResponse from .conversation_reference_extension import get_continuation_activity @@ -164,7 +167,9 @@ def __init__( ) -class BotFrameworkAdapter(BotAdapter, ExtendedUserTokenProvider): +class BotFrameworkAdapter( + BotAdapter, ExtendedUserTokenProvider, ConnectorClientBuilder +): """ Defines an adapter to connect a bot to a service endpoint. @@ -1083,7 +1088,8 @@ async def create_connector_client( self, service_url: str, identity: ClaimsIdentity = None, audience: str = None ) -> ConnectorClient: """ - Creates the connector client + Implementation of ConnectorClientProvider.create_connector_client. + :param service_url: The service URL :param identity: The claims identity :param audience: diff --git a/libraries/botbuilder-core/botbuilder/core/oauth/__init__.py b/libraries/botbuilder-core/botbuilder/core/oauth/__init__.py new file mode 100644 index 000000000..4fd090b48 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/oauth/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .extended_user_token_provider import ExtendedUserTokenProvider +from .user_token_provider import UserTokenProvider +from .connector_client_builder import ConnectorClientBuilder + +__all__ = [ + "ConnectorClientBuilder", + "ExtendedUserTokenProvider", + "UserTokenProvider", +] diff --git a/libraries/botbuilder-core/botbuilder/core/oauth/connector_client_builder.py b/libraries/botbuilder-core/botbuilder/core/oauth/connector_client_builder.py new file mode 100644 index 000000000..e5256040f --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/oauth/connector_client_builder.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from abc import ABC, abstractmethod + +from botframework.connector import ConnectorClient +from botframework.connector.auth import ClaimsIdentity + + +class ConnectorClientBuilder(ABC): + """ + Abstraction to build connector clients. + """ + + @abstractmethod + async def create_connector_client( + self, service_url: str, identity: ClaimsIdentity = None, audience: str = None + ) -> ConnectorClient: + """ + Creates the connector client asynchronous. + + :param service_url: The service URL. + :param identity: The claims claimsIdentity. + :param audience: The target audience for the connector. + :return: ConnectorClient instance + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py b/libraries/botbuilder-core/botbuilder/core/oauth/extended_user_token_provider.py similarity index 99% rename from libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py rename to libraries/botbuilder-core/botbuilder/core/oauth/extended_user_token_provider.py index f1c8301e8..ad07c3989 100644 --- a/libraries/botbuilder-core/botbuilder/core/extended_user_token_provider.py +++ b/libraries/botbuilder-core/botbuilder/core/oauth/extended_user_token_provider.py @@ -11,7 +11,7 @@ ) from botframework.connector.auth import AppCredentials -from .turn_context import TurnContext +from botbuilder.core.turn_context import TurnContext from .user_token_provider import UserTokenProvider diff --git a/libraries/botbuilder-core/botbuilder/core/user_token_provider.py b/libraries/botbuilder-core/botbuilder/core/oauth/user_token_provider.py similarity index 96% rename from libraries/botbuilder-core/botbuilder/core/user_token_provider.py rename to libraries/botbuilder-core/botbuilder/core/oauth/user_token_provider.py index 735af1e7a..04e92efc2 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_token_provider.py +++ b/libraries/botbuilder-core/botbuilder/core/oauth/user_token_provider.py @@ -1,113 +1,112 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC, abstractmethod -from typing import Dict, List - -from botbuilder.schema import TokenResponse -from botframework.connector.auth import AppCredentials - -from .turn_context import TurnContext - - -class UserTokenProvider(ABC): - @abstractmethod - async def get_user_token( - self, - context: TurnContext, - connection_name: str, - magic_code: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> TokenResponse: - """ - Retrieves the OAuth token for a user that is in a sign-in flow. - :param context: Context for the current turn of conversation with the user. - :param connection_name: Name of the auth connection to use. - :param magic_code: (Optional) Optional user entered code to validate. - :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the - Bots credentials are used. - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def sign_out_user( - self, - context: TurnContext, - connection_name: str = None, - user_id: str = None, - oauth_app_credentials: AppCredentials = None, - ): - """ - Signs the user out with the token server. - :param context: Context for the current turn of conversation with the user. - :param connection_name: Name of the auth connection to use. - :param user_id: User id of user to sign out. - :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the - Bots credentials are used. - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_oauth_sign_in_link( - self, - context: TurnContext, - connection_name: str, - final_redirect: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> str: - """ - Get the raw signin link to be sent to the user for signin for a connection name. - :param context: Context for the current turn of conversation with the user. - :param connection_name: Name of the auth connection to use. - :param final_redirect: The final URL that the OAuth flow will redirect to. - :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the - Bots credentials are used. - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_token_status( - self, - context: TurnContext, - connection_name: str = None, - user_id: str = None, - include_filter: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> Dict[str, TokenResponse]: - """ - Retrieves Azure Active Directory tokens for particular resources on a configured connection. - :param context: Context for the current turn of conversation with the user. - :param connection_name: Name of the auth connection to use. - :param user_id: The user Id for which token status is retrieved. - :param include_filter: Optional comma separated list of connection's to include. Blank will return token status - for all configured connections. - :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the - Bots credentials are used. - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_aad_tokens( - self, - context: TurnContext, - connection_name: str, - resource_urls: List[str], - user_id: str = None, - oauth_app_credentials: AppCredentials = None, - ) -> Dict[str, TokenResponse]: - """ - Retrieves Azure Active Directory tokens for particular resources on a configured connection. - :param context: Context for the current turn of conversation with the user. - :param connection_name: Name of the auth connection to use. - :param resource_urls: The list of resource URLs to retrieve tokens for. - :param user_id: The user Id for which tokens are retrieved. If passing in None the userId is taken - from the Activity in the TurnContext. - :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the - Bots credentials are used. - :return: - """ - raise NotImplementedError() +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from typing import Dict, List + +from botbuilder.core.turn_context import TurnContext +from botbuilder.schema import TokenResponse +from botframework.connector.auth import AppCredentials + + +class UserTokenProvider(ABC): + @abstractmethod + async def get_user_token( + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> TokenResponse: + """ + Retrieves the OAuth token for a user that is in a sign-in flow. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param magic_code: (Optional) Optional user entered code to validate. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def sign_out_user( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): + """ + Signs the user out with the token server. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: User id of user to sign out. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_oauth_sign_in_link( + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> str: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param final_redirect: The final URL that the OAuth flow will redirect to. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_token_status( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: The user Id for which token status is retrieved. + :param include_filter: Optional comma separated list of connection's to include. Blank will return token status + for all configured connections. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_aad_tokens( + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param resource_urls: The list of resource URLs to retrieve tokens for. + :param user_id: The user Id for which tokens are retrieved. If passing in None the userId is taken + from the Activity in the TurnContext. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 4ce769442..8d0a9241e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -7,7 +7,11 @@ from typing import Union, Awaitable, Callable from botframework.connector import Channels -from botframework.connector.auth import ClaimsIdentity, SkillValidation +from botframework.connector.auth import ( + ClaimsIdentity, + SkillValidation, + JwtTokenValidation, +) from botframework.connector.token_api.models import SignInUrlResponse from botbuilder.core import ( CardFactory, @@ -15,6 +19,10 @@ MessageFactory, InvokeResponse, TurnContext, + BotAdapter, +) +from botbuilder.core.oauth import ( + ConnectorClientBuilder, UserTokenProvider, ) from botbuilder.core.bot_framework_adapter import TokenExchangeRequest @@ -38,10 +46,19 @@ from .prompt_validator_context import PromptValidatorContext from .prompt_recognizer_result import PromptRecognizerResult -# TODO: Consider moving TokenExchangeInvokeRequest and TokenExchangeInvokeResponse to here + +class CallerInfo: + def __init__(self, caller_service_url: str = None, scope: str = None): + self.caller_service_url = caller_service_url + self.scope = scope class OAuthPrompt(Dialog): + PERSISTED_OPTIONS = "options" + PERSISTED_STATE = "state" + PERSISTED_EXPIRES = "expires" + PERSISTED_CALLER = "caller" + """ Creates a new prompt that asks the user to sign in, using the Bot Framework Single Sign On (SSO) service. @@ -143,9 +160,14 @@ async def begin_dialog( else 900000 ) state = dialog_context.active_dialog.state - state["state"] = {} - state["options"] = options - state["expires"] = datetime.now() + timedelta(seconds=timeout / 1000) + state[OAuthPrompt.PERSISTED_STATE] = {} + state[OAuthPrompt.PERSISTED_OPTIONS] = options + state[OAuthPrompt.PERSISTED_EXPIRES] = datetime.now() + timedelta( + seconds=timeout / 1000 + ) + state[OAuthPrompt.PERSISTED_CALLER] = OAuthPrompt.__create_caller_info( + dialog_context.context + ) if not isinstance(dialog_context.context.adapter, UserTokenProvider): raise TypeError( @@ -182,12 +204,14 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu user's reply as valid input for the prompt. """ # Recognize token - recognized = await self._recognize_token(dialog_context.context) + recognized = await self._recognize_token(dialog_context) # Check for timeout state = dialog_context.active_dialog.state is_message = dialog_context.context.activity.type == ActivityTypes.message - has_timed_out = is_message and (datetime.now() > state["expires"]) + has_timed_out = is_message and ( + datetime.now() > state[OAuthPrompt.PERSISTED_EXPIRES] + ) if has_timed_out: return await dialog_context.end_dialog(None) @@ -204,8 +228,8 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu PromptValidatorContext( dialog_context.context, recognized, - state["state"], - state["options"], + state[OAuthPrompt.PERSISTED_STATE], + state[OAuthPrompt.PERSISTED_OPTIONS], ) ) elif recognized.succeeded: @@ -219,9 +243,11 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu if ( not dialog_context.context.responded and is_message - and state["options"].retry_prompt is not None + and state[OAuthPrompt.PERSISTED_OPTIONS].retry_prompt is not None ): - await dialog_context.context.send_activity(state["options"].retry_prompt) + await dialog_context.context.send_activity( + state[OAuthPrompt.PERSISTED_OPTIONS].retry_prompt + ) return Dialog.end_of_turn @@ -285,6 +311,17 @@ async def sign_out_user(self, context: TurnContext): self._settings.oath_app_credentials, ) + @staticmethod + def __create_caller_info(context: TurnContext) -> CallerInfo: + bot_identity = context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) + if bot_identity and SkillValidation.is_skill_claim(bot_identity.claims): + return CallerInfo( + caller_service_url=context.activity.service_url, + scope=JwtTokenValidation.get_app_id_from_claims(bot_identity.claims), + ) + + return None + async def _send_oauth_card( self, context: TurnContext, prompt: Union[Activity, str] = None ): @@ -309,11 +346,11 @@ async def _send_oauth_card( context.activity.from_property.id, ) link = sign_in_resource.sign_in_link - bot_identity: ClaimsIdentity = context.turn_state.get("BotIdentity") + bot_identity: ClaimsIdentity = context.turn_state.get( + BotAdapter.BOT_IDENTITY_KEY + ) - # use the SignInLink when - # in speech channel or - # bot is a skill or + # use the SignInLink when in speech channel or bot is a skill or # an extra OAuthAppCredentials is being passed in if ( ( @@ -384,10 +421,40 @@ async def _send_oauth_card( # Send prompt await context.send_activity(prompt) - async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult: + async def _recognize_token( + self, dialog_context: DialogContext + ) -> PromptRecognizerResult: + context = dialog_context.context token = None if OAuthPrompt._is_token_response_event(context): token = context.activity.value + + # fixup the turnContext's state context if this was received from a skill host caller + state: CallerInfo = dialog_context.active_dialog.state[ + OAuthPrompt.PERSISTED_CALLER + ] + if state: + # set the ServiceUrl to the skill host's Url + dialog_context.context.activity.service_url = state.caller_service_url + + # recreate a ConnectorClient and set it in TurnState so replies use the correct one + if not isinstance(context.adapter, ConnectorClientBuilder): + raise TypeError( + "OAuthPrompt: IConnectorClientProvider interface not implemented by the current adapter" + ) + + connector_client_builder: ConnectorClientBuilder = context.adapter + claims_identity = context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) + connector_client = await connector_client_builder.create_connector_client( + dialog_context.context.activity.service_url, + claims_identity, + state.scope, + ) + + context.turn_state[ + BotAdapter.BOT_CONNECTOR_CLIENT_KEY + ] = connector_client + elif OAuthPrompt._is_teams_verification_invoke(context): code = context.activity.value["state"] try: From 398bc48e35f154a5d37fc6d22ac15b7138772c09 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Apr 2020 16:25:02 -0500 Subject: [PATCH 423/616] pylint --- libraries/botbuilder-core/botbuilder/core/__init__.py | 3 ++- .../botbuilder-core/botbuilder/core/adapters/test_adapter.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 48f8d4de2..9e00f8f88 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -19,7 +19,8 @@ from .card_factory import CardFactory from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler from .conversation_state import ConversationState -from botbuilder.core.oauth.extended_user_token_provider import ExtendedUserTokenProvider +from .oauth.extended_user_token_provider import ExtendedUserTokenProvider +from .oauth.user_token_provider import UserTokenProvider from .intent_score import IntentScore from .invoke_response import InvokeResponse from .memory_storage import MemoryStorage diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 7e91d89dc..d5c93c1d9 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -30,7 +30,7 @@ ) from ..bot_adapter import BotAdapter from ..turn_context import TurnContext -from botbuilder.core.oauth.extended_user_token_provider import ExtendedUserTokenProvider +from ..oauth.extended_user_token_provider import ExtendedUserTokenProvider class UserToken: From 288bf1a37611063826978f2c6f5d7c6edd844880 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 29 Apr 2020 11:13:51 -0500 Subject: [PATCH 424/616] Added HealthCheck (#1011) * Added HealthCheck * Added "Bearer" to HealthResults.authorization * Corrected HealthCheck test --- .../botbuilder/core/__init__.py | 2 + .../botbuilder/core/activity_handler.py | 24 +++++++ .../botbuilder/core/healthcheck.py | 31 +++++++++ .../tests/test_activity_handler.py | 67 ++++++++++++++++++- .../botbuilder/schema/__init__.py | 4 ++ .../botbuilder/schema/health_results.py | 35 ++++++++++ .../botbuilder/schema/healthcheck_response.py | 20 ++++++ 7 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/healthcheck.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/health_results.py create mode 100644 libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index c45484e6f..5baa2d950 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -42,6 +42,7 @@ from .user_token_provider import UserTokenProvider from .register_class_middleware import RegisterClassMiddleware from .adapter_extensions import AdapterExtensions +from .healthcheck import HealthCheck __all__ = [ "ActivityHandler", @@ -63,6 +64,7 @@ "ConversationState", "conversation_reference_extension", "ExtendedUserTokenProvider", + "HealthCheck", "IntentScore", "InvokeResponse", "MemoryStorage", diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index f88012ede..0757ff28c 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -9,7 +9,11 @@ ChannelAccount, MessageReaction, SignInConstants, + HealthCheckResponse, ) + +from .bot_adapter import BotAdapter +from .healthcheck import HealthCheck from .serializer_helper import serializer_helper from .bot_framework_adapter import BotFrameworkAdapter from .invoke_response import InvokeResponse @@ -401,6 +405,11 @@ async def on_invoke_activity( # pylint: disable=unused-argument await self.on_sign_in_invoke(turn_context) return self._create_invoke_response() + if turn_context.activity.name == "healthcheck": + return self._create_invoke_response( + await self.on_healthcheck(turn_context) + ) + raise _InvokeResponseException(HTTPStatus.NOT_IMPLEMENTED) except _InvokeResponseException as invoke_exception: return invoke_exception.create_invoke_response() @@ -421,6 +430,21 @@ async def on_sign_in_invoke( # pylint: disable=unused-argument """ raise _InvokeResponseException(HTTPStatus.NOT_IMPLEMENTED) + async def on_healthcheck(self, turn_context: TurnContext) -> HealthCheckResponse: + """ + Invoked when the bot is sent a health check from the hosting infrastructure or, in the case of + Skills the parent bot. By default, this method acknowledges the health state of the bot. + + When the on_invoke_activity method receives an Invoke with a Activity.name of `healthCheck`, it + calls this method. + + :param turn_context: A context object for this turn. + :return: The HealthCheckResponse object + """ + return HealthCheck.create_healthcheck_response( + turn_context.turn_state.get(BotAdapter.BOT_CONNECTOR_CLIENT_KEY) + ) + @staticmethod def _create_invoke_response(body: object = None) -> InvokeResponse: return InvokeResponse(status=int(HTTPStatus.OK), body=serializer_helper(body)) diff --git a/libraries/botbuilder-core/botbuilder/core/healthcheck.py b/libraries/botbuilder-core/botbuilder/core/healthcheck.py new file mode 100644 index 000000000..c9f5afb49 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/healthcheck.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import HealthCheckResponse, HealthResults +from botbuilder.core.bot_framework_adapter import USER_AGENT +from botframework.connector import ConnectorClient + + +class HealthCheck: + @staticmethod + def create_healthcheck_response( + connector_client: ConnectorClient, + ) -> HealthCheckResponse: + # A derived class may override this, however, the default is that the bot is healthy given + # we have got to here. + health_results = HealthResults(success=True) + + if connector_client: + health_results.authorization = "{} {}".format( + "Bearer", connector_client.config.credentials.get_access_token() + ) + health_results.user_agent = USER_AGENT + + success_message = "Health check succeeded." + health_results.messages = ( + [success_message] + if health_results.authorization + else [success_message, "Callbacks are not authorized."] + ) + + return HealthCheckResponse(health_results=health_results) diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 20d9386e0..d0f5b4f79 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -2,7 +2,10 @@ from typing import List import aiounittest -from botbuilder.core import ActivityHandler, BotAdapter, TurnContext +from botframework.connector import ConnectorClient +from botframework.connector.auth import AppCredentials + +from botbuilder.core import ActivityHandler, BotAdapter, TurnContext, InvokeResponse from botbuilder.schema import ( Activity, ActivityTypes, @@ -10,8 +13,11 @@ ConversationReference, MessageReaction, ResourceResponse, + HealthCheckResponse, ) +from botbuilder.core.bot_framework_adapter import USER_AGENT + class TestingActivityHandler(ActivityHandler): __test__ = False @@ -84,6 +90,10 @@ async def on_sign_in_invoke( # pylint: disable=unused-argument self.record.append("on_sign_in_invoke") return + async def on_healthcheck(self, turn_context: TurnContext) -> HealthCheckResponse: + self.record.append("on_healthcheck") + return HealthCheckResponse() + class NotImplementedAdapter(BotAdapter): async def delete_activity( @@ -129,6 +139,18 @@ async def update_activity(self, context: TurnContext, activity: Activity): raise NotImplementedError() +class MockConnectorClient(ConnectorClient): + def __init__(self): + super().__init__( + credentials=MockCredentials(), base_url="http://tempuri.org/whatever" + ) + + +class MockCredentials(AppCredentials): + def get_access_token(self, force_refresh: bool = False) -> str: + return "awesome" + + class TestActivityHandler(aiounittest.AsyncTestCase): async def test_message_reaction(self): # Note the code supports multiple adds and removes in the same activity though @@ -206,3 +228,46 @@ async def test_typing_activity(self): assert len(bot.record) == 1 assert bot.record[0] == "on_typing_activity" + + async def test_healthcheck(self): + activity = Activity(type=ActivityTypes.invoke, name="healthcheck",) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + bot = ActivityHandler() + await bot.on_turn(turn_context) + + self.assertIsNotNone(adapter.activity) + self.assertIsInstance(adapter.activity.value, InvokeResponse) + self.assertEqual(adapter.activity.value.status, 200) + + response = HealthCheckResponse.deserialize(adapter.activity.value.body) + self.assertTrue(response.health_results.success) + self.assertTrue(response.health_results.messages) + self.assertEqual(response.health_results.messages[0], "Health check succeeded.") + + async def test_healthcheck_with_connector(self): + activity = Activity(type=ActivityTypes.invoke, name="healthcheck",) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + mock_connector_client = MockConnectorClient() + turn_context.turn_state[ + BotAdapter.BOT_CONNECTOR_CLIENT_KEY + ] = mock_connector_client + + bot = ActivityHandler() + await bot.on_turn(turn_context) + + self.assertIsNotNone(adapter.activity) + self.assertIsInstance(adapter.activity.value, InvokeResponse) + self.assertEqual(adapter.activity.value.status, 200) + + response = HealthCheckResponse.deserialize(adapter.activity.value.body) + self.assertTrue(response.health_results.success) + self.assertEqual(response.health_results.authorization, "Bearer awesome") + self.assertEqual(response.health_results.user_agent, USER_AGENT) + self.assertTrue(response.health_results.messages) + self.assertEqual(response.health_results.messages[0], "Health check succeeded.") diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index bab8a9444..97443cf28 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -125,6 +125,8 @@ from ._sign_in_enums import SignInConstants from .callerid_constants import CallerIdConstants +from .health_results import HealthResults +from .healthcheck_response import HealthCheckResponse __all__ = [ "Activity", @@ -192,4 +194,6 @@ "ContactRelationUpdateActionTypes", "InstallationUpdateActionTypes", "CallerIdConstants", + "HealthResults", + "HealthCheckResponse", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/health_results.py b/libraries/botbuilder-schema/botbuilder/schema/health_results.py new file mode 100644 index 000000000..1d28e23aa --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/health_results.py @@ -0,0 +1,35 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from msrest.serialization import Model + + +class HealthResults(Model): + _attribute_map = { + "success": {"key": "success", "type": "bool"}, + "authorization": {"key": "authorization", "type": "str"}, + "user_agent": {"key": "user-agent", "type": "str"}, + "messages": {"key": "messages", "type": "[str]"}, + "diagnostics": {"key": "diagnostics", "type": "object"}, + } + + def __init__( + self, + *, + success: bool = None, + authorization: str = None, + user_agent: str = None, + messages: [str] = None, + diagnostics: object = None, + **kwargs + ) -> None: + super(HealthResults, self).__init__(**kwargs) + self.success = success + self.authorization = authorization + self.user_agent = user_agent + self.messages = messages + self.diagnostics = diagnostics diff --git a/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py b/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py new file mode 100644 index 000000000..70a6dcdfc --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py @@ -0,0 +1,20 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from msrest.serialization import Model + +from botbuilder.schema import HealthResults + + +class HealthCheckResponse(Model): + _attribute_map = { + "health_results": {"key": "healthResults", "type": "HealthResults"}, + } + + def __init__(self, *, health_results: HealthResults = None, **kwargs) -> None: + super(HealthCheckResponse, self).__init__(**kwargs) + self.health_results = health_results From 5a094bd2ed98df9f7a01824e54bff797425103de Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 29 Apr 2020 16:44:38 -0500 Subject: [PATCH 425/616] Volitile LowScoreVariation and Active Learning Issue fix --- .../botbuilder/ai/qna/dialogs/qnamaker_dialog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py index 0254dcd5e..f1b052207 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/dialogs/qnamaker_dialog.py @@ -292,13 +292,12 @@ async def __call_generate_answer(self, step_context: WaterfallStepContext): # Check if active learning is enabled and send card # maximum_score_for_low_score_variation is the score above which no need to check for feedback. if ( - is_active_learning_enabled - and response.answers + response.answers and response.answers[0].score <= self.maximum_score_for_low_score_variation ): # Get filtered list of the response that support low score variation criteria. response.answers = qna_client.get_low_score_variation(response.answers) - if len(response.answers) > 1: + if len(response.answers) > 1 and is_active_learning_enabled: suggested_questions = [qna.questions[0] for qna in response.answers] message = QnACardBuilder.get_suggestions_card( suggested_questions, From 75e7f188af5d11e6f56ac015d454f18f584c585c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 29 Apr 2020 17:08:35 -0500 Subject: [PATCH 426/616] Added QnA Low score tests --- .../qna/test_data/QnaMaker_TopNAnswer.json | 65 +++++++++++++++++++ ...aker_TopNAnswer_DisableActiveLearning.json | 65 +++++++++++++++++++ libraries/botbuilder-ai/tests/qna/test_qna.py | 48 ++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer.json create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer_DisableActiveLearning.json diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer.json b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer.json new file mode 100644 index 000000000..6c09c9b8d --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer.json @@ -0,0 +1,65 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [ + "Q1" + ], + "answer": "A1", + "score": 80, + "id": 15, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q2" + ], + "answer": "A2", + "score": 78, + "id": 16, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q3" + ], + "answer": "A3", + "score": 75, + "id": 17, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q4" + ], + "answer": "A4", + "score": 50, + "id": 18, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer_DisableActiveLearning.json b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer_DisableActiveLearning.json new file mode 100644 index 000000000..f4fa91d57 --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/QnaMaker_TopNAnswer_DisableActiveLearning.json @@ -0,0 +1,65 @@ +{ + "activeLearningEnabled": false, + "answers": [ + { + "questions": [ + "Q1" + ], + "answer": "A1", + "score": 80, + "id": 15, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q2" + ], + "answer": "A2", + "score": 78, + "id": 16, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q3" + ], + "answer": "A3", + "score": 75, + "id": 17, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q4" + ], + "answer": "A4", + "score": 50, + "id": 18, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 2d6313d64..8f41cdc2a 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -811,6 +811,54 @@ async def test_should_answer_with_low_score_without_provided_context(self): ) self.assertEqual(True, results[0].score < 1, "Score should be low.") + async def test_low_score_variation(self): + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + options = QnAMakerOptions(top=5, context=None) + + turn_context = QnaApplicationTest._get_context("Q11", TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "QnaMaker_TopNAnswer.json" + ) + + # active learning enabled + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(turn_context, options) + self.assertIsNotNone(results) + self.assertEqual( + 4, len(results), "should get four results" + ) + + filtered_results = qna.get_low_score_variation(results) + self.assertIsNotNone(filtered_results) + self.assertEqual( + 3, len(filtered_results), "should get three results" + ) + + # active learning disabled + turn_context = QnaApplicationTest._get_context("Q11", TestAdapter()) + response_json = QnaApplicationTest._get_json_for_file( + "QnaMaker_TopNAnswer_DisableActiveLearning.json" + ) + + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ): + results = await qna.get_answers(turn_context, options) + self.assertIsNotNone(results) + self.assertEqual( + 4, len(results), "should get four results" + ) + + filtered_results = qna.get_low_score_variation(results) + self.assertIsNotNone(filtered_results) + self.assertEqual( + 3, len(filtered_results), "should get three results" + ) + @classmethod async def _get_service_result( cls, From 6f99703c1dd8519655e8b9ba0714ce1c2d22438b Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 29 Apr 2020 17:09:55 -0500 Subject: [PATCH 427/616] pylint --- libraries/botbuilder-ai/tests/qna/test_qna.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 8f41cdc2a..309967839 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. # pylint: disable=protected-access +# pylint: disable=too-many-lines import json from os import path @@ -827,15 +828,11 @@ async def test_low_score_variation(self): ): results = await qna.get_answers(turn_context, options) self.assertIsNotNone(results) - self.assertEqual( - 4, len(results), "should get four results" - ) + self.assertEqual(4, len(results), "should get four results") filtered_results = qna.get_low_score_variation(results) self.assertIsNotNone(filtered_results) - self.assertEqual( - 3, len(filtered_results), "should get three results" - ) + self.assertEqual(3, len(filtered_results), "should get three results") # active learning disabled turn_context = QnaApplicationTest._get_context("Q11", TestAdapter()) @@ -849,15 +846,11 @@ async def test_low_score_variation(self): ): results = await qna.get_answers(turn_context, options) self.assertIsNotNone(results) - self.assertEqual( - 4, len(results), "should get four results" - ) + self.assertEqual(4, len(results), "should get four results") filtered_results = qna.get_low_score_variation(results) self.assertIsNotNone(filtered_results) - self.assertEqual( - 3, len(filtered_results), "should get three results" - ) + self.assertEqual(3, len(filtered_results), "should get three results") @classmethod async def _get_service_result( From dd24f54461fd9c0e70e36d2de465fda22717a087 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Thu, 30 Apr 2020 03:07:21 -0400 Subject: [PATCH 428/616] Adding draft of Luis Recognizer Refactor and V3 endpoint --- .../botbuilder/ai/luis/luis_recognizer.py | 95 ++++------ .../ai/luis/luis_recognizer_internal.py | 17 ++ .../ai/luis/luis_recognizer_options.py | 13 ++ .../ai/luis/luis_recognizer_options_v2.py | 28 +++ .../ai/luis/luis_recognizer_options_v3.py | 31 +++ .../botbuilder/ai/luis/luis_recognizer_v2.py | 95 ++++++++++ .../botbuilder/ai/luis/luis_recognizer_v3.py | 176 ++++++++++++++++++ 7 files changed, 396 insertions(+), 59 deletions(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 6733e8a1c..c5da18100 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -18,9 +18,14 @@ from botbuilder.schema import ActivityTypes from . import LuisApplication, LuisPredictionOptions, LuisTelemetryConstants -from .activity_util import ActivityUtil + from .luis_util import LuisUtil +from .luis_recognizer_v3 import LuisRecognizerV3 +from .luis_recognizer_v2 import LuisRecognizerV2 +from .luis_recognizer_options_v2 import LuisRecognizerOptionsV2 +from .luis_recognizer_options_v3 import LuisRecognizerOptionsV3 + class LuisRecognizer(Recognizer): """ @@ -36,7 +41,7 @@ class LuisRecognizer(Recognizer): def __init__( self, application: Union[LuisApplication, str], - prediction_options: LuisPredictionOptions = None, + prediction_options: Union[LuisRecognizerOptionsV2 , LuisRecognizerOptionsV3 , LuisPredictionOptions] = None, include_api_results: bool = False, ): """Initializes a new instance of the class. @@ -249,7 +254,7 @@ async def _recognize_internal( turn_context: TurnContext, telemetry_properties: Dict[str, str], telemetry_metrics: Dict[str, float], - luis_prediction_options: LuisPredictionOptions = None, + luis_prediction_options: Union [LuisPredictionOptions, LuisRecognizerOptionsV2, LuisRecognizerOptionsV3] = None, ) -> RecognizerResult: BotAssert.context_not_none(turn_context) @@ -259,10 +264,9 @@ async def _recognize_internal( utterance: str = turn_context.activity.text if turn_context.activity is not None else None recognizer_result: RecognizerResult = None - luis_result: LuisResult = None if luis_prediction_options: - options = self._merge_options(luis_prediction_options) + options = luis_prediction_options else: options = self._options @@ -271,71 +275,44 @@ async def _recognize_internal( text=utterance, intents={"": IntentScore(score=1.0)}, entities={} ) else: - luis_result = self._runtime.prediction.resolve( - self._application.application_id, - utterance, - timezone_offset=options.timezone_offset, - verbose=options.include_all_intents, - staging=options.staging, - spell_check=options.spell_check, - bing_spell_check_subscription_key=options.bing_spell_check_subscription_key, - log=options.log if options.log is not None else True, - ) - recognizer_result = RecognizerResult( - text=utterance, - altered_text=luis_result.altered_query, - intents=LuisUtil.get_intents(luis_result), - entities=LuisUtil.extract_entities_and_metadata( - luis_result.entities, - luis_result.composite_entities, - options.include_instance_data - if options.include_instance_data is not None - else True, - ), - ) - LuisUtil.add_properties(luis_result, recognizer_result) - if self._include_api_results: - recognizer_result.properties["luisResult"] = luis_result + luis_recognizer = self._build_recognizer(options) + recognizer_result = await luis_recognizer.recognizer_internal(turn_context) # Log telemetry self.on_recognizer_result( recognizer_result, turn_context, telemetry_properties, telemetry_metrics ) - await self._emit_trace_info( - turn_context, luis_result, recognizer_result, options - ) - return recognizer_result - async def _emit_trace_info( - self, - turn_context: TurnContext, - luis_result: LuisResult, - recognizer_result: RecognizerResult, - options: LuisPredictionOptions, - ) -> None: - trace_info: Dict[str, object] = { - "recognizerResult": LuisUtil.recognizer_result_as_dict(recognizer_result), - "luisModel": {"ModelID": self._application.application_id}, - "luisOptions": {"Staging": options.staging}, - "luisResult": LuisUtil.luis_result_as_dict(luis_result), - } - - trace_activity = ActivityUtil.create_trace( - turn_context.activity, - "LuisRecognizer", - trace_info, - LuisRecognizer.luis_trace_type, - LuisRecognizer.luis_trace_label, - ) - - await turn_context.send_activity(trace_activity) - def _merge_options( - self, user_defined_options: LuisPredictionOptions + self, user_defined_options: Union [LuisRecognizerOptionsV3, LuisRecognizerOptionsV2, LuisPredictionOptions] ) -> LuisPredictionOptions: merged_options = LuisPredictionOptions() merged_options.__dict__.update(user_defined_options.__dict__) return merged_options + + def _build_recognizer( + self, luis_prediction_options: Union [LuisRecognizerOptionsV3, LuisRecognizerOptionsV2, LuisPredictionOptions] + ): + if isinstance(luis_prediction_options, LuisRecognizerOptionsV3): + return LuisRecognizerV3(self._application, luis_prediction_options) + elif isinstance(luis_prediction_options, LuisRecognizerOptionsV2): + return LuisRecognizerV3(self._application, luis_prediction_options) + else: + recognizer_options = LuisRecognizerOptionsV2( + luis_prediction_options.bing_spell_check_subscription_key, + luis_prediction_options.include_all_intents, + luis_prediction_options.include_instance_data, + luis_prediction_options.log, + luis_prediction_options.spell_check, + luis_prediction_options.staging, + luis_prediction_options.timeout, + luis_prediction_options.timezone_offset, + self._include_api_results, + luis_prediction_options.telemetry_client, + luis_prediction_options.log_personal_information) + return LuisRecognizerV2(self._application, recognizer_options) + + diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py new file mode 100644 index 000000000..33710bf93 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from botbuilder.core import TurnContext +from .luis_application import LuisApplication + + +class LuisRecognizerInternal(ABC): + def __init__( + self, luis_application: LuisApplication + ): + if luis_application is None: + raise TypeError(luis_application.__class__.__name__) + + self.luis_application = luis_application + + @abstractmethod + async def recognizer_internal(self, turn_context: TurnContext): + raise NotImplementedError() diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py new file mode 100644 index 000000000..36fb32d95 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py @@ -0,0 +1,13 @@ +from botbuilder.core import BotTelemetryClient, NullTelemetryClient + + +class LuisRecognizerOptions: + def __init__( + self, + include_api_results: bool = None, + telemetry_client: BotTelemetryClient = NullTelemetryClient(), + log_personal_information: bool = False, + ): + self.include_api_results = include_api_results + self.telemetry_client = telemetry_client + self.log_personal_information = log_personal_information diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py new file mode 100644 index 000000000..ab39995d1 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py @@ -0,0 +1,28 @@ +from botbuilder.core import BotTelemetryClient, NullTelemetryClient +from .luis_recognizer_options import LuisRecognizerOptions + + +class LuisRecognizerOptionsV2(LuisRecognizerOptions): + def __init__( + self, + bing_spell_check_subscription_key: str = None, + include_all_intents: bool = None, + include_instance_data: bool = True, + log: bool = True, + spell_check: bool = None, + staging: bool = None, + timeout: float = 100000, + timezone_offset: float = None, + include_api_results: bool = True, + telemetry_client: BotTelemetryClient = NullTelemetryClient(), + log_personal_information: bool = False, + ): + super().__init__(include_api_results, telemetry_client, log_personal_information) + self.bing_spell_check_subscription_key = bing_spell_check_subscription_key + self.include_all_intents = include_all_intents + self.include_instance_data = include_instance_data + self.log = log + self.spell_check = spell_check + self.staging = staging + self.timeout = timeout + self.timezone_offset = timezone_offset diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py new file mode 100644 index 000000000..c84d0ad39 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py @@ -0,0 +1,31 @@ +from typing import List + +from botbuilder.core import BotTelemetryClient, NullTelemetryClient +from .luis_recognizer_options import LuisRecognizerOptions + + +class LuisRecognizerOptionsV3(LuisRecognizerOptions): + def __init__( + self, + + include_all_intents: bool = False, + include_instance_data: bool = True, + log: bool = True, + prefer_external_entities: bool = False, + dynamic_lists: List = None, + external_entities: List = None, + slot: str = 'production' or 'staging', + version: str = None, + include_api_results: bool = True, + telemetry_client: BotTelemetryClient = NullTelemetryClient(), + log_personal_information: bool = False, + ): + super().__init__(include_api_results, telemetry_client, log_personal_information) + self.include_all_intents = include_all_intents + self.include_instance_data = include_instance_data + self.log = log + self.prefer_external_entities = prefer_external_entities + self.dynamic_lists =dynamic_lists + self.external_entities = external_entities + self.slot = slot + self.version: str = version diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py new file mode 100644 index 000000000..4e6c2db5f --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py @@ -0,0 +1,95 @@ + +from typing import Dict +from .luis_recognizer_internal import LuisRecognizerInternal +from .luis_recognizer_options_v2 import LuisRecognizerOptionsV2 +from .luis_application import LuisApplication +from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient +from azure.cognitiveservices.language.luis.runtime.models import LuisResult +from msrest.authentication import CognitiveServicesCredentials +from .luis_util import LuisUtil +from botbuilder.core import ( + RecognizerResult, + TurnContext, +) +from .activity_util import ActivityUtil + + +class LuisRecognizerV2(LuisRecognizerInternal): + + # The value type for a LUIS trace activity. + luis_trace_type: str = "https://www.luis.ai/schemas/trace" + + # The context label for a LUIS trace activity. + luis_trace_label: str = "Luis Trace" + + def __init__(self, luis_application: LuisApplication, luis_recognizer_options_v2: LuisRecognizerOptionsV2 = None): + super().__init__(luis_application) + credentials = CognitiveServicesCredentials(luis_application.endpoint_key) + self._runtime = LUISRuntimeClient(luis_application.endpoint, credentials) + self._runtime.config.add_user_agent(LuisUtil.get_user_agent()) + self._runtime.config.connection.timeout = luis_recognizer_options_v2.timeout // 1000 + self.luis_recognizer_options_v2 = luis_recognizer_options_v2 or LuisRecognizerOptionsV2() + self._application = luis_application + + async def recognizer_internal( + self, + turn_context: TurnContext): + + utterance: str = turn_context.activity.text if turn_context.activity is not None else None + luis_result: LuisResult = self._runtime.prediction.resolve( + self._application.application_id, + utterance, + timezone_offset=self.luis_recognizer_options_v2.timezone_offset, + verbose=self.luis_recognizer_options_v2.include_all_intents, + staging=self.luis_recognizer_options_v2.staging, + spell_check=self.luis_recognizer_options_v2.spell_check, + bing_spell_check_subscription_key=self.luis_recognizer_options_v2.bing_spell_check_subscription_key, + log=self.luis_recognizer_options_v2.log if self.luis_recognizer_options_v2.log is not None else True, + ) + + recognizer_result: RecognizerResult = RecognizerResult( + text=utterance, + altered_text=luis_result.altered_query, + intents=LuisUtil.get_intents(luis_result), + entities=LuisUtil.extract_entities_and_metadata( + luis_result.entities, + luis_result.composite_entities, + self.luis_recognizer_options_v2.include_instance_data + if self.luis_recognizer_options_v2.include_instance_data is not None + else True, + ), + ) + + LuisUtil.add_properties(luis_result, recognizer_result) + if self.luis_recognizer_options_v2.include_api_results: + recognizer_result.properties["luisResult"] = luis_result + + await self._emit_trace_info( + turn_context, luis_result, recognizer_result, self.luis_recognizer_options_v2 + ) + + return recognizer_result + + async def _emit_trace_info( + self, + turn_context: TurnContext, + luis_result: LuisResult, + recognizer_result: RecognizerResult, + options: LuisRecognizerOptionsV2, + ) -> None: + trace_info: Dict[str, object] = { + "recognizerResult": LuisUtil.recognizer_result_as_dict(recognizer_result), + "luisModel": {"ModelID": self._application.application_id}, + "luisOptions": {"Staging": options.staging}, + "luisResult": LuisUtil.luis_result_as_dict(luis_result), + } + + trace_activity = ActivityUtil.create_trace( + turn_context.activity, + "LuisRecognizer", + trace_info, + LuisRecognizerV2.luis_trace_type, + LuisRecognizerV2.luis_trace_label, + ) + + await turn_context.send_activity(trace_activity) \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py new file mode 100644 index 000000000..b24e50820 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -0,0 +1,176 @@ +import aiohttp +import asyncio +import json + +from typing import Dict +from .luis_recognizer_internal import LuisRecognizerInternal +from .luis_recognizer_options_v3 import LuisRecognizerOptionsV3 +from .luis_application import LuisApplication + +from botbuilder.core import ( + RecognizerResult, + TurnContext, +) +from .activity_util import ActivityUtil + + +class LuisRecognizerV3(LuisRecognizerInternal): + _dateSubtypes = ["date", "daterange", "datetime", "datetimerange", "duration", "set", "time", "timerange"] + _geographySubtypes = ["poi", "city", "countryRegion", "continent", "state"] + _metadata_key = "$instance" + # The value type for a LUIS trace activity. + luis_trace_type: str = "https://www.luis.ai/schemas/trace" + + # The context label for a LUIS trace activity. + luis_trace_label: str = "Luis Trace" + + def __init__(self, luis_application: LuisApplication, luis_recognizer_options_v3: LuisRecognizerOptionsV3 = None): + super().__init__(luis_application) + + self.luis_recognizer_options_v3 = luis_recognizer_options_v3 or LuisRecognizerOptionsV3() + self._application = luis_application + + async def recognizer_internal( + self, + turn_context: TurnContext): + recognizer_result: RecognizerResult = None + + utterance: str = turn_context.activity.text if turn_context.activity is not None else None + + url = self._build_url() + body = self._build_request(utterance) + headers = { + 'Ocp-Apim-Subscription-Key': self.luis_application.endpoint_key, + 'Content-Type': 'application/json' + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=body, headers=headers) as result: + luis_result = await result.json() + recognizer_result["intents"] = self._get_intents(luis_result["prediction"]) + recognizer_result["entities"] = self._extract_entities_and_metadata(luis_result["prediction"]) + + return recognizer_result + + def _build_url(self): + + base_uri = self._application.endpoint or 'https://westus.api.cognitive.microsoft.com'; + uri = "%s/luis/prediction/v3.0/apps/%s" % (base_uri, self._application.application_id) + + if (self.luis_recognizer_options_v3.version): + uri += "versions/%/predict" % (self.luis_recognizer_options_v3.version) + else: + uri += "slots/%/predict" % (self.luis_recognizer_options_v3.slot) + + params = "?verbose=%s&show-all-intents=%s&log=%s" % ( + "true" if self.luis_recognizer_options_v3.include_instance_data else "false", + "true" if self.luis_recognizer_options_v3.include_all_intents else "false", + "true" if self.luis_recognizer_options_v3.log else "false") + + return uri + params + + def _build_request(self, utterance: str): + body = { + 'query': utterance, + 'preferExternalEntities' : self.luis_recognizer_options_v3.prefer_external_entities + } + + if self.luis_recognizer_options_v3.dynamic_lists: + body["dynamicLists"] = self.luis_recognizer_options_v3.dynamic_lists + + if self.luis_recognizer_options_v3.external_entities: + body["externalEntities"] = self.luis_recognizer_options_v3.external_entities + + return body + + def _get_intents(self, luisResult): + intents = {} + if not luisResult["intents"]: + return intents + + for intent in luisResult["intents"]: + intents[self._normalize(intent)] = {'score': luisResult["intents"][intent]["score"]} + + return intents + + def _normalize(self, entity): + splitEntity = entity.split(":") + entityName = splitEntity[-1] + return entityName + + def _extract_entities_and_metadata(self, luisResult): + entities = luisResult["entities"] + return self._map_properties(entities, False) + + def _map_properties(self, source, inInstance): + + if isinstance(source, int) or isinstance(source, float): + return source + + result = source + if isinstance(source, list): + narr = [] + for item in source: + isGeographyV2 = "" + if isinstance(item, dict) and "type" in item and item["type"] in self._geographySubtypes: + isGeographyV2 = item["type"] + + if inInstance and isGeographyV2: + geoEntity = {} + for itemProps in item: + if itemProps == "value": + geoEntity["location"] = item[itemProps] + + geoEntity["type"] = isGeographyV2 + narr.append(geoEntity) + else: + narr.append(self._map_properties(item, inInstance)) + + result = narr + + elif not isinstance(source, str): + nobj = {} + if not inInstance and isinstance(source, dict) and "type" in source and isinstance(source["type"], str) and \ + source["type"] in self._dateSubtypes: + timexs = source["values"] + arr = [] + if timexs: + unique = [] + for elt in timexs: + if elt["timex"] and elt["timex"] in unique: + unique.append(elt["timex"]) + + for timex in unique: + arr.append(timex) + + nobj["timex"] = arr + + nobj["type"] = source["type"] + + else: + for property in source: + name = property + isArray = isinstance(source[property], list) + isString = isinstance(source[property], str) + isInt = isinstance(source[property], int) + val = self._map_properties(source[property], inInstance or property == self._metadata_key) + if name == "datetime" and isArray: + nobj["datetimeV1"] = val + + elif name == "datetimeV2" and isArray: + nobj["datetime"] = val + + elif inInstance: + if name == "length" and isInt: + nobj["endIndex"] = source[name] + source["startIndex"] + elif not ((isInt and name == "modelTypeId") or + (isString and name == "role")): + nobj[name] = val + else: + if name == "unit" and isString: + nobj.units = val + else: + nobj[name] = val + + result = nobj + return result From b77ea7ccb54d03a8ce0f0954d25e22898325d576 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Thu, 30 Apr 2020 03:20:27 -0400 Subject: [PATCH 429/616] Run Black --- .../botbuilder/ai/luis/luis_recognizer.py | 23 +++-- .../ai/luis/luis_recognizer_options_v2.py | 4 +- .../ai/luis/luis_recognizer_options_v3.py | 9 +- .../botbuilder/ai/luis/luis_recognizer_v2.py | 30 ++++--- .../botbuilder/ai/luis/luis_recognizer_v3.py | 88 ++++++++++++++----- 5 files changed, 108 insertions(+), 46 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index c5da18100..7194de9ac 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -41,7 +41,9 @@ class LuisRecognizer(Recognizer): def __init__( self, application: Union[LuisApplication, str], - prediction_options: Union[LuisRecognizerOptionsV2 , LuisRecognizerOptionsV3 , LuisPredictionOptions] = None, + prediction_options: Union[ + LuisRecognizerOptionsV2, LuisRecognizerOptionsV3, LuisPredictionOptions + ] = None, include_api_results: bool = False, ): """Initializes a new instance of the class. @@ -254,7 +256,9 @@ async def _recognize_internal( turn_context: TurnContext, telemetry_properties: Dict[str, str], telemetry_metrics: Dict[str, float], - luis_prediction_options: Union [LuisPredictionOptions, LuisRecognizerOptionsV2, LuisRecognizerOptionsV3] = None, + luis_prediction_options: Union[ + LuisPredictionOptions, LuisRecognizerOptionsV2, LuisRecognizerOptionsV3 + ] = None, ) -> RecognizerResult: BotAssert.context_not_none(turn_context) @@ -287,14 +291,20 @@ async def _recognize_internal( return recognizer_result def _merge_options( - self, user_defined_options: Union [LuisRecognizerOptionsV3, LuisRecognizerOptionsV2, LuisPredictionOptions] + self, + user_defined_options: Union[ + LuisRecognizerOptionsV3, LuisRecognizerOptionsV2, LuisPredictionOptions + ], ) -> LuisPredictionOptions: merged_options = LuisPredictionOptions() merged_options.__dict__.update(user_defined_options.__dict__) return merged_options def _build_recognizer( - self, luis_prediction_options: Union [LuisRecognizerOptionsV3, LuisRecognizerOptionsV2, LuisPredictionOptions] + self, + luis_prediction_options: Union[ + LuisRecognizerOptionsV3, LuisRecognizerOptionsV2, LuisPredictionOptions + ], ): if isinstance(luis_prediction_options, LuisRecognizerOptionsV3): return LuisRecognizerV3(self._application, luis_prediction_options) @@ -312,7 +322,6 @@ def _build_recognizer( luis_prediction_options.timezone_offset, self._include_api_results, luis_prediction_options.telemetry_client, - luis_prediction_options.log_personal_information) + luis_prediction_options.log_personal_information, + ) return LuisRecognizerV2(self._application, recognizer_options) - - diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py index ab39995d1..f8d4198c4 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py @@ -17,7 +17,9 @@ def __init__( telemetry_client: BotTelemetryClient = NullTelemetryClient(), log_personal_information: bool = False, ): - super().__init__(include_api_results, telemetry_client, log_personal_information) + super().__init__( + include_api_results, telemetry_client, log_personal_information + ) self.bing_spell_check_subscription_key = bing_spell_check_subscription_key self.include_all_intents = include_all_intents self.include_instance_data = include_instance_data diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py index c84d0ad39..be2377a4f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py @@ -7,25 +7,26 @@ class LuisRecognizerOptionsV3(LuisRecognizerOptions): def __init__( self, - include_all_intents: bool = False, include_instance_data: bool = True, log: bool = True, prefer_external_entities: bool = False, dynamic_lists: List = None, external_entities: List = None, - slot: str = 'production' or 'staging', + slot: str = "production" or "staging", version: str = None, include_api_results: bool = True, telemetry_client: BotTelemetryClient = NullTelemetryClient(), log_personal_information: bool = False, ): - super().__init__(include_api_results, telemetry_client, log_personal_information) + super().__init__( + include_api_results, telemetry_client, log_personal_information + ) self.include_all_intents = include_all_intents self.include_instance_data = include_instance_data self.log = log self.prefer_external_entities = prefer_external_entities - self.dynamic_lists =dynamic_lists + self.dynamic_lists = dynamic_lists self.external_entities = external_entities self.slot = slot self.version: str = version diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py index 4e6c2db5f..3abda74d2 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py @@ -1,4 +1,3 @@ - from typing import Dict from .luis_recognizer_internal import LuisRecognizerInternal from .luis_recognizer_options_v2 import LuisRecognizerOptionsV2 @@ -22,18 +21,24 @@ class LuisRecognizerV2(LuisRecognizerInternal): # The context label for a LUIS trace activity. luis_trace_label: str = "Luis Trace" - def __init__(self, luis_application: LuisApplication, luis_recognizer_options_v2: LuisRecognizerOptionsV2 = None): + def __init__( + self, + luis_application: LuisApplication, + luis_recognizer_options_v2: LuisRecognizerOptionsV2 = None, + ): super().__init__(luis_application) credentials = CognitiveServicesCredentials(luis_application.endpoint_key) self._runtime = LUISRuntimeClient(luis_application.endpoint, credentials) self._runtime.config.add_user_agent(LuisUtil.get_user_agent()) - self._runtime.config.connection.timeout = luis_recognizer_options_v2.timeout // 1000 - self.luis_recognizer_options_v2 = luis_recognizer_options_v2 or LuisRecognizerOptionsV2() + self._runtime.config.connection.timeout = ( + luis_recognizer_options_v2.timeout // 1000 + ) + self.luis_recognizer_options_v2 = ( + luis_recognizer_options_v2 or LuisRecognizerOptionsV2() + ) self._application = luis_application - async def recognizer_internal( - self, - turn_context: TurnContext): + async def recognizer_internal(self, turn_context: TurnContext): utterance: str = turn_context.activity.text if turn_context.activity is not None else None luis_result: LuisResult = self._runtime.prediction.resolve( @@ -44,7 +49,9 @@ async def recognizer_internal( staging=self.luis_recognizer_options_v2.staging, spell_check=self.luis_recognizer_options_v2.spell_check, bing_spell_check_subscription_key=self.luis_recognizer_options_v2.bing_spell_check_subscription_key, - log=self.luis_recognizer_options_v2.log if self.luis_recognizer_options_v2.log is not None else True, + log=self.luis_recognizer_options_v2.log + if self.luis_recognizer_options_v2.log is not None + else True, ) recognizer_result: RecognizerResult = RecognizerResult( @@ -65,7 +72,10 @@ async def recognizer_internal( recognizer_result.properties["luisResult"] = luis_result await self._emit_trace_info( - turn_context, luis_result, recognizer_result, self.luis_recognizer_options_v2 + turn_context, + luis_result, + recognizer_result, + self.luis_recognizer_options_v2, ) return recognizer_result @@ -92,4 +102,4 @@ async def _emit_trace_info( LuisRecognizerV2.luis_trace_label, ) - await turn_context.send_activity(trace_activity) \ No newline at end of file + await turn_context.send_activity(trace_activity) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py index b24e50820..3d5bff6c5 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -15,7 +15,16 @@ class LuisRecognizerV3(LuisRecognizerInternal): - _dateSubtypes = ["date", "daterange", "datetime", "datetimerange", "duration", "set", "time", "timerange"] + _dateSubtypes = [ + "date", + "daterange", + "datetime", + "datetimerange", + "duration", + "set", + "time", + "timerange", + ] _geographySubtypes = ["poi", "city", "countryRegion", "continent", "state"] _metadata_key = "$instance" # The value type for a LUIS trace activity. @@ -24,15 +33,19 @@ class LuisRecognizerV3(LuisRecognizerInternal): # The context label for a LUIS trace activity. luis_trace_label: str = "Luis Trace" - def __init__(self, luis_application: LuisApplication, luis_recognizer_options_v3: LuisRecognizerOptionsV3 = None): + def __init__( + self, + luis_application: LuisApplication, + luis_recognizer_options_v3: LuisRecognizerOptionsV3 = None, + ): super().__init__(luis_application) - self.luis_recognizer_options_v3 = luis_recognizer_options_v3 or LuisRecognizerOptionsV3() + self.luis_recognizer_options_v3 = ( + luis_recognizer_options_v3 or LuisRecognizerOptionsV3() + ) self._application = luis_application - async def recognizer_internal( - self, - turn_context: TurnContext): + async def recognizer_internal(self, turn_context: TurnContext): recognizer_result: RecognizerResult = None utterance: str = turn_context.activity.text if turn_context.activity is not None else None @@ -40,39 +53,51 @@ async def recognizer_internal( url = self._build_url() body = self._build_request(utterance) headers = { - 'Ocp-Apim-Subscription-Key': self.luis_application.endpoint_key, - 'Content-Type': 'application/json' + "Ocp-Apim-Subscription-Key": self.luis_application.endpoint_key, + "Content-Type": "application/json", } async with aiohttp.ClientSession() as session: async with session.post(url, json=body, headers=headers) as result: luis_result = await result.json() - recognizer_result["intents"] = self._get_intents(luis_result["prediction"]) - recognizer_result["entities"] = self._extract_entities_and_metadata(luis_result["prediction"]) + recognizer_result["intents"] = self._get_intents( + luis_result["prediction"] + ) + recognizer_result["entities"] = self._extract_entities_and_metadata( + luis_result["prediction"] + ) return recognizer_result def _build_url(self): - base_uri = self._application.endpoint or 'https://westus.api.cognitive.microsoft.com'; - uri = "%s/luis/prediction/v3.0/apps/%s" % (base_uri, self._application.application_id) + base_uri = ( + self._application.endpoint or "https://westus.api.cognitive.microsoft.com" + ) + uri = "%s/luis/prediction/v3.0/apps/%s" % ( + base_uri, + self._application.application_id, + ) - if (self.luis_recognizer_options_v3.version): + if self.luis_recognizer_options_v3.version: uri += "versions/%/predict" % (self.luis_recognizer_options_v3.version) else: uri += "slots/%/predict" % (self.luis_recognizer_options_v3.slot) params = "?verbose=%s&show-all-intents=%s&log=%s" % ( - "true" if self.luis_recognizer_options_v3.include_instance_data else "false", + "true" + if self.luis_recognizer_options_v3.include_instance_data + else "false", "true" if self.luis_recognizer_options_v3.include_all_intents else "false", - "true" if self.luis_recognizer_options_v3.log else "false") + "true" if self.luis_recognizer_options_v3.log else "false", + ) return uri + params def _build_request(self, utterance: str): body = { - 'query': utterance, - 'preferExternalEntities' : self.luis_recognizer_options_v3.prefer_external_entities + "query": utterance, + "preferExternalEntities": self.luis_recognizer_options_v3.prefer_external_entities, } if self.luis_recognizer_options_v3.dynamic_lists: @@ -89,7 +114,9 @@ def _get_intents(self, luisResult): return intents for intent in luisResult["intents"]: - intents[self._normalize(intent)] = {'score': luisResult["intents"][intent]["score"]} + intents[self._normalize(intent)] = { + "score": luisResult["intents"][intent]["score"] + } return intents @@ -112,7 +139,11 @@ def _map_properties(self, source, inInstance): narr = [] for item in source: isGeographyV2 = "" - if isinstance(item, dict) and "type" in item and item["type"] in self._geographySubtypes: + if ( + isinstance(item, dict) + and "type" in item + and item["type"] in self._geographySubtypes + ): isGeographyV2 = item["type"] if inInstance and isGeographyV2: @@ -130,8 +161,13 @@ def _map_properties(self, source, inInstance): elif not isinstance(source, str): nobj = {} - if not inInstance and isinstance(source, dict) and "type" in source and isinstance(source["type"], str) and \ - source["type"] in self._dateSubtypes: + if ( + not inInstance + and isinstance(source, dict) + and "type" in source + and isinstance(source["type"], str) + and source["type"] in self._dateSubtypes + ): timexs = source["values"] arr = [] if timexs: @@ -153,7 +189,9 @@ def _map_properties(self, source, inInstance): isArray = isinstance(source[property], list) isString = isinstance(source[property], str) isInt = isinstance(source[property], int) - val = self._map_properties(source[property], inInstance or property == self._metadata_key) + val = self._map_properties( + source[property], inInstance or property == self._metadata_key + ) if name == "datetime" and isArray: nobj["datetimeV1"] = val @@ -163,8 +201,10 @@ def _map_properties(self, source, inInstance): elif inInstance: if name == "length" and isInt: nobj["endIndex"] = source[name] + source["startIndex"] - elif not ((isInt and name == "modelTypeId") or - (isString and name == "role")): + elif not ( + (isInt and name == "modelTypeId") + or (isString and name == "role") + ): nobj[name] = val else: if name == "unit" and isString: From 6da16dc572229817b020a8c4131a9cefeadb782e Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Thu, 30 Apr 2020 03:21:48 -0400 Subject: [PATCH 430/616] Run black on file --- .../botbuilder/ai/luis/luis_recognizer_internal.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py index 33710bf93..b20410e39 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py @@ -4,9 +4,7 @@ class LuisRecognizerInternal(ABC): - def __init__( - self, luis_application: LuisApplication - ): + def __init__(self, luis_application: LuisApplication): if luis_application is None: raise TypeError(luis_application.__class__.__name__) From 825e55dd7970fc94df024556188fa7550676d918 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Thu, 30 Apr 2020 03:54:55 -0400 Subject: [PATCH 431/616] Fixing Pylint errors --- .../botbuilder/ai/luis/luis_recognizer.py | 33 +++--- .../botbuilder/ai/luis/luis_recognizer_v2.py | 11 +- .../botbuilder/ai/luis/luis_recognizer_v3.py | 104 +++++++++--------- 3 files changed, 77 insertions(+), 71 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 7194de9ac..2a1ddfaae 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -5,7 +5,6 @@ from typing import Dict, List, Tuple, Union from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient -from azure.cognitiveservices.language.luis.runtime.models import LuisResult from msrest.authentication import CognitiveServicesCredentials from botbuilder.core import ( @@ -308,20 +307,20 @@ def _build_recognizer( ): if isinstance(luis_prediction_options, LuisRecognizerOptionsV3): return LuisRecognizerV3(self._application, luis_prediction_options) - elif isinstance(luis_prediction_options, LuisRecognizerOptionsV2): + if isinstance(luis_prediction_options, LuisRecognizerOptionsV2): return LuisRecognizerV3(self._application, luis_prediction_options) - else: - recognizer_options = LuisRecognizerOptionsV2( - luis_prediction_options.bing_spell_check_subscription_key, - luis_prediction_options.include_all_intents, - luis_prediction_options.include_instance_data, - luis_prediction_options.log, - luis_prediction_options.spell_check, - luis_prediction_options.staging, - luis_prediction_options.timeout, - luis_prediction_options.timezone_offset, - self._include_api_results, - luis_prediction_options.telemetry_client, - luis_prediction_options.log_personal_information, - ) - return LuisRecognizerV2(self._application, recognizer_options) + + recognizer_options = LuisRecognizerOptionsV2( + luis_prediction_options.bing_spell_check_subscription_key, + luis_prediction_options.include_all_intents, + luis_prediction_options.include_instance_data, + luis_prediction_options.log, + luis_prediction_options.spell_check, + luis_prediction_options.staging, + luis_prediction_options.timeout, + luis_prediction_options.timezone_offset, + self._include_api_results, + luis_prediction_options.telemetry_client, + luis_prediction_options.log_personal_information, + ) + return LuisRecognizerV2(self._application, recognizer_options) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py index 3abda74d2..9e182488b 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py @@ -1,15 +1,16 @@ from typing import Dict -from .luis_recognizer_internal import LuisRecognizerInternal -from .luis_recognizer_options_v2 import LuisRecognizerOptionsV2 -from .luis_application import LuisApplication from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient from azure.cognitiveservices.language.luis.runtime.models import LuisResult from msrest.authentication import CognitiveServicesCredentials -from .luis_util import LuisUtil from botbuilder.core import ( - RecognizerResult, TurnContext, + RecognizerResult, ) +from .luis_recognizer_internal import LuisRecognizerInternal +from .luis_recognizer_options_v2 import LuisRecognizerOptionsV2 +from .luis_application import LuisApplication +from .luis_util import LuisUtil + from .activity_util import ActivityUtil diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py index 3d5bff6c5..cc3cfa3ac 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -1,17 +1,19 @@ import aiohttp -import asyncio -import json - -from typing import Dict -from .luis_recognizer_internal import LuisRecognizerInternal -from .luis_recognizer_options_v3 import LuisRecognizerOptionsV3 -from .luis_application import LuisApplication - from botbuilder.core import ( RecognizerResult, TurnContext, ) -from .activity_util import ActivityUtil +from .luis_recognizer_internal import LuisRecognizerInternal +from .luis_recognizer_options_v3 import LuisRecognizerOptionsV3 +from .luis_application import LuisApplication + + +# from .activity_util import ActivityUtil +# +# import asyncio +# import json +# +# from typing import Dict class LuisRecognizerV3(LuisRecognizerInternal): @@ -60,11 +62,15 @@ async def recognizer_internal(self, turn_context: TurnContext): async with aiohttp.ClientSession() as session: async with session.post(url, json=body, headers=headers) as result: luis_result = await result.json() - recognizer_result["intents"] = self._get_intents( - luis_result["prediction"] - ) - recognizer_result["entities"] = self._extract_entities_and_metadata( - luis_result["prediction"] + + recognizer_result = RecognizerResult( + text=utterance, + # intents = self._get_intents( + # luis_result["prediction"] + # ), + entities=self._extract_entities_and_metadata( + luis_result["prediction"] + ), ) return recognizer_result @@ -80,9 +86,9 @@ def _build_url(self): ) if self.luis_recognizer_options_v3.version: - uri += "versions/%/predict" % (self.luis_recognizer_options_v3.version) + uri += "/versions/%s/predict" % (self.luis_recognizer_options_v3.version) else: - uri += "slots/%/predict" % (self.luis_recognizer_options_v3.slot) + uri += "/slots/%s/predict" % (self.luis_recognizer_options_v3.slot) params = "?verbose=%s&show-all-intents=%s&log=%s" % ( "true" @@ -108,61 +114,61 @@ def _build_request(self, utterance: str): return body - def _get_intents(self, luisResult): + def _get_intents(self, luis_result): intents = {} - if not luisResult["intents"]: + if not luis_result["intents"]: return intents - for intent in luisResult["intents"]: + for intent in luis_result["intents"]: intents[self._normalize(intent)] = { - "score": luisResult["intents"][intent]["score"] + "score": luis_result["intents"][intent]["score"] } return intents def _normalize(self, entity): - splitEntity = entity.split(":") - entityName = splitEntity[-1] - return entityName + split_entity = entity.split(":") + entity_name = split_entity[-1] + return entity_name - def _extract_entities_and_metadata(self, luisResult): - entities = luisResult["entities"] + def _extract_entities_and_metadata(self, luis_result): + entities = luis_result["entities"] return self._map_properties(entities, False) - def _map_properties(self, source, inInstance): + def _map_properties(self, source, in_instance): - if isinstance(source, int) or isinstance(source, float): + if isinstance(source, (int, float)): return source result = source if isinstance(source, list): narr = [] for item in source: - isGeographyV2 = "" + is_geography_v2 = "" if ( isinstance(item, dict) and "type" in item and item["type"] in self._geographySubtypes ): - isGeographyV2 = item["type"] + is_geography_v2 = item["type"] - if inInstance and isGeographyV2: - geoEntity = {} - for itemProps in item: - if itemProps == "value": - geoEntity["location"] = item[itemProps] + if in_instance and is_geography_v2: + geo_entity = {} + for item_props in item: + if item_props == "value": + geo_entity["location"] = item[item_props] - geoEntity["type"] = isGeographyV2 - narr.append(geoEntity) + geo_entity["type"] = is_geography_v2 + narr.append(geo_entity) else: - narr.append(self._map_properties(item, inInstance)) + narr.append(self._map_properties(item, in_instance)) result = narr elif not isinstance(source, str): nobj = {} if ( - not inInstance + not in_instance and isinstance(source, dict) and "type" in source and isinstance(source["type"], str) @@ -186,28 +192,28 @@ def _map_properties(self, source, inInstance): else: for property in source: name = property - isArray = isinstance(source[property], list) - isString = isinstance(source[property], str) - isInt = isinstance(source[property], int) + is_array = isinstance(source[property], list) + is_string = isinstance(source[property], str) + is_int = isinstance(source[property], int) val = self._map_properties( - source[property], inInstance or property == self._metadata_key + source[property], in_instance or property == self._metadata_key ) - if name == "datetime" and isArray: + if name == "datetime" and is_array: nobj["datetimeV1"] = val - elif name == "datetimeV2" and isArray: + elif name == "datetimeV2" and is_array: nobj["datetime"] = val - elif inInstance: - if name == "length" and isInt: + elif in_instance: + if name == "length" and is_int: nobj["endIndex"] = source[name] + source["startIndex"] elif not ( - (isInt and name == "modelTypeId") - or (isString and name == "role") + (is_int and name == "modelTypeId") + or (is_string and name == "role") ): nobj[name] = val else: - if name == "unit" and isString: + if name == "unit" and is_string: nobj.units = val else: nobj[name] = val From 806a862a2378ff91d207f09a46228f818c4b3499 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Fri, 1 May 2020 09:38:32 -0700 Subject: [PATCH 432/616] Add tenant_id and user_role to TeamsChannelAccount --- .../botbuilder/schema/teams/_models.py | 8 ++++++++ .../botbuilder/schema/teams/_models_py3.py | 12 +++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 29372b73b..7b82da917 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1537,6 +1537,10 @@ class TeamsChannelAccount(ChannelAccount): :type email: str :param user_principal_name: Unique user principal name :type user_principal_name: str + :param tenant_id: Tenant Id of the user. + :type tenant_id: str + :param user_role: User Role of the user. + :type user_role: str """ _attribute_map = { @@ -1547,6 +1551,8 @@ class TeamsChannelAccount(ChannelAccount): "email": {"key": "email", "type": "str"}, "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, "aad_object_id": {"key": "objectId", "type": "str"}, + "tenant_id": {"key": "tenantId", "type": "str"}, + "user_role": {"key": "userRole", "type": "str"}, } def __init__(self, **kwargs): @@ -1555,6 +1561,8 @@ def __init__(self, **kwargs): self.surname = kwargs.get("surname", None) self.email = kwargs.get("email", None) self.user_principal_name = kwargs.get("userPrincipalName", None) + self.tenant_id = kwargs.get("tenantId", None) + self.user_role = kwargs.get("userRole", None) class TeamsPagedMembersResult(PagedMembersResult): diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 9cea699cd..e8be1dc85 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1800,8 +1800,12 @@ class TeamsChannelAccount(ChannelAccount): :type surname: str :param email: Email Id of the user. :type email: str - :param user_principal_name: Unique user principal name + :param user_principal_name: Unique user principal name. :type user_principal_name: str + :param tenant_id: Tenant Id of the user. + :type tenant_id: str + :param user_role: User Role of the user. + :type user_role: str """ _attribute_map = { @@ -1812,6 +1816,8 @@ class TeamsChannelAccount(ChannelAccount): "email": {"key": "email", "type": "str"}, "user_principal_name": {"key": "userPrincipalName", "type": "str"}, "aad_object_id": {"key": "objectId", "type": "str"}, + "tenant_id": {"key": "tenantId", "type": "str"}, + "user_role": {"key": "userRole", "type": "str"}, } def __init__( @@ -1823,6 +1829,8 @@ def __init__( surname: str = None, email: str = None, user_principal_name: str = None, + tenant_id: str = None, + user_role: str = None, **kwargs ) -> None: super(TeamsChannelAccount, self).__init__(id=id, name=name, **kwargs) @@ -1830,6 +1838,8 @@ def __init__( self.surname = surname self.email = email self.user_principal_name = user_principal_name + self.tenant_id = tenant_id + self.user_role = user_role class TeamsPagedMembersResult(PagedMembersResult): From 5f276a4cc6593f4dcee69d55b4c33b93fe125bac Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Fri, 1 May 2020 14:24:09 -0400 Subject: [PATCH 433/616] Adding tests to Luis V3 endpoint --- .../botbuilder/ai/luis/__init__.py | 2 + .../botbuilder/ai/luis/luis_recognizer.py | 5 +- .../botbuilder/ai/luis/luis_recognizer_v3.py | 33 +- .../tests/luis/luis_recognizer_v3_test.py | 175 +++ .../tests/luis/test_data/Composite1_v3.json | 1285 +++++++++++++++++ .../tests/luis/test_data/Composite2_v3.json | 312 ++++ .../tests/luis/test_data/Composite3_v3.json | 315 ++++ 7 files changed, 2108 insertions(+), 19 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Composite1_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Composite3_v3.json diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py index cbee8bdc2..823d15dd9 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py @@ -2,12 +2,14 @@ # Licensed under the MIT License. from .luis_application import LuisApplication +from .luis_recognizer_options_v3 import LuisRecognizerOptionsV3 from .luis_prediction_options import LuisPredictionOptions from .luis_telemetry_constants import LuisTelemetryConstants from .luis_recognizer import LuisRecognizer __all__ = [ "LuisApplication", + "LuisRecognizerOptionsV3", "LuisPredictionOptions", "LuisRecognizer", "LuisTelemetryConstants", diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 2a1ddfaae..413efb039 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -70,11 +70,12 @@ def __init__( self.telemetry_client = self._options.telemetry_client self.log_personal_information = self._options.log_personal_information - credentials = CognitiveServicesCredentials(self._application.endpoint_key) self._runtime = LUISRuntimeClient(self._application.endpoint, credentials) self._runtime.config.add_user_agent(LuisUtil.get_user_agent()) - self._runtime.config.connection.timeout = self._options.timeout // 1000 + + if isinstance(prediction_options, LuisPredictionOptions): + self._runtime.config.connection.timeout = self._options.timeout // 1000 @staticmethod def top_intent( diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py index cc3cfa3ac..4de0820fa 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -1,5 +1,6 @@ import aiohttp from botbuilder.core import ( + IntentScore, RecognizerResult, TurnContext, ) @@ -9,11 +10,6 @@ # from .activity_util import ActivityUtil -# -# import asyncio -# import json -# -# from typing import Dict class LuisRecognizerV3(LuisRecognizerInternal): @@ -65,14 +61,19 @@ async def recognizer_internal(self, turn_context: TurnContext): recognizer_result = RecognizerResult( text=utterance, - # intents = self._get_intents( - # luis_result["prediction"] - # ), + intents=self._get_intents(luis_result["prediction"]), entities=self._extract_entities_and_metadata( luis_result["prediction"] ), ) + if self.luis_recognizer_options_v3.include_instance_data: + recognizer_result.entities[self._metadata_key] = ( + recognizer_result.entities[self._metadata_key] + if recognizer_result.entities[self._metadata_key] + else {} + ) + return recognizer_result def _build_url(self): @@ -120,9 +121,7 @@ def _get_intents(self, luis_result): return intents for intent in luis_result["intents"]: - intents[self._normalize(intent)] = { - "score": luis_result["intents"][intent]["score"] - } + intents[intent] = IntentScore(luis_result["intents"][intent]["score"]) return intents @@ -137,7 +136,7 @@ def _extract_entities_and_metadata(self, luis_result): def _map_properties(self, source, in_instance): - if isinstance(source, (int, float)): + if isinstance(source, (int, float, bool, str)): return source result = source @@ -152,7 +151,7 @@ def _map_properties(self, source, in_instance): ): is_geography_v2 = item["type"] - if in_instance and is_geography_v2: + if not in_instance and is_geography_v2: geo_entity = {} for item_props in item: if item_props == "value": @@ -179,7 +178,7 @@ def _map_properties(self, source, in_instance): if timexs: unique = [] for elt in timexs: - if elt["timex"] and elt["timex"] in unique: + if elt["timex"] and elt["timex"] not in unique: unique.append(elt["timex"]) for timex in unique: @@ -191,10 +190,10 @@ def _map_properties(self, source, in_instance): else: for property in source: - name = property + name = self._normalize(property) is_array = isinstance(source[property], list) is_string = isinstance(source[property], str) - is_int = isinstance(source[property], int) + is_int = isinstance(source[property], (int, float)) val = self._map_properties( source[property], in_instance or property == self._metadata_key ) @@ -214,7 +213,7 @@ def _map_properties(self, source, in_instance): nobj[name] = val else: if name == "unit" and is_string: - nobj.units = val + nobj["units"] = val else: nobj[name] = val diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py new file mode 100644 index 000000000..0abfa26f3 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# pylint: disable=protected-access + +import json +from os import path +from typing import Dict, Tuple, Union + +from aiounittest import AsyncTestCase + +from botbuilder.ai.luis import LuisRecognizerOptionsV3 + +from asynctest import CoroutineMock, patch + +from botbuilder.ai.luis import LuisApplication, LuisPredictionOptions, LuisRecognizer +from botbuilder.ai.luis.luis_util import LuisUtil +from botbuilder.core import ( + BotAdapter, + IntentScore, + RecognizerResult, + TurnContext, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) + + +class LuisRecognizerV3Test(AsyncTestCase): + _luisAppId: str = "b31aeaf3-3511-495b-a07f-571fc873214b" + _subscriptionKey: str = "048ec46dc58e495482b0c447cfdbd291" + _endpoint: str = "https://westus.api.cognitive.microsoft.com" + + def __init__(self, *args, **kwargs): + super(LuisRecognizerV3Test, self).__init__(*args, **kwargs) + self._mocked_results: RecognizerResult = RecognizerResult( + intents={"Test": IntentScore(score=0.2), "Greeting": IntentScore(score=0.4)} + ) + self._empty_luis_response: Dict[str, object] = json.loads( + '{ "query": null, "intents": [], "entities": [] }' + ) + + @staticmethod + def _remove_none_property(dictionary: Dict[str, object]) -> Dict[str, object]: + for key, value in list(dictionary.items()): + if value is None: + del dictionary[key] + elif isinstance(value, dict): + LuisRecognizerV3Test._remove_none_property(value) + return dictionary + + @classmethod + @patch('aiohttp.ClientSession.post') + async def _get_recognizer_result( + cls, + utterance: str, + response_json: Union[str, Dict[str, object]], + mock_get, + bot_adapter: BotAdapter = TestAdapter(), + options: Union[LuisRecognizerOptionsV3, LuisPredictionOptions] = None, + include_api_results: bool = False, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + recognizer_class: type = LuisRecognizer, + + ) -> Tuple[LuisRecognizer, RecognizerResult]: + if isinstance(response_json, str): + response_json = LuisRecognizerV3Test._get_json_for_file( + response_file=response_json + ) + + recognizer = LuisRecognizerV3Test._get_luis_recognizer( + recognizer_class, include_api_results=include_api_results, options=options + ) + context = LuisRecognizerV3Test._get_context(utterance, bot_adapter) + mock_get.return_value.__aenter__.return_value.json = CoroutineMock(side_effect=[response_json]) + + result = await recognizer.recognize( + context, telemetry_properties, telemetry_metrics + ) + return recognizer, result + + @classmethod + def _get_json_for_file(cls, response_file: str) -> Dict[str, object]: + curr_dir = path.dirname(path.abspath(__file__)) + response_path = path.join(curr_dir, "test_data", response_file) + + with open(response_path, "r", encoding="utf-8-sig") as file: + response_str = file.read() + response_json = json.loads(response_str) + return response_json + + @classmethod + def _get_luis_recognizer( + cls, + recognizer_class: type, + options: Union[LuisPredictionOptions, LuisRecognizerOptionsV3] = None, + include_api_results: bool = False, + ) -> LuisRecognizer: + luis_app = LuisApplication(cls._luisAppId, cls._subscriptionKey, cls._endpoint) + + if isinstance(options, LuisRecognizerOptionsV3): + LuisRecognizerOptionsV3.include_api_results = include_api_results + + return recognizer_class( + luis_app, + prediction_options=options, + include_api_results=include_api_results, + ) + + @staticmethod + def _get_context(utterance: str, bot_adapter: BotAdapter) -> TurnContext: + activity = Activity( + type=ActivityTypes.message, + text=utterance, + conversation=ConversationAccount(), + recipient=ChannelAccount(), + from_property=ChannelAccount(), + ) + return TurnContext(bot_adapter, activity) + + # Luis V3 endpoint tests begin here + async def _test_json_v3(self, response_file: str) -> None: + # Arrange + expected_json = LuisRecognizerV3Test._get_json_for_file(response_file) + response_json = expected_json["v3"]["response"] + utterance = expected_json.get("text") + if utterance is None: + utterance = expected_json.get("Text") + + test_options = expected_json["v3"]["options"] + + options = LuisRecognizerOptionsV3( + include_all_intents = test_options["includeAllIntents"], + include_instance_data=test_options["includeInstanceData"], + log=test_options["log"], + prefer_external_entities=test_options["preferExternalEntities"], + slot=test_options["slot"], + include_api_results = test_options["includeAPIResults"], + ) + + if "version" in test_options: + options.version=test_options["version"] + + # dynamic_lists: List = None, + # external_entities: List = None, + # telemetry_client: BotTelemetryClient = NullTelemetryClient(), + # log_personal_information: bool = False,) + # , + + # Act + _, result = await LuisRecognizerV3Test._get_recognizer_result( + utterance, response_json, options=options, include_api_results=True + ) + + # Assert + actual_result_json = LuisUtil.recognizer_result_as_dict(result) + del expected_json["v3"] + del expected_json["sentiment"] + trimmed_expected = LuisRecognizerV3Test._remove_none_property(expected_json) + trimmed_actual = LuisRecognizerV3Test._remove_none_property(actual_result_json) + self.assertEqual(trimmed_expected, trimmed_actual) + + async def test_composite1_v3(self): + await self._test_json_v3("Composite1_v3.json") + + async def test_composite2_v3(self): + await self._test_json_v3("Composite2_v3.json") + + async def test_composite3_v3(self): + await self._test_json_v3("Composite3_v3.json") \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite1_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite1_v3.json new file mode 100644 index 000000000..5d1266497 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite1_v3.json @@ -0,0 +1,1285 @@ +{ + "text": "12 years old and 3 days old and monday july 3rd, 2019 and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c and the next one and the previous one", + "intents": { + "Cancel": { + "score": 0.00000156337478 + }, + "Delivery": { + "score": 0.0002846266 + }, + "EntityTests": { + "score": 0.953405857 + }, + "Greeting": { + "score": 8.20979437e-7 + }, + "Help": { + "score": 0.00000481870757 + }, + "None": { + "score": 0.01040122 + }, + "Roles": { + "score": 0.197366714 + }, + "search": { + "score": 0.14049834 + }, + "SpecifyName": { + "score": 0.000137732946 + }, + "Travel": { + "score": 0.0100996653 + }, + "Weather_GetForecast": { + "score": 0.0143940123 + } + }, + "entities": { + "$instance": { + "Composite1": [ + { + "endIndex": 306, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.880988955, + "startIndex": 0, + "text": "12 years old and 3 days old and monday july 3rd, 2019 and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c and the next one and the previous one", + "type": "Composite1" + } + ], + "ordinalV2": [ + { + "endIndex": 47, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 44, + "text": "3rd", + "type": "builtin.ordinalV2" + }, + { + "endIndex": 199, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 194, + "text": "first", + "type": "builtin.ordinalV2" + }, + { + "endIndex": 285, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 277, + "text": "next one", + "type": "builtin.ordinalV2.relative" + }, + { + "endIndex": 306, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 294, + "text": "previous one", + "type": "builtin.ordinalV2.relative" + } + ] + }, + "Composite1": [ + { + "$instance": { + "age": [ + { + "endIndex": 12, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "12 years old", + "type": "builtin.age" + }, + { + "endIndex": 27, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "3 days old", + "type": "builtin.age" + } + ], + "datetime": [ + { + "endIndex": 8, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "12 years", + "type": "builtin.datetimeV2.duration" + }, + { + "endIndex": 23, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "3 days", + "type": "builtin.datetimeV2.duration" + }, + { + "endIndex": 53, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 32, + "text": "monday july 3rd, 2019", + "type": "builtin.datetimeV2.date" + }, + { + "endIndex": 70, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 58, + "text": "every monday", + "type": "builtin.datetimeV2.set" + }, + { + "endIndex": 97, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 75, + "text": "between 3am and 5:30am", + "type": "builtin.datetimeV2.timerange" + } + ], + "dimension": [ + { + "endIndex": 109, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 102, + "text": "4 acres", + "type": "builtin.dimension" + }, + { + "endIndex": 127, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 114, + "text": "4 pico meters", + "type": "builtin.dimension" + } + ], + "email": [ + { + "endIndex": 150, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 132, + "text": "chrimc@hotmail.com", + "type": "builtin.email" + } + ], + "money": [ + { + "endIndex": 157, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 155, + "text": "$4", + "type": "builtin.currency" + }, + { + "endIndex": 167, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 162, + "text": "$4.25", + "type": "builtin.currency" + } + ], + "number": [ + { + "endIndex": 2, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "12", + "type": "builtin.number" + }, + { + "endIndex": 18, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "3", + "type": "builtin.number" + }, + { + "endIndex": 53, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 49, + "text": "2019", + "type": "builtin.number" + }, + { + "endIndex": 92, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 91, + "text": "5", + "type": "builtin.number" + }, + { + "endIndex": 103, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 102, + "text": "4", + "type": "builtin.number" + }, + { + "endIndex": 115, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 114, + "text": "4", + "type": "builtin.number" + }, + { + "endIndex": 157, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 156, + "text": "4", + "type": "builtin.number" + }, + { + "endIndex": 167, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 163, + "text": "4.25", + "type": "builtin.number" + }, + { + "endIndex": 179, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 177, + "text": "32", + "type": "builtin.number" + }, + { + "endIndex": 189, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 184, + "text": "210.4", + "type": "builtin.number" + }, + { + "endIndex": 206, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 204, + "text": "10", + "type": "builtin.number" + }, + { + "endIndex": 216, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 212, + "text": "10.5", + "type": "builtin.number" + }, + { + "endIndex": 225, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 222, + "text": "425", + "type": "builtin.number" + }, + { + "endIndex": 229, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 226, + "text": "555", + "type": "builtin.number" + }, + { + "endIndex": 234, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 230, + "text": "1234", + "type": "builtin.number" + }, + { + "endIndex": 240, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 239, + "text": "3", + "type": "builtin.number" + }, + { + "endIndex": 258, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 253, + "text": "-27.5", + "type": "builtin.number" + }, + { + "endIndex": 285, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 282, + "text": "one", + "type": "builtin.number" + }, + { + "endIndex": 306, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 303, + "text": "one", + "type": "builtin.number" + } + ], + "percentage": [ + { + "endIndex": 207, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 204, + "text": "10%", + "type": "builtin.percentage" + }, + { + "endIndex": 217, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 212, + "text": "10.5%", + "type": "builtin.percentage" + } + ], + "phonenumber": [ + { + "endIndex": 234, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9, + "startIndex": 222, + "text": "425-555-1234", + "type": "builtin.phonenumber" + } + ], + "temperature": [ + { + "endIndex": 248, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 239, + "text": "3 degrees", + "type": "builtin.temperature" + }, + { + "endIndex": 268, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 253, + "text": "-27.5 degrees c", + "type": "builtin.temperature" + } + ] + }, + "age": [ + { + "number": 12, + "units": "Year" + }, + { + "number": 3, + "units": "Day" + } + ], + "datetime": [ + { + "timex": [ + "P12Y" + ], + "type": "duration" + }, + { + "timex": [ + "P3D" + ], + "type": "duration" + }, + { + "timex": [ + "2019-07-03" + ], + "type": "date" + }, + { + "timex": [ + "XXXX-WXX-1" + ], + "type": "set" + }, + { + "timex": [ + "(T03,T05:30,PT2H30M)" + ], + "type": "timerange" + } + ], + "dimension": [ + { + "number": 4, + "units": "Acre" + }, + { + "number": 4, + "units": "Picometer" + } + ], + "email": [ + "chrimc@hotmail.com" + ], + "money": [ + { + "number": 4, + "units": "Dollar" + }, + { + "number": 4.25, + "units": "Dollar" + } + ], + "number": [ + 12, + 3, + 2019, + 5, + 4, + 4, + 4, + 4.25, + 32, + 210.4, + 10, + 10.5, + 425, + 555, + 1234, + 3, + -27.5, + 1, + 1 + ], + "percentage": [ + 10, + 10.5 + ], + "phonenumber": [ + "425-555-1234" + ], + "temperature": [ + { + "number": 3, + "units": "Degree" + }, + { + "number": -27.5, + "units": "C" + } + ] + } + ], + "ordinalV2": [ + { + "offset": 3, + "relativeTo": "start" + }, + { + "offset": 1, + "relativeTo": "start" + }, + { + "offset": 1, + "relativeTo": "current" + }, + { + "offset": -1, + "relativeTo": "current" + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "v3": { + "response": { + "prediction": { + "entities": { + "$instance": { + "Composite1": [ + { + "length": 306, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.880988955, + "startIndex": 0, + "text": "12 years old and 3 days old and monday july 3rd, 2019 and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c and the next one and the previous one", + "type": "Composite1" + } + ], + "ordinalV2": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 44, + "text": "3rd", + "type": "builtin.ordinalV2" + }, + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 194, + "text": "first", + "type": "builtin.ordinalV2" + }, + { + "length": 8, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 277, + "text": "next one", + "type": "builtin.ordinalV2.relative" + }, + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 294, + "text": "previous one", + "type": "builtin.ordinalV2.relative" + } + ] + }, + "Composite1": [ + { + "$instance": { + "age": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "12 years old", + "type": "builtin.age" + }, + { + "length": 10, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "3 days old", + "type": "builtin.age" + } + ], + "datetimeV2": [ + { + "length": 8, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "12 years", + "type": "builtin.datetimeV2.duration" + }, + { + "length": 6, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "3 days", + "type": "builtin.datetimeV2.duration" + }, + { + "length": 21, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 32, + "text": "monday july 3rd, 2019", + "type": "builtin.datetimeV2.date" + }, + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 58, + "text": "every monday", + "type": "builtin.datetimeV2.set" + }, + { + "length": 22, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 75, + "text": "between 3am and 5:30am", + "type": "builtin.datetimeV2.timerange" + } + ], + "dimension": [ + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 102, + "text": "4 acres", + "type": "builtin.dimension" + }, + { + "length": 13, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 114, + "text": "4 pico meters", + "type": "builtin.dimension" + } + ], + "email": [ + { + "length": 18, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 132, + "text": "chrimc@hotmail.com", + "type": "builtin.email" + } + ], + "money": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 155, + "text": "$4", + "type": "builtin.currency" + }, + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 162, + "text": "$4.25", + "type": "builtin.currency" + } + ], + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "12", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "3", + "type": "builtin.number" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 49, + "text": "2019", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 91, + "text": "5", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 102, + "text": "4", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 114, + "text": "4", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 156, + "text": "4", + "type": "builtin.number" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 163, + "text": "4.25", + "type": "builtin.number" + }, + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 177, + "text": "32", + "type": "builtin.number" + }, + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 184, + "text": "210.4", + "type": "builtin.number" + }, + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 204, + "text": "10", + "type": "builtin.number" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 212, + "text": "10.5", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 222, + "text": "425", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 226, + "text": "555", + "type": "builtin.number" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 230, + "text": "1234", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 239, + "text": "3", + "type": "builtin.number" + }, + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 253, + "text": "-27.5", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 282, + "text": "one", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 303, + "text": "one", + "type": "builtin.number" + } + ], + "percentage": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 204, + "text": "10%", + "type": "builtin.percentage" + }, + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 212, + "text": "10.5%", + "type": "builtin.percentage" + } + ], + "phonenumber": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "score": 0.9, + "startIndex": 222, + "text": "425-555-1234", + "type": "builtin.phonenumber" + } + ], + "temperature": [ + { + "length": 9, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 239, + "text": "3 degrees", + "type": "builtin.temperature" + }, + { + "length": 15, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 253, + "text": "-27.5 degrees c", + "type": "builtin.temperature" + } + ] + }, + "age": [ + { + "number": 12, + "unit": "Year" + }, + { + "number": 3, + "unit": "Day" + } + ], + "datetimeV2": [ + { + "type": "duration", + "values": [ + { + "timex": "P12Y", + "value": "378432000" + } + ] + }, + { + "type": "duration", + "values": [ + { + "timex": "P3D", + "value": "259200" + } + ] + }, + { + "type": "date", + "values": [ + { + "timex": "2019-07-03", + "value": "2019-07-03" + } + ] + }, + { + "type": "set", + "values": [ + { + "timex": "XXXX-WXX-1", + "value": "not resolved" + } + ] + }, + { + "type": "timerange", + "values": [ + { + "end": "05:30:00", + "start": "03:00:00", + "timex": "(T03,T05:30,PT2H30M)" + } + ] + } + ], + "dimension": [ + { + "number": 4, + "unit": "Acre" + }, + { + "number": 4, + "unit": "Picometer" + } + ], + "email": [ + "chrimc@hotmail.com" + ], + "money": [ + { + "number": 4, + "unit": "Dollar" + }, + { + "number": 4.25, + "unit": "Dollar" + } + ], + "number": [ + 12, + 3, + 2019, + 5, + 4, + 4, + 4, + 4.25, + 32, + 210.4, + 10, + 10.5, + 425, + 555, + 1234, + 3, + -27.5, + 1, + 1 + ], + "percentage": [ + 10, + 10.5 + ], + "phonenumber": [ + "425-555-1234" + ], + "temperature": [ + { + "number": 3, + "unit": "Degree" + }, + { + "number": -27.5, + "unit": "C" + } + ] + } + ], + "ordinalV2": [ + { + "offset": 3, + "relativeTo": "start" + }, + { + "offset": 1, + "relativeTo": "start" + }, + { + "offset": 1, + "relativeTo": "current" + }, + { + "offset": -1, + "relativeTo": "current" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.00000156337478 + }, + "Delivery": { + "score": 0.0002846266 + }, + "EntityTests": { + "score": 0.953405857 + }, + "Greeting": { + "score": 8.20979437e-7 + }, + "Help": { + "score": 0.00000481870757 + }, + "None": { + "score": 0.01040122 + }, + "Roles": { + "score": 0.197366714 + }, + "search": { + "score": 0.14049834 + }, + "SpecifyName": { + "score": 0.000137732946 + }, + "Travel": { + "score": 0.0100996653 + }, + "Weather_GetForecast": { + "score": 0.0143940123 + } + }, + "normalizedQuery": "12 years old and 3 days old and monday july 3rd, 2019 and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c and the next one and the previous one", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "EntityTests" + }, + "query": "12 years old and 3 days old and monday july 3rd, 2019 and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c and the next one and the previous one" + }, + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json new file mode 100644 index 000000000..21e135b54 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json @@ -0,0 +1,312 @@ +{ + "entities": { + "$instance": { + "Composite2": [ + { + "endIndex": 69, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.97076714, + "startIndex": 0, + "text": "http://foo.com is where you can fly from seattle to dallas via denver", + "type": "Composite2" + } + ], + "geographyV2": [ + { + "endIndex": 48, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 41, + "text": "seattle", + "type": "builtin.geographyV2.city" + } + ] + }, + "Composite2": [ + { + "$instance": { + "City": [ + { + "endIndex": 69, + "modelType": "Hierarchical Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.984581649, + "startIndex": 63, + "text": "denver", + "type": "City" + } + ], + "From": [ + { + "endIndex": 48, + "modelType": "Hierarchical Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.999511, + "startIndex": 41, + "text": "seattle", + "type": "City::From" + } + ], + "To": [ + { + "endIndex": 58, + "modelType": "Hierarchical Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9984612, + "startIndex": 52, + "text": "dallas", + "type": "City::To" + } + ], + "url": [ + { + "endIndex": 14, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "http://foo.com", + "type": "builtin.url" + } + ] + }, + "City": [ + "denver" + ], + "From": [ + "seattle" + ], + "To": [ + "dallas" + ], + "url": [ + "http://foo.com" + ] + } + ], + "geographyV2": [ + { + "location": "seattle", + "type": "city" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.000227437369 + }, + "Delivery": { + "score": 0.001310123 + }, + "EntityTests": { + "score": 0.94500196 + }, + "Greeting": { + "score": 0.000152356763 + }, + "Help": { + "score": 0.000547201431 + }, + "None": { + "score": 0.004187195 + }, + "Roles": { + "score": 0.0300086979 + }, + "search": { + "score": 0.0108942846 + }, + "SpecifyName": { + "score": 0.00168467627 + }, + "Travel": { + "score": 0.0154484725 + }, + "Weather_GetForecast": { + "score": 0.0237181056 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "http://foo.com is where you can fly from seattle to dallas via denver", + "v3": { + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Composite2": [ + { + "length": 69, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.97076714, + "startIndex": 0, + "text": "http://foo.com is where you can fly from seattle to dallas via denver", + "type": "Composite2" + } + ], + "geographyV2": [ + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 41, + "text": "seattle", + "type": "builtin.geographyV2.city" + } + ] + }, + "Composite2": [ + { + "$instance": { + "City": [ + { + "length": 6, + "modelType": "Hierarchical Entity Extractor", + "modelTypeId": 3, + "recognitionSources": [ + "model" + ], + "score": 0.984581649, + "startIndex": 63, + "text": "denver", + "type": "City" + } + ], + "City::From": [ + { + "length": 7, + "modelType": "Hierarchical Entity Extractor", + "modelTypeId": 3, + "recognitionSources": [ + "model" + ], + "score": 0.999511, + "startIndex": 41, + "text": "seattle", + "type": "City::From" + } + ], + "City::To": [ + { + "length": 6, + "modelType": "Hierarchical Entity Extractor", + "modelTypeId": 3, + "recognitionSources": [ + "model" + ], + "score": 0.9984612, + "startIndex": 52, + "text": "dallas", + "type": "City::To" + } + ], + "url": [ + { + "length": 14, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "http://foo.com", + "type": "builtin.url" + } + ] + }, + "City": [ + "denver" + ], + "City::From": [ + "seattle" + ], + "City::To": [ + "dallas" + ], + "url": [ + "http://foo.com" + ] + } + ], + "geographyV2": [ + { + "type": "city", + "value": "seattle" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.000227437369 + }, + "Delivery": { + "score": 0.001310123 + }, + "EntityTests": { + "score": 0.94500196 + }, + "Greeting": { + "score": 0.000152356763 + }, + "Help": { + "score": 0.000547201431 + }, + "None": { + "score": 0.004187195 + }, + "Roles": { + "score": 0.0300086979 + }, + "search": { + "score": 0.0108942846 + }, + "SpecifyName": { + "score": 0.00168467627 + }, + "Travel": { + "score": 0.0154484725 + }, + "Weather_GetForecast": { + "score": 0.0237181056 + } + }, + "normalizedQuery": "http://foo.com is where you can fly from seattle to dallas via denver", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "EntityTests" + }, + "query": "http://foo.com is where you can fly from seattle to dallas via denver" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite3_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite3_v3.json new file mode 100644 index 000000000..fe55aba56 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite3_v3.json @@ -0,0 +1,315 @@ +{ + "text": "Deliver from 12345 VA to 12346 WA", + "intents": { + "Cancel": { + "score": 1.01764708e-9 + }, + "Delivery": { + "score": 0.00238572317 + }, + "EntityTests": { + "score": 4.757576e-10 + }, + "Greeting": { + "score": 1.0875e-9 + }, + "Help": { + "score": 1.01764708e-9 + }, + "None": { + "score": 0.00000117844979 + }, + "Roles": { + "score": 0.999911964 + }, + "search": { + "score": 0.000009494859 + }, + "SpecifyName": { + "score": 3.0666667e-9 + }, + "Travel": { + "score": 0.00000309763345 + }, + "Weather_GetForecast": { + "score": 0.00000102792524 + } + }, + "entities": { + "$instance": { + "Destination": [ + { + "endIndex": 33, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9818366, + "startIndex": 25, + "text": "12346 WA", + "type": "Address" + } + ], + "Source": [ + { + "endIndex": 21, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9345161, + "startIndex": 13, + "text": "12345 VA", + "type": "Address" + } + ] + }, + "Destination": [ + { + "$instance": { + "number": [ + { + "endIndex": 30, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 25, + "text": "12346", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 33, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9893861, + "startIndex": 31, + "text": "WA", + "type": "State" + } + ] + }, + "number": [ + 12346 + ], + "State": [ + "WA" + ] + } + ], + "Source": [ + { + "$instance": { + "number": [ + { + "endIndex": 18, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 13, + "text": "12345", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 21, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.941649556, + "startIndex": 19, + "text": "VA", + "type": "State" + } + ] + }, + "number": [ + 12345 + ], + "State": [ + "VA" + ] + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "v3": { + "response": { + "prediction": { + "entities": { + "$instance": { + "Destination": [ + { + "length": 8, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "role": "Destination", + "score": 0.9818366, + "startIndex": 25, + "text": "12346 WA", + "type": "Address" + } + ], + "Source": [ + { + "length": 8, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "role": "Source", + "score": 0.9345161, + "startIndex": 13, + "text": "12345 VA", + "type": "Address" + } + ] + }, + "Destination": [ + { + "$instance": { + "number": [ + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 25, + "text": "12346", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "score": 0.9893861, + "startIndex": 31, + "text": "WA", + "type": "State" + } + ] + }, + "number": [ + 12346 + ], + "State": [ + "WA" + ] + } + ], + "Source": [ + { + "$instance": { + "number": [ + { + "length": 5, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 13, + "text": "12345", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "score": 0.941649556, + "startIndex": 19, + "text": "VA", + "type": "State" + } + ] + }, + "number": [ + 12345 + ], + "State": [ + "VA" + ] + } + ] + }, + "intents": { + "Cancel": { + "score": 1.01764708e-9 + }, + "Delivery": { + "score": 0.00238572317 + }, + "EntityTests": { + "score": 4.757576e-10 + }, + "Greeting": { + "score": 1.0875e-9 + }, + "Help": { + "score": 1.01764708e-9 + }, + "None": { + "score": 0.00000117844979 + }, + "Roles": { + "score": 0.999911964 + }, + "search": { + "score": 0.000009494859 + }, + "SpecifyName": { + "score": 3.0666667e-9 + }, + "Travel": { + "score": 0.00000309763345 + }, + "Weather_GetForecast": { + "score": 0.00000102792524 + } + }, + "normalizedQuery": "deliver from 12345 va to 12346 wa", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Roles" + }, + "query": "Deliver from 12345 VA to 12346 WA" + }, + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production", + "version": "GeoPeople" + } + } +} \ No newline at end of file From a129014224354d0fa85b562ea5a8a82577306ad7 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 1 May 2020 13:30:42 -0500 Subject: [PATCH 434/616] Support SSO with skill dialog and expected replies --- .../botbuilder/core/adapters/test_adapter.py | 21 ++ .../botbuilder/core/bot_framework_adapter.py | 6 +- .../dialogs/prompts/oauth_prompt.py | 22 +- .../skills/begin_skill_dialog_options.py | 13 +- .../botbuilder/dialogs/skills/skill_dialog.py | 149 +++++++--- .../tests/test_skill_dialog.py | 274 +++++++++++++++++- .../botbuilder/schema/_models_py3.py | 23 ++ 7 files changed, 446 insertions(+), 62 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 8215e70bd..69100983d 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -92,6 +92,7 @@ def __init__(self, key: UserToken = None, magic_code: str = None): class TestAdapter(BotAdapter, ExtendedUserTokenProvider): __test__ = False + __EXCEPTION_EXPECTED = "ExceptionExpected" def __init__( self, @@ -446,6 +447,23 @@ def add_exchangeable_token( ) self.exchangeable_tokens[key.to_key()] = key + def throw_on_exchange_request( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ): + key = ExchangeableToken( + connection_name=connection_name, + channel_id=channel_id, + user_id=user_id, + exchangeable_item=exchangeable_item, + token=TestAdapter.__EXCEPTION_EXPECTED, + ) + + self.exchangeable_tokens[key.to_key()] = key + async def get_sign_in_resource_from_user( self, turn_context: TurnContext, @@ -504,6 +522,9 @@ async def exchange_token_from_credentials( token_exchange_response = self.exchangeable_tokens.get(key.to_key()) if token_exchange_response: + if token_exchange_response.token == TestAdapter.__EXCEPTION_EXPECTED: + raise Exception("Exception occurred during exchanging tokens") + return TokenResponse( channel_id=key.channel_id, connection_name=key.connection_name, diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index f081a425c..34a4b59f1 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -1236,7 +1236,7 @@ async def exchange_token_from_credentials( turn_context, oauth_app_credentials ) - return client.user_token.exchange_async( + result = client.user_token.exchange_async( user_id, connection_name, turn_context.activity.channel_id, @@ -1244,6 +1244,10 @@ async def exchange_token_from_credentials( exchange_request.token, ) + if isinstance(result, TokenResponse): + return result + raise TypeError(f"exchange_async returned improper result: {type(result)}") + @staticmethod def key_for_connector_client(service_url: str, app_id: str, scope: str): return f"{service_url if service_url else ''}:{app_id if app_id else ''}:{scope if scope else ''}" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 4ce769442..95b2d840c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -460,13 +460,21 @@ async def _recognize_token(self, context: TurnContext) -> PromptRecognizerResult else: # No errors. Proceed with token exchange. extended_user_token_provider: ExtendedUserTokenProvider = context.adapter - token_exchange_response = await extended_user_token_provider.exchange_token_from_credentials( - context, - self._settings.oath_app_credentials, - self._settings.connection_name, - context.activity.from_property.id, - TokenExchangeRequest(token=context.activity.value.token), - ) + + token_exchange_response = None + try: + token_exchange_response = await extended_user_token_provider.exchange_token_from_credentials( + context, + self._settings.oath_app_credentials, + self._settings.connection_name, + context.activity.from_property.id, + TokenExchangeRequest(token=context.activity.value.token), + ) + except: + # Ignore Exceptions + # If token exchange failed for any reason, tokenExchangeResponse above stays null, and + # hence we send back a failure invoke response to the caller. + pass if not token_exchange_response or not token_exchange_response.token: await context.send_activity( diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py index 62a02ab2e..da2d39914 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py @@ -5,14 +5,19 @@ class BeginSkillDialogOptions: - def __init__(self, activity: Activity): # pylint: disable=unused-argument + def __init__( + self, activity: Activity, connection_name: str = None + ): # pylint: disable=unused-argument self.activity = activity + self.connection_name = connection_name @staticmethod def from_object(obj: object) -> "BeginSkillDialogOptions": if isinstance(obj, dict) and "activity" in obj: - return BeginSkillDialogOptions(obj["activity"]) + return BeginSkillDialogOptions(obj["activity"], obj.get("connection_name")) if hasattr(obj, "activity"): - return BeginSkillDialogOptions(obj.activity) - + return BeginSkillDialogOptions( + obj.activity, + obj.connection_name if hasattr(obj, "connection_name") else None, + ) return None diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py index 58c3857e0..f86c8db99 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -4,13 +4,18 @@ from copy import deepcopy from typing import List -from botbuilder.schema import Activity, ActivityTypes, ExpectedReplies, DeliveryModes -from botbuilder.core import ( - BotAdapter, - TurnContext, +from botbuilder.schema import ( + Activity, + ActivityTypes, + ExpectedReplies, + DeliveryModes, + OAuthCard, + SignInConstants, + TokenExchangeInvokeRequest, ) +from botbuilder.core import BotAdapter, TurnContext, ExtendedUserTokenProvider +from botbuilder.core.card_factory import ContentTypes from botbuilder.core.skills import SkillConversationIdFactoryOptions - from botbuilder.dialogs import ( Dialog, DialogContext, @@ -18,6 +23,7 @@ DialogReason, DialogInstance, ) +from botframework.connector.token_api.models import TokenExchangeRequest from .begin_skill_dialog_options import BeginSkillDialogOptions from .skill_dialog_options import SkillDialogOptions @@ -31,6 +37,7 @@ def __init__(self, dialog_options: SkillDialogOptions, dialog_id: str): self.dialog_options = dialog_options self._deliver_mode_state_key = "deliverymode" + self._sso_connection_name_key = "SkillDialog.SSOConnectionName" async def begin_dialog(self, dialog_context: DialogContext, options: object = None): """ @@ -59,8 +66,14 @@ async def begin_dialog(self, dialog_context: DialogContext, options: object = No self._deliver_mode_state_key ] = dialog_args.activity.delivery_mode + dialog_context.active_dialog.state[ + self._sso_connection_name_key + ] = dialog_args.connection_name + # Send the activity to the skill. - eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity) + eoc_activity = await self._send_to_skill( + dialog_context.context, skill_activity, dialog_args.connection_name + ) if eoc_activity: return await dialog_context.end_dialog(eoc_activity.value) @@ -84,23 +97,21 @@ async def continue_dialog(self, dialog_context: DialogContext): dialog_context.context.activity.value ) - # Forward only Message and Event activities to the skill - if ( - dialog_context.context.activity.type == ActivityTypes.message - or dialog_context.context.activity.type == ActivityTypes.event - ): - # Create deep clone of the original activity to avoid altering it before forwarding it. - skill_activity = deepcopy(dialog_context.context.activity) - skill_activity.delivery_mode = dialog_context.active_dialog.state[ - self._deliver_mode_state_key - ] - - # Just forward to the remote skill - eoc_activity = await self._send_to_skill( - dialog_context.context, skill_activity - ) - if eoc_activity: - return await dialog_context.end_dialog(eoc_activity.value) + # Create deep clone of the original activity to avoid altering it before forwarding it. + skill_activity = deepcopy(dialog_context.context.activity) + skill_activity.delivery_mode = dialog_context.active_dialog.state[ + self._deliver_mode_state_key + ] + connection_name = dialog_context.active_dialog.state[ + self._sso_connection_name_key + ] + + # Just forward to the remote skill + eoc_activity = await self._send_to_skill( + dialog_context.context, skill_activity, connection_name + ) + if eoc_activity: + return await dialog_context.end_dialog(eoc_activity.value) return self.end_of_turn @@ -119,6 +130,7 @@ async def reprompt_dialog( # pylint: disable=unused-argument is_incoming=True, ) + # connection Name is not applicable for a RePrompt, as we don't expect as OAuthCard in response. await self._send_to_skill(context, reprompt_event) async def resume_dialog( # pylint: disable=unused-argument @@ -147,6 +159,7 @@ async def end_dialog( activity.channel_data = context.activity.channel_data activity.additional_properties = context.activity.additional_properties + # connection Name is not applicable for an EndDialog, as we don't expect as OAuthCard in response. await self._send_to_skill(context, activity) await super().end_dialog(context, instance, reason) @@ -168,20 +181,10 @@ def _validate_begin_dialog_args(options: object) -> BeginSkillDialogOptions: "SkillDialog: activity object in options as BeginSkillDialogOptions cannot be None." ) - # Only accept Message or Event activities - if ( - dialog_args.activity.type != ActivityTypes.message - and dialog_args.activity.type != ActivityTypes.event - ): - raise TypeError( - f"Only {ActivityTypes.message} and {ActivityTypes.event} activities are supported." - f" Received activity of type {dialog_args.activity.type}." - ) - return dialog_args async def _send_to_skill( - self, context: TurnContext, activity: Activity, + self, context: TurnContext, activity: Activity, connection_name: str = None ) -> Activity: # Create a conversationId to interact with the skill and send the activity conversation_id_factory_options = SkillConversationIdFactoryOptions( @@ -226,8 +229,86 @@ async def _send_to_skill( if from_skill_activity.type == ActivityTypes.end_of_conversation: # Capture the EndOfConversation activity if it was sent from skill eoc_activity = from_skill_activity + elif await self._intercept_oauth_cards( + context, from_skill_activity, connection_name + ): + # do nothing. Token exchange succeeded, so no oauthcard needs to be shown to the user + pass else: # Send the response back to the channel. await context.send_activity(from_skill_activity) return eoc_activity + + async def _intercept_oauth_cards( + self, context: TurnContext, activity: Activity, connection_name: str + ): + """ + Tells is if we should intercept the OAuthCard message. + """ + if not connection_name or not isinstance( + context.adapter, ExtendedUserTokenProvider + ): + return False + + oauth_card_attachment = next( + attachment + for attachment in activity.attachments + if attachment.content_type == ContentTypes.oauth_card + ) + if oauth_card_attachment: + oauth_card = oauth_card_attachment.content + if ( + oauth_card + and oauth_card.token_exchange_resource + and oauth_card.token_exchange_resource.uri + ): + try: + result = await context.adapter.exchange_token( + turn_context=context, + connection_name=connection_name, + user_id=context.activity.from_property.id, + exchange_request=TokenExchangeRequest( + uri=oauth_card.token_exchange_resource.uri + ), + ) + + if result and result.token: + return await self._send_token_exchange_invoke_to_skill( + activity, + oauth_card.token_exchange_resource.id, + oauth_card.connection_name, + result.token, + ) + except: + return False + + return False + + async def _send_token_exchange_invoke_to_skill( + self, + incoming_activity: Activity, + request_id: str, + connection_name: str, + token: str, + ): + activity = incoming_activity.create_reply() + activity.type = ActivityTypes.invoke + activity.name = SignInConstants.token_exchange_operation_name + activity.value = TokenExchangeInvokeRequest( + id=request_id, token=token, connection_name=connection_name, + ) + + # route the activity to the skill + skill_info = self.dialog_options.skill + response = await self.dialog_options.skill_client.post_activity( + self.dialog_options.bot_id, + skill_info.app_id, + skill_info.skill_endpoint, + self.dialog_options.skill_host_endpoint, + incoming_activity.conversation.id, + activity, + ) + + # Check response status: true if success, false if failure + return response.status / 100 == 2 diff --git a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py index cafa17c88..4319d7d61 100644 --- a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py +++ b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py @@ -12,6 +12,7 @@ TurnContext, MessageFactory, ) +from botbuilder.core.card_factory import ContentTypes from botbuilder.core.skills import ( BotFrameworkSkill, ConversationIdFactoryBase, @@ -19,7 +20,17 @@ SkillConversationReference, BotFrameworkClient, ) -from botbuilder.schema import Activity, ActivityTypes, ConversationReference +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + OAuthCard, + Attachment, + ConversationAccount, + ChannelAccount, + ExpectedReplies, + DeliveryModes, +) from botbuilder.testing import DialogTestClient from botbuilder.dialogs import ( @@ -28,6 +39,7 @@ BeginSkillDialogOptions, DialogTurnStatus, ) +from botframework.connector.token_api.models import TokenExchangeResource class SimpleConversationIdFactory(ConversationIdFactoryBase): @@ -91,15 +103,6 @@ async def test_begin_dialog_options_validation(self): with self.assertRaises(TypeError): await client.send_activity("irrelevant") - # Only Message and Event activities are supported - client = DialogTestClient( - "test", - sut, - BeginSkillDialogOptions(Activity(type=ActivityTypes.conversation_update)), - ) - with self.assertRaises(TypeError): - await client.send_activity("irrelevant") - async def test_begin_dialog_calls_skill(self): activity_sent = None from_bot_id_sent = None @@ -123,7 +126,7 @@ async def capture( mock_skill_client = self._create_mock_skill_client(capture) conversation_state = ConversationState(MemoryStorage()) - dialog_options = self._create_skill_dialog_options( + dialog_options = SkillDialogTests.create_skill_dialog_options( conversation_state, mock_skill_client ) @@ -171,7 +174,7 @@ async def capture( mock_skill_client = self._create_mock_skill_client(capture) conversation_state = ConversationState(MemoryStorage()) - dialog_options = self._create_skill_dialog_options( + dialog_options = SkillDialogTests.create_skill_dialog_options( conversation_state, mock_skill_client ) @@ -199,7 +202,7 @@ async def test_should_throw_on_post_failure(self): mock_skill_client = self._create_mock_skill_client(None, 500) conversation_state = ConversationState(MemoryStorage()) - dialog_options = self._create_skill_dialog_options( + dialog_options = SkillDialogTests.create_skill_dialog_options( conversation_state, mock_skill_client ) @@ -217,8 +220,223 @@ async def test_should_throw_on_post_failure(self): with self.assertRaises(Exception): await client.send_activity("irrelevant") - def _create_skill_dialog_options( - self, conversation_state: ConversationState, skill_client: BotFrameworkClient + async def test_should_intercept_oauth_cards_for_sso(self): + connection_name = "connectionName" + first_response = ExpectedReplies( + activities=[ + SkillDialogTests.create_oauth_card_attachment_activity("https://test") + ] + ) + + sequence = 0 + + async def post_return(): + nonlocal sequence + if sequence == 0: + result = InvokeResponse(body=first_response, status=200) + else: + result = InvokeResponse(status=200) + sequence += 1 + return result + + mock_skill_client = self._create_mock_skill_client(None, post_return) + conversation_state = ConversationState(MemoryStorage()) + + dialog_options = SkillDialogTests.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + sut = SkillDialog(dialog_options, dialog_id="dialog") + activity_to_send = SkillDialogTests.create_send_activity() + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions( + activity=activity_to_send, connection_name=connection_name, + ), + conversation_state=conversation_state, + ) + + client.test_adapter.add_exchangeable_token( + connection_name, "test", "User1", "https://test", "https://test1" + ) + + final_activity = await client.send_activity(MessageFactory.text("irrelevant")) + self.assertIsNone(final_activity) + + async def test_should_not_intercept_oauth_cards_for_empty_connection_name(self): + connection_name = "connectionName" + first_response = ExpectedReplies( + activities=[ + SkillDialogTests.create_oauth_card_attachment_activity("https://test") + ] + ) + + sequence = 0 + + async def post_return(): + nonlocal sequence + if sequence == 0: + result = InvokeResponse(body=first_response, status=200) + else: + result = InvokeResponse(status=200) + sequence += 1 + return result + + mock_skill_client = self._create_mock_skill_client(None, post_return) + conversation_state = ConversationState(MemoryStorage()) + + dialog_options = SkillDialogTests.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + sut = SkillDialog(dialog_options, dialog_id="dialog") + activity_to_send = SkillDialogTests.create_send_activity() + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity=activity_to_send,), + conversation_state=conversation_state, + ) + + client.test_adapter.add_exchangeable_token( + connection_name, "test", "User1", "https://test", "https://test1" + ) + + final_activity = await client.send_activity(MessageFactory.text("irrelevant")) + self.assertIsNotNone(final_activity) + self.assertEqual(len(final_activity.attachments), 1) + + async def test_should_not_intercept_oauth_cards_for_empty_token(self): + first_response = ExpectedReplies( + activities=[ + SkillDialogTests.create_oauth_card_attachment_activity("https://test") + ] + ) + + sequence = 0 + + async def post_return(): + nonlocal sequence + if sequence == 0: + result = InvokeResponse(body=first_response, status=200) + else: + result = InvokeResponse(status=200) + sequence += 1 + return result + + mock_skill_client = self._create_mock_skill_client(None, post_return) + conversation_state = ConversationState(MemoryStorage()) + + dialog_options = SkillDialogTests.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + sut = SkillDialog(dialog_options, dialog_id="dialog") + activity_to_send = SkillDialogTests.create_send_activity() + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity=activity_to_send,), + conversation_state=conversation_state, + ) + + # Don't add exchangeable token to test adapter + + final_activity = await client.send_activity(MessageFactory.text("irrelevant")) + self.assertIsNotNone(final_activity) + self.assertEqual(len(final_activity.attachments), 1) + + async def test_should_not_intercept_oauth_cards_for_token_exception(self): + connection_name = "connectionName" + first_response = ExpectedReplies( + activities=[ + SkillDialogTests.create_oauth_card_attachment_activity("https://test") + ] + ) + + sequence = 0 + + async def post_return(): + nonlocal sequence + if sequence == 0: + result = InvokeResponse(body=first_response, status=200) + else: + result = InvokeResponse(status=200) + sequence += 1 + return result + + mock_skill_client = self._create_mock_skill_client(None, post_return) + conversation_state = ConversationState(MemoryStorage()) + + dialog_options = SkillDialogTests.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + sut = SkillDialog(dialog_options, dialog_id="dialog") + activity_to_send = SkillDialogTests.create_send_activity() + initial_dialog_options = BeginSkillDialogOptions( + activity=activity_to_send, connection_name=connection_name, + ) + + client = DialogTestClient( + "test", sut, initial_dialog_options, conversation_state=conversation_state, + ) + client.test_adapter.throw_on_exchange_request( + connection_name, "test", "User1", "https://test" + ) + + final_activity = await client.send_activity(MessageFactory.text("irrelevant")) + self.assertIsNotNone(final_activity) + self.assertEqual(len(final_activity.attachments), 1) + + async def test_should_not_intercept_oauth_cards_for_bad_request(self): + connection_name = "connectionName" + first_response = ExpectedReplies( + activities=[ + SkillDialogTests.create_oauth_card_attachment_activity("https://test") + ] + ) + + sequence = 0 + + async def post_return(): + nonlocal sequence + if sequence == 0: + result = InvokeResponse(body=first_response, status=200) + else: + result = InvokeResponse(status=409) + sequence += 1 + return result + + mock_skill_client = self._create_mock_skill_client(None, post_return) + conversation_state = ConversationState(MemoryStorage()) + + dialog_options = SkillDialogTests.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + sut = SkillDialog(dialog_options, dialog_id="dialog") + activity_to_send = SkillDialogTests.create_send_activity() + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions( + activity=activity_to_send, connection_name=connection_name, + ), + conversation_state=conversation_state, + ) + + client.test_adapter.add_exchangeable_token( + connection_name, "test", "User1", "https://test", "https://test1" + ) + + final_activity = await client.send_activity(MessageFactory.text("irrelevant")) + self.assertIsNotNone(final_activity) + self.assertEqual(len(final_activity.attachments), 1) + + @staticmethod + def create_skill_dialog_options( + conversation_state: ConversationState, skill_client: BotFrameworkClient ): return SkillDialogOptions( bot_id=str(uuid.uuid4()), @@ -232,8 +450,29 @@ def _create_skill_dialog_options( ), ) + @staticmethod + def create_send_activity() -> Activity: + return Activity( + type=ActivityTypes.message, + delivery_mode=DeliveryModes.expect_replies, + text=str(uuid.uuid4()), + ) + + @staticmethod + def create_oauth_card_attachment_activity(uri: str) -> Activity: + oauth_card = OAuthCard(token_exchange_resource=TokenExchangeResource(uri=uri)) + attachment = Attachment( + content_type=ContentTypes.oauth_card, content=oauth_card, + ) + + attachment_activity = MessageFactory.attachment(attachment) + attachment_activity.conversation = ConversationAccount(id=str(uuid.uuid4())) + attachment_activity.from_property = ChannelAccount(id="blah", name="name") + + return attachment_activity + def _create_mock_skill_client( - self, callback: Callable, return_status: int = 200 + self, callback: Callable, return_status: Union[Callable, int] = 200 ) -> BotFrameworkClient: mock_client = Mock() @@ -255,6 +494,9 @@ async def mock_post_activity( conversation_id, activity, ) + + if isinstance(return_status, Callable): + return await return_status() return InvokeResponse(status=return_status) mock_client.post_activity.side_effect = mock_post_activity diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 81b23c977..13f3856ad 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -8,6 +8,7 @@ # Changes may cause incorrect behavior and will be lost if the code is # regenerated. # -------------------------------------------------------------------------- +from datetime import datetime from msrest.serialization import Model from msrest.exceptions import HttpOperationError @@ -287,6 +288,28 @@ def __init__( self.semantic_action = semantic_action self.caller_id = caller_id + def create_reply(self, text: str = None, locale: str = None): + return Activity( + type="message", + timestamp=datetime.utcnow(), + from_property=ChannelAccount( + id=self.recipient.id if self.recipient else None, + name=self.recipient.name if self.recipient else None, + ), + reply_to_id=self.id, + service_url=self.service_url, + channel_id=self.channel_id, + conversation=ConversationAccount( + is_group=self.conversation.is_group, + id=self.conversation.id, + name=self.conversation.name, + ), + text=text if text else "", + locale=locale if locale else self.locale, + attachments=[], + entities=[], + ) + class AnimationCard(Model): """An animation card (Ex: gif or short video clip). From 76a470b3cec84698f9262519abf447ea975a0b22 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Fri, 1 May 2020 14:38:28 -0400 Subject: [PATCH 435/616] Adding dependency on tests --- libraries/botbuilder-ai/tests/requirements.txt | 1 + pipelines/botbuilder-python-ci.yml | 1 + 2 files changed, 2 insertions(+) create mode 100644 libraries/botbuilder-ai/tests/requirements.txt diff --git a/libraries/botbuilder-ai/tests/requirements.txt b/libraries/botbuilder-ai/tests/requirements.txt new file mode 100644 index 000000000..9dec09f63 --- /dev/null +++ b/libraries/botbuilder-ai/tests/requirements.txt @@ -0,0 +1 @@ +asynctest==0.13.0 \ No newline at end of file diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index 4723d96a3..d5c852b9e 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -56,6 +56,7 @@ jobs: pip install -e ./libraries/botbuilder-integration-aiohttp pip install -r ./libraries/botframework-connector/tests/requirements.txt pip install -r ./libraries/botbuilder-core/tests/requirements.txt + pip install -r ./libraries/botbuilder-ai/tests/requirements.txt pip install coveralls pip install pylint==2.4.4 pip install black From b3732d0a4c63a00860097bd0b02843461c6ba660 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 1 May 2020 13:57:48 -0500 Subject: [PATCH 436/616] pylint fix --- .../botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py index f86c8db99..f0ed30d38 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -9,7 +9,6 @@ ActivityTypes, ExpectedReplies, DeliveryModes, - OAuthCard, SignInConstants, TokenExchangeInvokeRequest, ) From 99b7d88c7cf1a6ad7b0a42c2bb200a15dd096fae Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 04:22:18 -0400 Subject: [PATCH 437/616] Adding missing tests --- .../botbuilder/ai/luis/luis_recognizer.py | 23 +- .../ai/luis/luis_recognizer_options_v3.py | 2 + .../botbuilder/ai/luis/luis_recognizer_v3.py | 76 +- .../tests/luis/luis_recognizer_test.py | 33 - .../tests/luis/luis_recognizer_v3_test.py | 91 +- .../tests/luis/test_data/Composite2_v3.json | 2 +- .../ExternalEntitiesAndBuiltIn_v3.json | 168 ++ .../ExternalEntitiesAndComposite_v3.json | 261 +++ .../test_data/ExternalEntitiesAndList_v3.json | 178 ++ .../ExternalEntitiesAndRegex_v3.json | 167 ++ .../ExternalEntitiesAndSimpleOverride_v3.json | 299 +++ .../ExternalEntitiesAndSimple_v3.json | 292 +++ .../luis/test_data/GeoPeopleOrdinal_v3.json | 321 +++ .../tests/luis/test_data/Minimal_v3.json | 83 + .../test_data/NoEntitiesInstanceTrue_v3.json | 33 + .../tests/luis/test_data/Patterns_v3.json | 262 +++ .../tests/luis/test_data/Prebuilt_v3.json | 246 +++ .../tests/luis/test_data/roles_v3.json | 1759 +++++++++++++++++ 18 files changed, 4233 insertions(+), 63 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndBuiltIn_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndComposite_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndList_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimpleOverride_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimple_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/GeoPeopleOrdinal_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Minimal_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/NoEntitiesInstanceTrue_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Patterns_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Prebuilt_v3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/roles_v3.json diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 413efb039..5167358ab 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -3,10 +3,6 @@ import json from typing import Dict, List, Tuple, Union - -from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient -from msrest.authentication import CognitiveServicesCredentials - from botbuilder.core import ( BotAssert, IntentScore, @@ -15,11 +11,7 @@ TurnContext, ) from botbuilder.schema import ActivityTypes - from . import LuisApplication, LuisPredictionOptions, LuisTelemetryConstants - -from .luis_util import LuisUtil - from .luis_recognizer_v3 import LuisRecognizerV3 from .luis_recognizer_v2 import LuisRecognizerV2 from .luis_recognizer_options_v2 import LuisRecognizerOptionsV2 @@ -65,17 +57,16 @@ def __init__( ) self._options = prediction_options or LuisPredictionOptions() - - self._include_api_results = include_api_results + self._include_api_results = include_api_results or ( + prediction_options.include_api_results + if isinstance( + prediction_options, (LuisRecognizerOptionsV3, LuisRecognizerOptionsV2) + ) + else False + ) self.telemetry_client = self._options.telemetry_client self.log_personal_information = self._options.log_personal_information - credentials = CognitiveServicesCredentials(self._application.endpoint_key) - self._runtime = LUISRuntimeClient(self._application.endpoint, credentials) - self._runtime.config.add_user_agent(LuisUtil.get_user_agent()) - - if isinstance(prediction_options, LuisPredictionOptions): - self._runtime.config.connection.timeout = self._options.timeout // 1000 @staticmethod def top_intent( diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py index be2377a4f..bd5d7f4c1 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py @@ -11,6 +11,7 @@ def __init__( include_instance_data: bool = True, log: bool = True, prefer_external_entities: bool = False, + datetime_reference: str = None, dynamic_lists: List = None, external_entities: List = None, slot: str = "production" or "staging", @@ -26,6 +27,7 @@ def __init__( self.include_instance_data = include_instance_data self.log = log self.prefer_external_entities = prefer_external_entities + self.datetime_reference = datetime_reference self.dynamic_lists = dynamic_lists self.external_entities = external_entities self.slot = slot diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py index 4de0820fa..c73b6c1fd 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -1,4 +1,9 @@ +import re +from typing import Dict + import aiohttp +from botbuilder.ai.luis.activity_util import ActivityUtil +from botbuilder.ai.luis.luis_util import LuisUtil from botbuilder.core import ( IntentScore, RecognizerResult, @@ -25,6 +30,7 @@ class LuisRecognizerV3(LuisRecognizerInternal): ] _geographySubtypes = ["poi", "city", "countryRegion", "continent", "state"] _metadata_key = "$instance" + # The value type for a LUIS trace activity. luis_trace_type: str = "https://www.luis.ai/schemas/trace" @@ -67,11 +73,23 @@ async def recognizer_internal(self, turn_context: TurnContext): ), ) - if self.luis_recognizer_options_v3.include_instance_data: - recognizer_result.entities[self._metadata_key] = ( - recognizer_result.entities[self._metadata_key] - if recognizer_result.entities[self._metadata_key] - else {} + if self.luis_recognizer_options_v3.include_instance_data: + recognizer_result.entities[self._metadata_key] = ( + recognizer_result.entities[self._metadata_key] + if self._metadata_key in recognizer_result.entities + else {} + ) + + if "sentiment" in luis_result["prediction"]: + recognizer_result.properties["sentiment"] = self._get_sentiment( + luis_result["prediction"] + ) + + await self._emit_trace_info( + turn_context, + luis_result, + recognizer_result, + self.luis_recognizer_options_v3, ) return recognizer_result @@ -104,9 +122,16 @@ def _build_url(self): def _build_request(self, utterance: str): body = { "query": utterance, - "preferExternalEntities": self.luis_recognizer_options_v3.prefer_external_entities, + "options": { + "preferExternalEntities": self.luis_recognizer_options_v3.prefer_external_entities, + }, } + if self.luis_recognizer_options_v3.datetime_reference: + body["options"][ + "datetimeReference" + ] = self.luis_recognizer_options_v3.datetime_reference + if self.luis_recognizer_options_v3.dynamic_lists: body["dynamicLists"] = self.luis_recognizer_options_v3.dynamic_lists @@ -121,14 +146,19 @@ def _get_intents(self, luis_result): return intents for intent in luis_result["intents"]: - intents[intent] = IntentScore(luis_result["intents"][intent]["score"]) + intents[self._normalize_name(intent)] = IntentScore( + luis_result["intents"][intent]["score"] + ) return intents + def _normalize_name(self, name): + return re.sub(r"\.", "_", name) + def _normalize(self, entity): split_entity = entity.split(":") entity_name = split_entity[-1] - return entity_name + return self._normalize_name(entity_name) def _extract_entities_and_metadata(self, luis_result): entities = luis_result["entities"] @@ -219,3 +249,33 @@ def _map_properties(self, source, in_instance): result = nobj return result + + def _get_sentiment(self, luis_result): + return { + "label": luis_result["sentiment"]["label"], + "score": luis_result["sentiment"]["score"], + } + + async def _emit_trace_info( + self, + turn_context: TurnContext, + luis_result, + recognizer_result: RecognizerResult, + options: LuisRecognizerOptionsV3, + ) -> None: + trace_info: Dict[str, object] = { + "recognizerResult": LuisUtil.recognizer_result_as_dict(recognizer_result), + "luisModel": {"ModelID": self._application.application_id}, + "luisOptions": {"Slot": options.slot}, + "luisResult": luis_result, + } + + trace_activity = ActivityUtil.create_trace( + turn_context.activity, + "LuisRecognizer", + trace_info, + LuisRecognizerV3.luis_trace_type, + LuisRecognizerV3.luis_trace_label, + ) + + await turn_context.send_activity(trace_activity) diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index d6bc0c4df..753c3e0bb 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -70,21 +70,6 @@ def test_luis_recognizer_construction(self): self.assertEqual("048ec46dc58e495482b0c447cfdbd291", app.endpoint_key) self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) - def test_luis_recognizer_timeout(self): - endpoint = ( - "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/" - "b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360" - "&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" - ) - expected_timeout = 300 - options_with_timeout = LuisPredictionOptions(timeout=expected_timeout * 1000) - - recognizer_with_timeout = LuisRecognizer(endpoint, options_with_timeout) - - self.assertEqual( - expected_timeout, recognizer_with_timeout._runtime.config.connection.timeout - ) - def test_none_endpoint(self): # Arrange my_app = LuisApplication( @@ -418,24 +403,6 @@ def test_top_intent_returns_top_intent_if_score_equals_min_score(self): ) self.assertEqual(default_intent, "Greeting") - async def test_user_agent_contains_product_version(self): - utterance: str = "please book from May 5 to June 6" - response_path: str = "MultipleDateTimeEntities.json" # it doesn't matter to use which file. - - recognizer, _ = await LuisRecognizerTest._get_recognizer_result( - utterance, response_path, bot_adapter=NullAdapter() - ) - - runtime: LUISRuntimeClient = recognizer._runtime - config: LUISRuntimeClientConfiguration = runtime.config - user_agent = config.user_agent - - # Verify we didn't unintentionally stamp on the user-agent from the client. - self.assertTrue("azure-cognitiveservices-language-luis" in user_agent) - - # And that we added the bot.builder package details. - self.assertTrue("botbuilder-ai/4" in user_agent) - def test_telemetry_construction(self): # Arrange # Note this is NOT a real LUIS application ID nor a real LUIS subscription-key diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py index 0abfa26f3..ba11e2fb5 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py @@ -7,8 +7,12 @@ from os import path from typing import Dict, Tuple, Union -from aiounittest import AsyncTestCase +import re +from aioresponses import aioresponses +from aiounittest import AsyncTestCase +from unittest import mock +from unittest.mock import MagicMock, Mock from botbuilder.ai.luis import LuisRecognizerOptionsV3 from asynctest import CoroutineMock, patch @@ -54,7 +58,8 @@ def _remove_none_property(dictionary: Dict[str, object]) -> Dict[str, object]: return dictionary @classmethod - @patch('aiohttp.ClientSession.post') + # @patch('aiohttp.ClientSession.post') + @aioresponses() async def _get_recognizer_result( cls, utterance: str, @@ -77,7 +82,10 @@ async def _get_recognizer_result( recognizer_class, include_api_results=include_api_results, options=options ) context = LuisRecognizerV3Test._get_context(utterance, bot_adapter) - mock_get.return_value.__aenter__.return_value.json = CoroutineMock(side_effect=[response_json]) + # mock_get.return_value.__aenter__.return_value.json = CoroutineMock(side_effect=[response_json]) + + pattern = re.compile(r'^https://westus.api.cognitive.microsoft.com.*$') + mock_get.post(pattern, payload=response_json, status=200) result = await recognizer.recognize( context, telemetry_properties, telemetry_metrics @@ -146,6 +154,10 @@ async def _test_json_v3(self, response_file: str) -> None: if "version" in test_options: options.version=test_options["version"] + if "externalEntities" in test_options: + options.external_entities=test_options["externalEntities"] + + # dynamic_lists: List = None, # external_entities: List = None, # telemetry_client: BotTelemetryClient = NullTelemetryClient(), @@ -160,9 +172,9 @@ async def _test_json_v3(self, response_file: str) -> None: # Assert actual_result_json = LuisUtil.recognizer_result_as_dict(result) del expected_json["v3"] - del expected_json["sentiment"] trimmed_expected = LuisRecognizerV3Test._remove_none_property(expected_json) trimmed_actual = LuisRecognizerV3Test._remove_none_property(actual_result_json) + self.assertEqual(trimmed_expected, trimmed_actual) async def test_composite1_v3(self): @@ -172,4 +184,73 @@ async def test_composite2_v3(self): await self._test_json_v3("Composite2_v3.json") async def test_composite3_v3(self): - await self._test_json_v3("Composite3_v3.json") \ No newline at end of file + await self._test_json_v3("Composite3_v3.json") + + async def test_external_entities_and_built_in_v3(self): + await self._test_json_v3("ExternalEntitiesAndBuiltIn_v3.json") + + async def test_external_entities_and_composite_v3(self): + await self._test_json_v3("ExternalEntitiesAndComposite_v3.json") + + async def test_external_entities_and_list_v3(self): + await self._test_json_v3("ExternalEntitiesAndList_v3.json") + + async def test_external_entities_and_regex_v3(self): + await self._test_json_v3("ExternalEntitiesAndRegex_v3.json") + + async def test_external_entities_and_simple_v3(self): + await self._test_json_v3("ExternalEntitiesAndSimple_v3.json") + + async def test_geo_people_ordinal_v3(self): + await self._test_json_v3("GeoPeopleOrdinal_v3.json") + + async def test_minimal_v3(self): + await self._test_json_v3("Minimal_v3.json") + + async def test_no_entities_instance_true_v3(self): + await self._test_json_v3("NoEntitiesInstanceTrue_v3.json") + + async def test_patterns_v3(self): + await self._test_json_v3("Patterns_v3.json") + + async def test_prebuilt_v3(self): + await self._test_json_v3("Prebuilt_v3.json") + + async def test_roles_v3(self): + await self._test_json_v3("roles_v3.json") + + async def test_trace_activity(self): + # Arrange + utterance: str = "fly on delta at 3pm" + expected_json = LuisRecognizerV3Test._get_json_for_file("Minimal_v3.json") + response_json = expected_json["v3"]["response"] + + # add async support to magic mock. + async def async_magic(): + pass + + MagicMock.__await__ = lambda x: async_magic().__await__() + + # Act + with mock.patch.object(TurnContext, "send_activity") as mock_send_activity: + await self._get_recognizer_result(utterance, response_json, options= LuisRecognizerOptionsV3()) + trace_activity: Activity = mock_send_activity.call_args[0][0] + + # Assert + self.assertIsNotNone(trace_activity) + self.assertEqual(LuisRecognizer.luis_trace_type, trace_activity.value_type) + self.assertEqual(LuisRecognizer.luis_trace_label, trace_activity.label) + + luis_trace_info = trace_activity.value + self.assertIsNotNone(luis_trace_info) + self.assertIsNotNone(luis_trace_info["recognizerResult"]) + self.assertIsNotNone(luis_trace_info["luisResult"]) + self.assertIsNotNone(luis_trace_info["luisOptions"]) + self.assertIsNotNone(luis_trace_info["luisModel"]) + + recognizer_result: RecognizerResult = luis_trace_info["recognizerResult"] + self.assertEqual(utterance, recognizer_result["text"]) + self.assertIsNotNone(recognizer_result["intents"]["Roles"]) + self.assertEqual( + LuisRecognizerV3Test._luisAppId, luis_trace_info["luisModel"]["ModelID"] + ) diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json index 21e135b54..11fc7bb89 100644 --- a/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite2_v3.json @@ -295,7 +295,7 @@ "Travel": { "score": 0.0154484725 }, - "Weather_GetForecast": { + "Weather.GetForecast": { "score": 0.0237181056 } }, diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndBuiltIn_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndBuiltIn_v3.json new file mode 100644 index 000000000..a451ebbb2 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndBuiltIn_v3.json @@ -0,0 +1,168 @@ +{ + "text": "buy hul and 2 items", + "intents": { + "Cancel": { + "score": 0.006906527 + }, + "Delivery": { + "score": 0.00567273 + }, + "EntityTests": { + "score": 0.128755629 + }, + "Greeting": { + "score": 0.00450348156 + }, + "Help": { + "score": 0.00583425 + }, + "None": { + "score": 0.0135525977 + }, + "Roles": { + "score": 0.04635598 + }, + "search": { + "score": 0.008885799 + }, + "SpecifyName": { + "score": 0.00721160974 + }, + "Travel": { + "score": 0.005146626 + }, + "Weather_GetForecast": { + "score": 0.00913477 + } + }, + "entities": { + "$instance": { + "number": [ + { + "endIndex": 7, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 4, + "text": "hul", + "type": "builtin.number" + }, + { + "endIndex": 13, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 12, + "text": "2", + "type": "builtin.number" + } + ] + }, + "number": [ + 8, + 2 + ] + }, + "sentiment": { + "label": "positive", + "score": 0.7149857 + }, + "v3": { + "response": { + "prediction": { + "entities": { + "$instance": { + "number": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 4, + "text": "hul", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 12, + "text": "2", + "type": "builtin.number" + } + ] + }, + "number": [ + 8, + 2 + ] + }, + "intents": { + "Cancel": { + "score": 0.006906527 + }, + "Delivery": { + "score": 0.00567273 + }, + "EntityTests": { + "score": 0.128755629 + }, + "Greeting": { + "score": 0.00450348156 + }, + "Help": { + "score": 0.00583425 + }, + "None": { + "score": 0.0135525977 + }, + "Roles": { + "score": 0.04635598 + }, + "search": { + "score": 0.008885799 + }, + "SpecifyName": { + "score": 0.00721160974 + }, + "Travel": { + "score": 0.005146626 + }, + "Weather.GetForecast": { + "score": 0.00913477 + } + }, + "normalizedQuery": "buy hul and 2 items", + "sentiment": { + "label": "positive", + "score": 0.7149857 + }, + "topIntent": "EntityTests" + }, + "query": "buy hul and 2 items" + }, + "options": { + "externalEntities": [ + { + "entityLength": 3, + "entityName": "number", + "resolution": 8, + "startIndex": 4 + } + ], + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndComposite_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndComposite_v3.json new file mode 100644 index 000000000..33c5d7342 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndComposite_v3.json @@ -0,0 +1,261 @@ +{ + "entities": { + "$instance": { + "Address": [ + { + "endIndex": 13, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.7160641, + "startIndex": 8, + "text": "35 WA", + "type": "Address" + }, + { + "endIndex": 33, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 17, + "text": "repent harelquin", + "type": "Address" + } + ] + }, + "Address": [ + { + "$instance": { + "number": [ + { + "endIndex": 10, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "35", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 13, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.614376, + "startIndex": 11, + "text": "WA", + "type": "State" + } + ] + }, + "number": [ + 35 + ], + "State": [ + "WA" + ] + }, + { + "number": [ + 3 + ], + "State": [ + "France" + ] + } + ] + }, + "intents": { + "Cancel": { + "score": 0.00325984019 + }, + "Delivery": { + "score": 0.482009649 + }, + "EntityTests": { + "score": 0.00372873852 + }, + "Greeting": { + "score": 0.00283122621 + }, + "Help": { + "score": 0.00292110164 + }, + "None": { + "score": 0.0208108239 + }, + "Roles": { + "score": 0.069060266 + }, + "search": { + "score": 0.009682492 + }, + "SpecifyName": { + "score": 0.00586992875 + }, + "Travel": { + "score": 0.007831623 + }, + "Weather_GetForecast": { + "score": 0.009580207 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "deliver 35 WA to repent harelquin", + "v3": { + "options": { + "externalEntities": [ + { + "entityLength": 16, + "entityName": "Address", + "resolution": { + "number": [ + 3 + ], + "State": [ + "France" + ] + }, + "startIndex": 17 + } + ], + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Address": [ + { + "length": 5, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.7160641, + "startIndex": 8, + "text": "35 WA", + "type": "Address" + }, + { + "length": 16, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 17, + "text": "repent harelquin", + "type": "Address" + } + ] + }, + "Address": [ + { + "$instance": { + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "35", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "score": 0.614376, + "startIndex": 11, + "text": "WA", + "type": "State" + } + ] + }, + "number": [ + 35 + ], + "State": [ + "WA" + ] + }, + { + "number": [ + 3 + ], + "State": [ + "France" + ] + } + ] + }, + "intents": { + "Cancel": { + "score": 0.00325984019 + }, + "Delivery": { + "score": 0.482009649 + }, + "EntityTests": { + "score": 0.00372873852 + }, + "Greeting": { + "score": 0.00283122621 + }, + "Help": { + "score": 0.00292110164 + }, + "None": { + "score": 0.0208108239 + }, + "Roles": { + "score": 0.069060266 + }, + "search": { + "score": 0.009682492 + }, + "SpecifyName": { + "score": 0.00586992875 + }, + "Travel": { + "score": 0.007831623 + }, + "Weather.GetForecast": { + "score": 0.009580207 + } + }, + "normalizedQuery": "deliver 35 wa to repent harelquin", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Delivery" + }, + "query": "deliver 35 WA to repent harelquin" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndList_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndList_v3.json new file mode 100644 index 000000000..e2cf8eb63 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndList_v3.json @@ -0,0 +1,178 @@ +{ + "entities": { + "$instance": { + "Airline": [ + { + "endIndex": 23, + "modelType": "List Entity Extractor", + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 7, + "text": "humberg airlines", + "type": "Airline" + }, + { + "endIndex": 32, + "modelType": "List Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 27, + "text": "Delta", + "type": "Airline" + } + ] + }, + "Airline": [ + [ + "HumAir" + ], + [ + "Delta" + ] + ] + }, + "intents": { + "Cancel": { + "score": 0.00330878259 + }, + "Delivery": { + "score": 0.00452178251 + }, + "EntityTests": { + "score": 0.052175343 + }, + "Greeting": { + "score": 0.002769983 + }, + "Help": { + "score": 0.002995687 + }, + "None": { + "score": 0.0302589461 + }, + "Roles": { + "score": 0.132316783 + }, + "search": { + "score": 0.007362695 + }, + "SpecifyName": { + "score": 0.00500302855 + }, + "Travel": { + "score": 0.0146034053 + }, + "Weather_GetForecast": { + "score": 0.005048246 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "fly on humberg airlines or Delta", + "v3": { + "options": { + "externalEntities": [ + { + "entityLength": 16, + "entityName": "Airline", + "resolution": [ + "HumAir" + ], + "startIndex": 7 + } + ], + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Airline": [ + { + "length": 16, + "modelType": "List Entity Extractor", + "modelTypeId": 5, + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 7, + "text": "humberg airlines", + "type": "Airline" + }, + { + "length": 5, + "modelType": "List Entity Extractor", + "modelTypeId": 5, + "recognitionSources": [ + "model" + ], + "startIndex": 27, + "text": "Delta", + "type": "Airline" + } + ] + }, + "Airline": [ + [ + "HumAir" + ], + [ + "Delta" + ] + ] + }, + "intents": { + "Cancel": { + "score": 0.00330878259 + }, + "Delivery": { + "score": 0.00452178251 + }, + "EntityTests": { + "score": 0.052175343 + }, + "Greeting": { + "score": 0.002769983 + }, + "Help": { + "score": 0.002995687 + }, + "None": { + "score": 0.0302589461 + }, + "Roles": { + "score": 0.132316783 + }, + "search": { + "score": 0.007362695 + }, + "SpecifyName": { + "score": 0.00500302855 + }, + "Travel": { + "score": 0.0146034053 + }, + "Weather.GetForecast": { + "score": 0.005048246 + } + }, + "normalizedQuery": "fly on humberg airlines or delta", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Roles" + }, + "query": "fly on humberg airlines or Delta" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json new file mode 100644 index 000000000..3a92a6ef7 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json @@ -0,0 +1,167 @@ +{ + "entities": { + "$instance": { + "Part": [ + { + "endIndex": 5, + "modelType": "Regex Entity Extractor", + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 0, + "text": "42ski", + "type": "Part" + }, + { + "endIndex": 26, + "modelType": "Regex Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 21, + "text": "kb423", + "type": "Part" + } + ] + }, + "Part": [ + "42ski", + "kb423" + ] + }, + "intents": { + "Cancel": { + "score": 0.0127721056 + }, + "Delivery": { + "score": 0.004578639 + }, + "EntityTests": { + "score": 0.008811761 + }, + "Greeting": { + "score": 0.00256775436 + }, + "Help": { + "score": 0.00214677141 + }, + "None": { + "score": 0.27875194 + }, + "Roles": { + "score": 0.0273685548 + }, + "search": { + "score": 0.0084077 + }, + "SpecifyName": { + "score": 0.0148377549 + }, + "Travel": { + "score": 0.0039825947 + }, + "Weather_GetForecast": { + "score": 0.009611839 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "42ski is a part like kb423", + "v3": { + "options": { + "ExternalEntities": [ + { + "entityLength": 5, + "entityName": "Part", + "startIndex": 0 + } + ], + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Part": [ + { + "length": 5, + "modelType": "Regex Entity Extractor", + "modelTypeId": 8, + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 0, + "text": "42ski", + "type": "Part" + }, + { + "length": 5, + "modelType": "Regex Entity Extractor", + "modelTypeId": 8, + "recognitionSources": [ + "model" + ], + "startIndex": 21, + "text": "kb423", + "type": "Part" + } + ] + }, + "Part": [ + "42ski", + "kb423" + ] + }, + "intents": { + "Cancel": { + "score": 0.0127721056 + }, + "Delivery": { + "score": 0.004578639 + }, + "EntityTests": { + "score": 0.008811761 + }, + "Greeting": { + "score": 0.00256775436 + }, + "Help": { + "score": 0.00214677141 + }, + "None": { + "score": 0.27875194 + }, + "Roles": { + "score": 0.0273685548 + }, + "search": { + "score": 0.0084077 + }, + "SpecifyName": { + "score": 0.0148377549 + }, + "Travel": { + "score": 0.0039825947 + }, + "Weather.GetForecast": { + "score": 0.009611839 + } + }, + "normalizedQuery": "42ski is a part like kb423", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "None" + }, + "query": "42ski is a part like kb423" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimpleOverride_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimpleOverride_v3.json new file mode 100644 index 000000000..8f48817dd --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimpleOverride_v3.json @@ -0,0 +1,299 @@ +{ + "entities": { + "$instance": { + "Address": [ + { + "endIndex": 13, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.7033113, + "startIndex": 8, + "text": "37 wa", + "type": "Address" + } + ], + "number": [ + { + "endIndex": 19, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "82", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 22, + "modelType": "Entity Extractor", + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 20, + "text": "co", + "type": "State" + } + ] + }, + "Address": [ + { + "$instance": { + "number": [ + { + "endIndex": 10, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "37", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 13, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model", + "externalEntities" + ], + "score": 0.5987082, + "startIndex": 11, + "text": "wa", + "type": "State" + } + ] + }, + "number": [ + 37 + ], + "State": [ + { + "state": "Washington" + } + ] + } + ], + "number": [ + 82 + ], + "State": [ + { + "state": "Colorado" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.004045653 + }, + "Delivery": { + "score": 0.511144161 + }, + "EntityTests": { + "score": 0.004197402 + }, + "Greeting": { + "score": 0.00286332145 + }, + "Help": { + "score": 0.00351834856 + }, + "None": { + "score": 0.01229356 + }, + "Roles": { + "score": 0.08465987 + }, + "search": { + "score": 0.009909824 + }, + "SpecifyName": { + "score": 0.006426142 + }, + "Travel": { + "score": 0.008369388 + }, + "Weather_GetForecast": { + "score": 0.0112502193 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "deliver 37 wa to 82 co", + "v3": { + "options": { + "externalEntities": [ + { + "entityLength": 2, + "entityName": "State", + "resolution": { + "state": "Washington" + }, + "startIndex": 11 + }, + { + "entityLength": 2, + "entityName": "State", + "resolution": { + "state": "Colorado" + }, + "startIndex": 20 + } + ], + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Address": [ + { + "length": 5, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.7033113, + "startIndex": 8, + "text": "37 wa", + "type": "Address" + } + ], + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "82", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 20, + "text": "co", + "type": "State" + } + ] + }, + "Address": [ + { + "$instance": { + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "37", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model", + "externalEntities" + ], + "score": 0.5987082, + "startIndex": 11, + "text": "wa", + "type": "State" + } + ] + }, + "number": [ + 37 + ], + "State": [ + { + "state": "Washington" + } + ] + } + ], + "number": [ + 82 + ], + "State": [ + { + "state": "Colorado" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.004045653 + }, + "Delivery": { + "score": 0.511144161 + }, + "EntityTests": { + "score": 0.004197402 + }, + "Greeting": { + "score": 0.00286332145 + }, + "Help": { + "score": 0.00351834856 + }, + "None": { + "score": 0.01229356 + }, + "Roles": { + "score": 0.08465987 + }, + "search": { + "score": 0.009909824 + }, + "SpecifyName": { + "score": 0.006426142 + }, + "Travel": { + "score": 0.008369388 + }, + "Weather.GetForecast": { + "score": 0.0112502193 + } + }, + "normalizedQuery": "deliver 37 wa to 82 co", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Delivery" + }, + "query": "deliver 37 wa to 82 co" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimple_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimple_v3.json new file mode 100644 index 000000000..e7073627d --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndSimple_v3.json @@ -0,0 +1,292 @@ +{ + "entities": { + "$instance": { + "Address": [ + { + "endIndex": 13, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.7033113, + "startIndex": 8, + "text": "37 wa", + "type": "Address" + } + ], + "number": [ + { + "endIndex": 19, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "82", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 22, + "modelType": "Entity Extractor", + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 20, + "text": "co", + "type": "State" + } + ] + }, + "Address": [ + { + "$instance": { + "number": [ + { + "endIndex": 10, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "37", + "type": "builtin.number" + } + ], + "State": [ + { + "endIndex": 13, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model", + "externalEntities" + ], + "score": 0.5987082, + "startIndex": 11, + "text": "wa", + "type": "State" + } + ] + }, + "number": [ + 37 + ], + "State": [ + "wa" + ] + } + ], + "number": [ + 82 + ], + "State": [ + { + "state": "Colorado" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.004045653 + }, + "Delivery": { + "score": 0.511144161 + }, + "EntityTests": { + "score": 0.004197402 + }, + "Greeting": { + "score": 0.00286332145 + }, + "Help": { + "score": 0.00351834856 + }, + "None": { + "score": 0.01229356 + }, + "Roles": { + "score": 0.08465987 + }, + "search": { + "score": 0.009909824 + }, + "SpecifyName": { + "score": 0.006426142 + }, + "Travel": { + "score": 0.008369388 + }, + "Weather_GetForecast": { + "score": 0.0112502193 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "deliver 37 wa to 82 co", + "v3": { + "options": { + "externalEntities": [ + { + "entityLength": 2, + "entityName": "State", + "startIndex": 11 + }, + { + "entityLength": 2, + "entityName": "State", + "resolution": { + "state": "Colorado" + }, + "startIndex": 20 + } + ], + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Address": [ + { + "length": 5, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.7033113, + "startIndex": 8, + "text": "37 wa", + "type": "Address" + } + ], + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "82", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "externalEntities" + ], + "startIndex": 20, + "text": "co", + "type": "State" + } + ] + }, + "Address": [ + { + "$instance": { + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "37", + "type": "builtin.number" + } + ], + "State": [ + { + "length": 2, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model", + "externalEntities" + ], + "score": 0.5987082, + "startIndex": 11, + "text": "wa", + "type": "State" + } + ] + }, + "number": [ + 37 + ], + "State": [ + "wa" + ] + } + ], + "number": [ + 82 + ], + "State": [ + { + "state": "Colorado" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.004045653 + }, + "Delivery": { + "score": 0.511144161 + }, + "EntityTests": { + "score": 0.004197402 + }, + "Greeting": { + "score": 0.00286332145 + }, + "Help": { + "score": 0.00351834856 + }, + "None": { + "score": 0.01229356 + }, + "Roles": { + "score": 0.08465987 + }, + "search": { + "score": 0.009909824 + }, + "SpecifyName": { + "score": 0.006426142 + }, + "Travel": { + "score": 0.008369388 + }, + "Weather.GetForecast": { + "score": 0.0112502193 + } + }, + "normalizedQuery": "deliver 37 wa to 82 co", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Delivery" + }, + "query": "deliver 37 wa to 82 co" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/GeoPeopleOrdinal_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/GeoPeopleOrdinal_v3.json new file mode 100644 index 000000000..4ac3ed4ff --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/GeoPeopleOrdinal_v3.json @@ -0,0 +1,321 @@ +{ + "entities": { + "$instance": { + "child": [ + { + "endIndex": 99, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 87, + "text": "lisa simpson", + "type": "builtin.personName" + } + ], + "endloc": [ + { + "endIndex": 51, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 44, + "text": "jakarta", + "type": "builtin.geographyV2.city" + } + ], + "ordinalV2": [ + { + "endIndex": 28, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 24, + "text": "last", + "type": "builtin.ordinalV2.relative" + } + ], + "parent": [ + { + "endIndex": 69, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 56, + "text": "homer simpson", + "type": "builtin.personName" + } + ], + "startloc": [ + { + "endIndex": 40, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 34, + "text": "london", + "type": "builtin.geographyV2.city" + } + ], + "startpos": [ + { + "endIndex": 20, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 8, + "text": "next to last", + "type": "builtin.ordinalV2.relative" + } + ] + }, + "child": [ + "lisa simpson" + ], + "endloc": [ + { + "location": "jakarta", + "type": "city" + } + ], + "ordinalV2": [ + { + "offset": 0, + "relativeTo": "end" + } + ], + "parent": [ + "homer simpson" + ], + "startloc": [ + { + "location": "london", + "type": "city" + } + ], + "startpos": [ + { + "offset": -1, + "relativeTo": "end" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.000107549029 + }, + "Delivery": { + "score": 0.00123035291 + }, + "EntityTests": { + "score": 0.0009487789 + }, + "Greeting": { + "score": 5.293933E-05 + }, + "Help": { + "score": 0.0001358991 + }, + "None": { + "score": 0.0109820236 + }, + "Roles": { + "score": 0.999204934 + }, + "search": { + "score": 0.0263254233 + }, + "SpecifyName": { + "score": 0.00104324089 + }, + "Travel": { + "score": 0.01043327 + }, + "Weather_GetForecast": { + "score": 0.0106523167 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "go from next to last to last move london to jakarta and homer simpson is the parent of lisa simpson", + "v3": { + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "child": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "child", + "startIndex": 87, + "text": "lisa simpson", + "type": "builtin.personName" + } + ], + "endloc": [ + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "endloc", + "startIndex": 44, + "text": "jakarta", + "type": "builtin.geographyV2.city" + } + ], + "ordinalV2": [ + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 24, + "text": "last", + "type": "builtin.ordinalV2.relative" + } + ], + "parent": [ + { + "length": 13, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "parent", + "startIndex": 56, + "text": "homer simpson", + "type": "builtin.personName" + } + ], + "startloc": [ + { + "length": 6, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "startloc", + "startIndex": 34, + "text": "london", + "type": "builtin.geographyV2.city" + } + ], + "startpos": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "startpos", + "startIndex": 8, + "text": "next to last", + "type": "builtin.ordinalV2.relative" + } + ] + }, + "child": [ + "lisa simpson" + ], + "endloc": [ + { + "value": "jakarta", + "type": "city" + } + ], + "ordinalV2": [ + { + "offset": 0, + "relativeTo": "end" + } + ], + "parent": [ + "homer simpson" + ], + "startloc": [ + { + "value": "london", + "type": "city" + } + ], + "startpos": [ + { + "offset": -1, + "relativeTo": "end" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.000107549029 + }, + "Delivery": { + "score": 0.00123035291 + }, + "EntityTests": { + "score": 0.0009487789 + }, + "Greeting": { + "score": 5.293933E-05 + }, + "Help": { + "score": 0.0001358991 + }, + "None": { + "score": 0.0109820236 + }, + "Roles": { + "score": 0.999204934 + }, + "search": { + "score": 0.0263254233 + }, + "SpecifyName": { + "score": 0.00104324089 + }, + "Travel": { + "score": 0.01043327 + }, + "Weather.GetForecast": { + "score": 0.0106523167 + } + }, + "normalizedQuery": "go from next to last to last move london to jakarta and homer simpson is the parent of lisa simpson", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Roles" + }, + "query": "go from next to last to last move london to jakarta and homer simpson is the parent of lisa simpson" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Minimal_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Minimal_v3.json new file mode 100644 index 000000000..b810446ad --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Minimal_v3.json @@ -0,0 +1,83 @@ +{ + "entities": { + "Airline": [ + [ + "Delta" + ] + ], + "datetime": [ + { + "timex": [ + "T15" + ], + "type": "time" + } + ], + "dimension": [ + { + "number": 3, + "units": "Picometer" + } + ] + }, + "intents": { + "Roles": { + "score": 0.446264923 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "fly on delta at 3pm", + "v3": { + "options": { + "includeAllIntents": false, + "includeAPIResults": true, + "includeInstanceData": false, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "Airline": [ + [ + "Delta" + ] + ], + "datetimeV2": [ + { + "type": "time", + "values": [ + { + "timex": "T15", + "value": "15:00:00" + } + ] + } + ], + "dimension": [ + { + "number": 3, + "unit": "Picometer" + } + ] + }, + "intents": { + "Roles": { + "score": 0.446264923 + } + }, + "normalizedQuery": "fly on delta at 3pm", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Roles" + }, + "query": "fly on delta at 3pm" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/NoEntitiesInstanceTrue_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/NoEntitiesInstanceTrue_v3.json new file mode 100644 index 000000000..10a268338 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/NoEntitiesInstanceTrue_v3.json @@ -0,0 +1,33 @@ +{ + "entities": { + "$instance": {} + }, + "intents": { + "Greeting": { + "score": 0.9589885 + } + }, + "text": "Hi", + "v3": { + "options": { + "includeAllIntents": false, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "query": "Hi", + "prediction": { + "topIntent": "Greeting", + "intents": { + "Greeting": { + "score": 0.9589885 + } + }, + "entities": {} + } + } + } + } diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Patterns_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Patterns_v3.json new file mode 100644 index 000000000..824bf5f54 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Patterns_v3.json @@ -0,0 +1,262 @@ +{ + "entities": { + "$instance": { + "extra": [ + { + "endIndex": 76, + "modelType": "Pattern.Any Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 71, + "text": "kb435", + "type": "subject" + } + ], + "Part": [ + { + "endIndex": 76, + "modelType": "Regex Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 71, + "text": "kb435", + "type": "Part" + } + ], + "person": [ + { + "endIndex": 61, + "modelType": "Pattern.Any Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 49, + "text": "bart simpson", + "type": "person" + } + ], + "personName": [ + { + "endIndex": 61, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 49, + "text": "bart simpson", + "type": "builtin.personName" + } + ], + "subject": [ + { + "endIndex": 43, + "modelType": "Pattern.Any Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 12, + "text": "something wicked this way comes", + "type": "subject" + } + ] + }, + "extra": [ + "kb435" + ], + "Part": [ + "kb435" + ], + "person": [ + "bart simpson" + ], + "personName": [ + "bart simpson" + ], + "subject": [ + "something wicked this way comes" + ] + }, + "intents": { + "Cancel": { + "score": 1.01764708E-09 + }, + "Delivery": { + "score": 1.8E-09 + }, + "EntityTests": { + "score": 1.044335E-05 + }, + "Greeting": { + "score": 1.0875E-09 + }, + "Help": { + "score": 1.01764708E-09 + }, + "None": { + "score": 2.38094663E-06 + }, + "Roles": { + "score": 5.98274755E-06 + }, + "search": { + "score": 0.9999993 + }, + "SpecifyName": { + "score": 3.0666667E-09 + }, + "Travel": { + "score": 3.09763345E-06 + }, + "Weather_GetForecast": { + "score": 1.02792524E-06 + } + }, + "sentiment": { + "label": "negative", + "score": 0.210341513 + }, + "text": "email about something wicked this way comes from bart simpson and also kb435", + "v3": { + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "extra": [ + { + "length": 5, + "modelType": "Pattern.Any Entity Extractor", + "modelTypeId": 7, + "recognitionSources": [ + "model" + ], + "role": "extra", + "startIndex": 71, + "text": "kb435", + "type": "subject" + } + ], + "Part": [ + { + "length": 5, + "modelType": "Regex Entity Extractor", + "modelTypeId": 8, + "recognitionSources": [ + "model" + ], + "startIndex": 71, + "text": "kb435", + "type": "Part" + } + ], + "person": [ + { + "length": 12, + "modelType": "Pattern.Any Entity Extractor", + "modelTypeId": 7, + "recognitionSources": [ + "model" + ], + "startIndex": 49, + "text": "bart simpson", + "type": "person" + } + ], + "personName": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 49, + "text": "bart simpson", + "type": "builtin.personName" + } + ], + "subject": [ + { + "length": 31, + "modelType": "Pattern.Any Entity Extractor", + "modelTypeId": 7, + "recognitionSources": [ + "model" + ], + "startIndex": 12, + "text": "something wicked this way comes", + "type": "subject" + } + ] + }, + "extra": [ + "kb435" + ], + "Part": [ + "kb435" + ], + "person": [ + "bart simpson" + ], + "personName": [ + "bart simpson" + ], + "subject": [ + "something wicked this way comes" + ] + }, + "intents": { + "Cancel": { + "score": 1.01764708E-09 + }, + "Delivery": { + "score": 1.8E-09 + }, + "EntityTests": { + "score": 1.044335E-05 + }, + "Greeting": { + "score": 1.0875E-09 + }, + "Help": { + "score": 1.01764708E-09 + }, + "None": { + "score": 2.38094663E-06 + }, + "Roles": { + "score": 5.98274755E-06 + }, + "search": { + "score": 0.9999993 + }, + "SpecifyName": { + "score": 3.0666667E-09 + }, + "Travel": { + "score": 3.09763345E-06 + }, + "Weather.GetForecast": { + "score": 1.02792524E-06 + } + }, + "normalizedQuery": "email about something wicked this way comes from bart simpson and also kb435", + "sentiment": { + "label": "negative", + "score": 0.210341513 + }, + "topIntent": "search" + }, + "query": "email about something wicked this way comes from bart simpson and also kb435" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Prebuilt_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/Prebuilt_v3.json new file mode 100644 index 000000000..9cb4ab134 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Prebuilt_v3.json @@ -0,0 +1,246 @@ +{ + "entities": { + "$instance": { + "Composite2": [ + { + "endIndex": 66, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.7077416, + "startIndex": 0, + "text": "http://foo.com is where you can get a weather forecast for seattle", + "type": "Composite2" + } + ], + "geographyV2": [ + { + "endIndex": 66, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 59, + "text": "seattle", + "type": "builtin.geographyV2.city" + } + ] + }, + "Composite2": [ + { + "$instance": { + "url": [ + { + "endIndex": 14, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "http://foo.com", + "type": "builtin.url" + } + ], + "Weather_Location": [ + { + "endIndex": 66, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.76184386, + "startIndex": 59, + "text": "seattle", + "type": "Weather.Location" + } + ] + }, + "url": [ + "http://foo.com" + ], + "Weather_Location": [ + "seattle" + ] + } + ], + "geographyV2": [ + { + "location": "seattle", + "type": "city" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.000171828113 + }, + "Delivery": { + "score": 0.0011408634 + }, + "EntityTests": { + "score": 0.342939854 + }, + "Greeting": { + "score": 0.0001518702 + }, + "Help": { + "score": 0.0005502715 + }, + "None": { + "score": 0.0175834317 + }, + "Roles": { + "score": 0.0432791822 + }, + "search": { + "score": 0.01050759 + }, + "SpecifyName": { + "score": 0.001833231 + }, + "Travel": { + "score": 0.004430798 + }, + "Weather_GetForecast": { + "score": 0.669524968 + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "text": "http://foo.com is where you can get a weather forecast for seattle", + "v3": { + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + }, + "response": { + "prediction": { + "entities": { + "$instance": { + "Composite2": [ + { + "length": 66, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.7077416, + "startIndex": 0, + "text": "http://foo.com is where you can get a weather forecast for seattle", + "type": "Composite2" + } + ], + "geographyV2": [ + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 59, + "text": "seattle", + "type": "builtin.geographyV2.city" + } + ] + }, + "Composite2": [ + { + "$instance": { + "url": [ + { + "length": 14, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "http://foo.com", + "type": "builtin.url" + } + ], + "Weather.Location": [ + { + "length": 7, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "score": 0.76184386, + "startIndex": 59, + "text": "seattle", + "type": "Weather.Location" + } + ] + }, + "url": [ + "http://foo.com" + ], + "Weather.Location": [ + "seattle" + ] + } + ], + "geographyV2": [ + { + "type": "city", + "value": "seattle" + } + ] + }, + "intents": { + "Cancel": { + "score": 0.000171828113 + }, + "Delivery": { + "score": 0.0011408634 + }, + "EntityTests": { + "score": 0.342939854 + }, + "Greeting": { + "score": 0.0001518702 + }, + "Help": { + "score": 0.0005502715 + }, + "None": { + "score": 0.0175834317 + }, + "Roles": { + "score": 0.0432791822 + }, + "search": { + "score": 0.01050759 + }, + "SpecifyName": { + "score": 0.001833231 + }, + "Travel": { + "score": 0.004430798 + }, + "Weather.GetForecast": { + "score": 0.669524968 + } + }, + "normalizedQuery": "http://foo.com is where you can get a weather forecast for seattle", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Weather.GetForecast" + }, + "query": "http://foo.com is where you can get a weather forecast for seattle" + } + } + } \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/roles_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/roles_v3.json new file mode 100644 index 000000000..15ee58ac4 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/roles_v3.json @@ -0,0 +1,1759 @@ +{ + "text": "3 inches long by 2 inches wide and 5% to 10% and are you between 6 years old and 8 years old and can i trade kb457 for kb922 and change 425-777-1212 to 206-666-4123 and did delta buy virgin and did the rain from hawaii get to redmond and http://foo.com changed to http://blah.com and i like between 68 degrees and 72 degrees and john likes mary and leave 3pm and arrive 5pm and pay between $400 and $500 and send chrimc@hotmail.com from emad@gmail.com", + "intents": { + "Cancel": { + "score": 4.50860341e-7 + }, + "Delivery": { + "score": 0.00007978094 + }, + "EntityTests": { + "score": 0.0046325135 + }, + "Greeting": { + "score": 4.73494453e-7 + }, + "Help": { + "score": 7.622754e-7 + }, + "None": { + "score": 0.00093744183 + }, + "Roles": { + "score": 1 + }, + "search": { + "score": 0.07635335 + }, + "SpecifyName": { + "score": 0.00009136085 + }, + "Travel": { + "score": 0.00771805458 + }, + "Weather_GetForecast": { + "score": 0.0100867962 + } + }, + "entities": { + "$instance": { + "a": [ + { + "endIndex": 309, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 299, + "text": "68 degrees", + "type": "builtin.temperature" + } + ], + "arrive": [ + { + "endIndex": 373, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 370, + "text": "5pm", + "type": "builtin.datetimeV2.time" + } + ], + "b": [ + { + "endIndex": 324, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 314, + "text": "72 degrees", + "type": "builtin.temperature" + } + ], + "begin": [ + { + "endIndex": 76, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 65, + "text": "6 years old", + "type": "builtin.age" + } + ], + "buy": [ + { + "endIndex": 124, + "modelType": "Regex Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 119, + "text": "kb922", + "type": "Part" + } + ], + "Buyer": [ + { + "endIndex": 178, + "modelType": "List Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 173, + "text": "delta", + "type": "Airline" + } + ], + "Composite1": [ + { + "endIndex": 172, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.01107535, + "startIndex": 0, + "text": "3 inches long by 2 inches wide and 5% to 10% and are you between 6 years old and 8 years old and can i trade kb457 for kb922 and change 425-777-1212 to 206-666-4123 and did", + "type": "Composite1" + } + ], + "Composite2": [ + { + "endIndex": 283, + "modelType": "Composite Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.15191336, + "startIndex": 238, + "text": "http://foo.com changed to http://blah.com and", + "type": "Composite2" + } + ], + "destination": [ + { + "endIndex": 233, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.985884964, + "startIndex": 226, + "text": "redmond", + "type": "Weather.Location" + } + ], + "dimension": [ + { + "endIndex": 358, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 355, + "text": "3pm", + "type": "builtin.dimension" + }, + { + "endIndex": 373, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 370, + "text": "5pm", + "type": "builtin.dimension" + } + ], + "end": [ + { + "endIndex": 92, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 81, + "text": "8 years old", + "type": "builtin.age" + } + ], + "geographyV2": [ + { + "endIndex": 218, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 212, + "text": "hawaii", + "type": "builtin.geographyV2.state" + }, + { + "endIndex": 233, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 226, + "text": "redmond", + "type": "builtin.geographyV2.city" + } + ], + "leave": [ + { + "endIndex": 358, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 355, + "text": "3pm", + "type": "builtin.datetimeV2.time" + } + ], + "length": [ + { + "endIndex": 8, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "3 inches", + "type": "builtin.dimension" + } + ], + "likee": [ + { + "endIndex": 344, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9900547, + "startIndex": 340, + "text": "mary", + "type": "Name" + } + ], + "liker": [ + { + "endIndex": 333, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.992201567, + "startIndex": 329, + "text": "john", + "type": "Name" + } + ], + "max": [ + { + "endIndex": 403, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 399, + "text": "$500", + "type": "builtin.currency" + } + ], + "maximum": [ + { + "endIndex": 44, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 41, + "text": "10%", + "type": "builtin.percentage" + } + ], + "min": [ + { + "endIndex": 394, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 390, + "text": "$400", + "type": "builtin.currency" + } + ], + "minimum": [ + { + "endIndex": 37, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 35, + "text": "5%", + "type": "builtin.percentage" + } + ], + "newPhone": [ + { + "endIndex": 164, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9, + "startIndex": 152, + "text": "206-666-4123", + "type": "builtin.phonenumber" + } + ], + "number": [ + { + "endIndex": 301, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 299, + "text": "68", + "type": "builtin.number" + }, + { + "endIndex": 316, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 314, + "text": "72", + "type": "builtin.number" + }, + { + "endIndex": 394, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 391, + "text": "400", + "type": "builtin.number" + }, + { + "endIndex": 403, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 400, + "text": "500", + "type": "builtin.number" + } + ], + "old": [ + { + "endIndex": 148, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9, + "startIndex": 136, + "text": "425-777-1212", + "type": "builtin.phonenumber" + } + ], + "oldURL": [ + { + "endIndex": 252, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 238, + "text": "http://foo.com", + "type": "builtin.url" + } + ], + "personName": [ + { + "endIndex": 333, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 329, + "text": "john", + "type": "builtin.personName" + }, + { + "endIndex": 344, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 340, + "text": "mary", + "type": "builtin.personName" + } + ], + "receiver": [ + { + "endIndex": 431, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 413, + "text": "chrimc@hotmail.com", + "type": "builtin.email" + } + ], + "sell": [ + { + "endIndex": 114, + "modelType": "Regex Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 109, + "text": "kb457", + "type": "Part" + } + ], + "Seller": [ + { + "endIndex": 189, + "modelType": "List Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 183, + "text": "virgin", + "type": "Airline" + } + ], + "sender": [ + { + "endIndex": 451, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 437, + "text": "emad@gmail.com", + "type": "builtin.email" + } + ], + "source": [ + { + "endIndex": 218, + "modelType": "Entity Extractor", + "recognitionSources": [ + "model" + ], + "score": 0.9713092, + "startIndex": 212, + "text": "hawaii", + "type": "Weather.Location" + } + ], + "width": [ + { + "endIndex": 25, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "2 inches", + "type": "builtin.dimension" + } + ] + }, + "a": [ + { + "number": 68, + "units": "Degree" + } + ], + "arrive": [ + { + "timex": [ + "T17" + ], + "type": "time" + } + ], + "b": [ + { + "number": 72, + "units": "Degree" + } + ], + "begin": [ + { + "number": 6, + "units": "Year" + } + ], + "buy": [ + "kb922" + ], + "Buyer": [ + [ + "Delta" + ] + ], + "Composite1": [ + { + "$instance": { + "datetime": [ + { + "endIndex": 72, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 65, + "text": "6 years", + "type": "builtin.datetimeV2.duration" + }, + { + "endIndex": 88, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 81, + "text": "8 years", + "type": "builtin.datetimeV2.duration" + } + ], + "number": [ + { + "endIndex": 1, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "3", + "type": "builtin.number" + }, + { + "endIndex": 18, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "2", + "type": "builtin.number" + }, + { + "endIndex": 36, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 35, + "text": "5", + "type": "builtin.number" + }, + { + "endIndex": 43, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 41, + "text": "10", + "type": "builtin.number" + }, + { + "endIndex": 66, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 65, + "text": "6", + "type": "builtin.number" + }, + { + "endIndex": 82, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 81, + "text": "8", + "type": "builtin.number" + }, + { + "endIndex": 139, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 136, + "text": "425", + "type": "builtin.number" + }, + { + "endIndex": 143, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 140, + "text": "777", + "type": "builtin.number" + }, + { + "endIndex": 148, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 144, + "text": "1212", + "type": "builtin.number" + }, + { + "endIndex": 155, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 152, + "text": "206", + "type": "builtin.number" + }, + { + "endIndex": 159, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 156, + "text": "666", + "type": "builtin.number" + }, + { + "endIndex": 164, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 160, + "text": "4123", + "type": "builtin.number" + } + ] + }, + "datetime": [ + { + "timex": [ + "P6Y" + ], + "type": "duration" + }, + { + "timex": [ + "P8Y" + ], + "type": "duration" + } + ], + "number": [ + 3, + 2, + 5, + 10, + 6, + 8, + 425, + 777, + 1212, + 206, + 666, + 4123 + ] + } + ], + "Composite2": [ + { + "$instance": { + "url": [ + { + "endIndex": 279, + "modelType": "Prebuilt Entity Extractor", + "recognitionSources": [ + "model" + ], + "startIndex": 264, + "text": "http://blah.com", + "type": "builtin.url" + } + ] + }, + "url": [ + "http://blah.com" + ] + } + ], + "destination": [ + "redmond" + ], + "dimension": [ + { + "number": 3, + "units": "Picometer" + }, + { + "number": 5, + "units": "Picometer" + } + ], + "end": [ + { + "number": 8, + "units": "Year" + } + ], + "geographyV2": [ + { + "location": "hawaii", + "type": "state" + }, + { + "location": "redmond", + "type": "city" + } + ], + "leave": [ + { + "timex": [ + "T15" + ], + "type": "time" + } + ], + "length": [ + { + "number": 3, + "units": "Inch" + } + ], + "likee": [ + "mary" + ], + "liker": [ + "john" + ], + "max": [ + { + "number": 500, + "units": "Dollar" + } + ], + "maximum": [ + 10 + ], + "min": [ + { + "number": 400, + "units": "Dollar" + } + ], + "minimum": [ + 5 + ], + "newPhone": [ + "206-666-4123" + ], + "number": [ + 68, + 72, + 400, + 500 + ], + "old": [ + "425-777-1212" + ], + "oldURL": [ + "http://foo.com" + ], + "personName": [ + "john", + "mary" + ], + "receiver": [ + "chrimc@hotmail.com" + ], + "sell": [ + "kb457" + ], + "Seller": [ + [ + "Virgin" + ] + ], + "sender": [ + "emad@gmail.com" + ], + "source": [ + "hawaii" + ], + "width": [ + { + "number": 2, + "units": "Inch" + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "v3": { + "response": { + "prediction": { + "entities": { + "$instance": { + "a": [ + { + "length": 10, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "a", + "startIndex": 299, + "text": "68 degrees", + "type": "builtin.temperature" + } + ], + "arrive": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "arrive", + "startIndex": 370, + "text": "5pm", + "type": "builtin.datetimeV2.time" + } + ], + "b": [ + { + "length": 10, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "b", + "startIndex": 314, + "text": "72 degrees", + "type": "builtin.temperature" + } + ], + "begin": [ + { + "length": 11, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "begin", + "startIndex": 65, + "text": "6 years old", + "type": "builtin.age" + } + ], + "buy": [ + { + "length": 5, + "modelType": "Regex Entity Extractor", + "modelTypeId": 8, + "recognitionSources": [ + "model" + ], + "role": "buy", + "startIndex": 119, + "text": "kb922", + "type": "Part" + } + ], + "Buyer": [ + { + "length": 5, + "modelType": "List Entity Extractor", + "modelTypeId": 5, + "recognitionSources": [ + "model" + ], + "role": "Buyer", + "startIndex": 173, + "text": "delta", + "type": "Airline" + } + ], + "Composite1": [ + { + "length": 172, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.01107535, + "startIndex": 0, + "text": "3 inches long by 2 inches wide and 5% to 10% and are you between 6 years old and 8 years old and can i trade kb457 for kb922 and change 425-777-1212 to 206-666-4123 and did", + "type": "Composite1" + } + ], + "Composite2": [ + { + "length": 45, + "modelType": "Composite Entity Extractor", + "modelTypeId": 4, + "recognitionSources": [ + "model" + ], + "score": 0.15191336, + "startIndex": 238, + "text": "http://foo.com changed to http://blah.com and", + "type": "Composite2" + } + ], + "destination": [ + { + "length": 7, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "role": "destination", + "score": 0.985884964, + "startIndex": 226, + "text": "redmond", + "type": "Weather.Location" + } + ], + "dimension": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 355, + "text": "3pm", + "type": "builtin.dimension" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 370, + "text": "5pm", + "type": "builtin.dimension" + } + ], + "end": [ + { + "length": 11, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "end", + "startIndex": 81, + "text": "8 years old", + "type": "builtin.age" + } + ], + "geographyV2": [ + { + "length": 6, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 212, + "text": "hawaii", + "type": "builtin.geographyV2.state" + }, + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 226, + "text": "redmond", + "type": "builtin.geographyV2.city" + } + ], + "leave": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "leave", + "startIndex": 355, + "text": "3pm", + "type": "builtin.datetimeV2.time" + } + ], + "length": [ + { + "length": 8, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "length", + "startIndex": 0, + "text": "3 inches", + "type": "builtin.dimension" + } + ], + "likee": [ + { + "length": 4, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "role": "likee", + "score": 0.9900547, + "startIndex": 340, + "text": "mary", + "type": "Name" + } + ], + "liker": [ + { + "length": 4, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "role": "liker", + "score": 0.992201567, + "startIndex": 329, + "text": "john", + "type": "Name" + } + ], + "max": [ + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "max", + "startIndex": 399, + "text": "$500", + "type": "builtin.currency" + } + ], + "maximum": [ + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "maximum", + "startIndex": 41, + "text": "10%", + "type": "builtin.percentage" + } + ], + "min": [ + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "min", + "startIndex": 390, + "text": "$400", + "type": "builtin.currency" + } + ], + "minimum": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "minimum", + "startIndex": 35, + "text": "5%", + "type": "builtin.percentage" + } + ], + "newPhone": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "newPhone", + "score": 0.9, + "startIndex": 152, + "text": "206-666-4123", + "type": "builtin.phonenumber" + } + ], + "number": [ + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 299, + "text": "68", + "type": "builtin.number" + }, + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 314, + "text": "72", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 391, + "text": "400", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 400, + "text": "500", + "type": "builtin.number" + } + ], + "old": [ + { + "length": 12, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "old", + "score": 0.9, + "startIndex": 136, + "text": "425-777-1212", + "type": "builtin.phonenumber" + } + ], + "oldURL": [ + { + "length": 14, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "oldURL", + "startIndex": 238, + "text": "http://foo.com", + "type": "builtin.url" + } + ], + "personName": [ + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 329, + "text": "john", + "type": "builtin.personName" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 340, + "text": "mary", + "type": "builtin.personName" + } + ], + "receiver": [ + { + "length": 18, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "receiver", + "startIndex": 413, + "text": "chrimc@hotmail.com", + "type": "builtin.email" + } + ], + "sell": [ + { + "length": 5, + "modelType": "Regex Entity Extractor", + "modelTypeId": 8, + "recognitionSources": [ + "model" + ], + "role": "sell", + "startIndex": 109, + "text": "kb457", + "type": "Part" + } + ], + "Seller": [ + { + "length": 6, + "modelType": "List Entity Extractor", + "modelTypeId": 5, + "recognitionSources": [ + "model" + ], + "role": "Seller", + "startIndex": 183, + "text": "virgin", + "type": "Airline" + } + ], + "sender": [ + { + "length": 14, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "sender", + "startIndex": 437, + "text": "emad@gmail.com", + "type": "builtin.email" + } + ], + "source": [ + { + "length": 6, + "modelType": "Entity Extractor", + "modelTypeId": 1, + "recognitionSources": [ + "model" + ], + "role": "source", + "score": 0.9713092, + "startIndex": 212, + "text": "hawaii", + "type": "Weather.Location" + } + ], + "width": [ + { + "length": 8, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "role": "width", + "startIndex": 17, + "text": "2 inches", + "type": "builtin.dimension" + } + ] + }, + "a": [ + { + "number": 68, + "unit": "Degree" + } + ], + "arrive": [ + { + "type": "time", + "values": [ + { + "timex": "T17", + "value": "17:00:00" + } + ] + } + ], + "b": [ + { + "number": 72, + "unit": "Degree" + } + ], + "begin": [ + { + "number": 6, + "unit": "Year" + } + ], + "buy": [ + "kb922" + ], + "Buyer": [ + [ + "Delta" + ] + ], + "Composite1": [ + { + "$instance": { + "datetimeV2": [ + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 65, + "text": "6 years", + "type": "builtin.datetimeV2.duration" + }, + { + "length": 7, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 81, + "text": "8 years", + "type": "builtin.datetimeV2.duration" + } + ], + "number": [ + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 0, + "text": "3", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 17, + "text": "2", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 35, + "text": "5", + "type": "builtin.number" + }, + { + "length": 2, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 41, + "text": "10", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 65, + "text": "6", + "type": "builtin.number" + }, + { + "length": 1, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 81, + "text": "8", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 136, + "text": "425", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 140, + "text": "777", + "type": "builtin.number" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 144, + "text": "1212", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 152, + "text": "206", + "type": "builtin.number" + }, + { + "length": 3, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 156, + "text": "666", + "type": "builtin.number" + }, + { + "length": 4, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 160, + "text": "4123", + "type": "builtin.number" + } + ] + }, + "datetimeV2": [ + { + "type": "duration", + "values": [ + { + "timex": "P6Y", + "value": "189216000" + } + ] + }, + { + "type": "duration", + "values": [ + { + "timex": "P8Y", + "value": "252288000" + } + ] + } + ], + "number": [ + 3, + 2, + 5, + 10, + 6, + 8, + 425, + 777, + 1212, + 206, + 666, + 4123 + ] + } + ], + "Composite2": [ + { + "$instance": { + "url": [ + { + "length": 15, + "modelType": "Prebuilt Entity Extractor", + "modelTypeId": 2, + "recognitionSources": [ + "model" + ], + "startIndex": 264, + "text": "http://blah.com", + "type": "builtin.url" + } + ] + }, + "url": [ + "http://blah.com" + ] + } + ], + "destination": [ + "redmond" + ], + "dimension": [ + { + "number": 3, + "unit": "Picometer" + }, + { + "number": 5, + "unit": "Picometer" + } + ], + "end": [ + { + "number": 8, + "unit": "Year" + } + ], + "geographyV2": [ + { + "type": "state", + "value": "hawaii" + }, + { + "type": "city", + "value": "redmond" + } + ], + "leave": [ + { + "type": "time", + "values": [ + { + "timex": "T15", + "value": "15:00:00" + } + ] + } + ], + "length": [ + { + "number": 3, + "unit": "Inch" + } + ], + "likee": [ + "mary" + ], + "liker": [ + "john" + ], + "max": [ + { + "number": 500, + "unit": "Dollar" + } + ], + "maximum": [ + 10 + ], + "min": [ + { + "number": 400, + "unit": "Dollar" + } + ], + "minimum": [ + 5 + ], + "newPhone": [ + "206-666-4123" + ], + "number": [ + 68, + 72, + 400, + 500 + ], + "old": [ + "425-777-1212" + ], + "oldURL": [ + "http://foo.com" + ], + "personName": [ + "john", + "mary" + ], + "receiver": [ + "chrimc@hotmail.com" + ], + "sell": [ + "kb457" + ], + "Seller": [ + [ + "Virgin" + ] + ], + "sender": [ + "emad@gmail.com" + ], + "source": [ + "hawaii" + ], + "width": [ + { + "number": 2, + "unit": "Inch" + } + ] + }, + "intents": { + "Cancel": { + "score": 4.50860341e-7 + }, + "Delivery": { + "score": 0.00007978094 + }, + "EntityTests": { + "score": 0.0046325135 + }, + "Greeting": { + "score": 4.73494453e-7 + }, + "Help": { + "score": 7.622754e-7 + }, + "None": { + "score": 0.00093744183 + }, + "Roles": { + "score": 1 + }, + "search": { + "score": 0.07635335 + }, + "SpecifyName": { + "score": 0.00009136085 + }, + "Travel": { + "score": 0.00771805458 + }, + "Weather.GetForecast": { + "score": 0.0100867962 + } + }, + "normalizedQuery": "3 inches long by 2 inches wide and 5% to 10% and are you between 6 years old and 8 years old and can i trade kb457 for kb922 and change 425-777-1212 to 206-666-4123 and did delta buy virgin and did the rain from hawaii get to redmond and http://foo.com changed to http://blah.com and i like between 68 degrees and 72 degrees and john likes mary and leave 3pm and arrive 5pm and pay between $400 and $500 and send chrimc@hotmail.com from emad@gmail.com", + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "topIntent": "Roles" + }, + "query": "3 inches long by 2 inches wide and 5% to 10% and are you between 6 years old and 8 years old and can i trade kb457 for kb922 and change 425-777-1212 to 206-666-4123 and did delta buy virgin and did the rain from hawaii get to redmond and http://foo.com changed to http://blah.com and i like between 68 degrees and 72 degrees and john likes mary and leave 3pm and arrive 5pm and pay between $400 and $500 and send chrimc@hotmail.com from emad@gmail.com" + }, + "options": { + "includeAllIntents": true, + "includeAPIResults": true, + "includeInstanceData": true, + "log": true, + "preferExternalEntities": true, + "slot": "production" + } + } +} \ No newline at end of file From 23657a196d0324bcd779d7ba180e1dfc01ddf932 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 04:29:00 -0400 Subject: [PATCH 438/616] Adding missing dependecies in test --- libraries/botbuilder-ai/tests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-ai/tests/requirements.txt b/libraries/botbuilder-ai/tests/requirements.txt index 9dec09f63..93fc8e8ff 100644 --- a/libraries/botbuilder-ai/tests/requirements.txt +++ b/libraries/botbuilder-ai/tests/requirements.txt @@ -1 +1 @@ -asynctest==0.13.0 \ No newline at end of file +aioresponses==0.6.3 \ No newline at end of file From ebcbb9c18b5ec9148a0ed0a0396d3d07b378fe30 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 04:33:04 -0400 Subject: [PATCH 439/616] Deleting unused import --- libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py index ba11e2fb5..69f8e0acb 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py @@ -15,8 +15,6 @@ from unittest.mock import MagicMock, Mock from botbuilder.ai.luis import LuisRecognizerOptionsV3 -from asynctest import CoroutineMock, patch - from botbuilder.ai.luis import LuisApplication, LuisPredictionOptions, LuisRecognizer from botbuilder.ai.luis.luis_util import LuisUtil from botbuilder.core import ( From 07ddc1d101147ce4f9ad77f4e39fa3977e23edf0 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 04:53:52 -0400 Subject: [PATCH 440/616] Pylint on test file --- .../tests/luis/luis_recognizer_test.py | 4 --- .../tests/luis/luis_recognizer_v3_test.py | 25 ++++++++----------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 753c3e0bb..33a45ff59 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -10,10 +10,6 @@ from unittest.mock import MagicMock, Mock from aiounittest import AsyncTestCase -from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient -from azure.cognitiveservices.language.luis.runtime.luis_runtime_client import ( - LUISRuntimeClientConfiguration, -) from msrest import Deserializer from requests import Session from requests.models import Response diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py index 69f8e0acb..b87252deb 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_v3_test.py @@ -1,20 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -# pylint: disable=protected-access +# pylint: disable=no-value-for-parameter import json from os import path from typing import Dict, Tuple, Union import re +from unittest import mock +from unittest.mock import MagicMock from aioresponses import aioresponses - from aiounittest import AsyncTestCase -from unittest import mock -from unittest.mock import MagicMock, Mock from botbuilder.ai.luis import LuisRecognizerOptionsV3 - from botbuilder.ai.luis import LuisApplication, LuisPredictionOptions, LuisRecognizer from botbuilder.ai.luis.luis_util import LuisUtil from botbuilder.core import ( @@ -56,7 +54,6 @@ def _remove_none_property(dictionary: Dict[str, object]) -> Dict[str, object]: return dictionary @classmethod - # @patch('aiohttp.ClientSession.post') @aioresponses() async def _get_recognizer_result( cls, @@ -69,7 +66,6 @@ async def _get_recognizer_result( telemetry_properties: Dict[str, str] = None, telemetry_metrics: Dict[str, float] = None, recognizer_class: type = LuisRecognizer, - ) -> Tuple[LuisRecognizer, RecognizerResult]: if isinstance(response_json, str): response_json = LuisRecognizerV3Test._get_json_for_file( @@ -82,7 +78,7 @@ async def _get_recognizer_result( context = LuisRecognizerV3Test._get_context(utterance, bot_adapter) # mock_get.return_value.__aenter__.return_value.json = CoroutineMock(side_effect=[response_json]) - pattern = re.compile(r'^https://westus.api.cognitive.microsoft.com.*$') + pattern = re.compile(r"^https://westus.api.cognitive.microsoft.com.*$") mock_get.post(pattern, payload=response_json, status=200) result = await recognizer.recognize( @@ -141,20 +137,19 @@ async def _test_json_v3(self, response_file: str) -> None: test_options = expected_json["v3"]["options"] options = LuisRecognizerOptionsV3( - include_all_intents = test_options["includeAllIntents"], + include_all_intents=test_options["includeAllIntents"], include_instance_data=test_options["includeInstanceData"], log=test_options["log"], prefer_external_entities=test_options["preferExternalEntities"], slot=test_options["slot"], - include_api_results = test_options["includeAPIResults"], + include_api_results=test_options["includeAPIResults"], ) if "version" in test_options: - options.version=test_options["version"] + options.version = test_options["version"] if "externalEntities" in test_options: - options.external_entities=test_options["externalEntities"] - + options.external_entities = test_options["externalEntities"] # dynamic_lists: List = None, # external_entities: List = None, @@ -231,7 +226,9 @@ async def async_magic(): # Act with mock.patch.object(TurnContext, "send_activity") as mock_send_activity: - await self._get_recognizer_result(utterance, response_json, options= LuisRecognizerOptionsV3()) + await LuisRecognizerV3Test._get_recognizer_result( + utterance, response_json, options=LuisRecognizerOptionsV3() + ) trace_activity: Activity = mock_send_activity.call_args[0][0] # Assert From e683d2cc467d382f944d6c9b1dc90382926772e9 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 05:05:32 -0400 Subject: [PATCH 441/616] Adding file headers --- .../botbuilder/ai/luis/luis_prediction_options.py | 2 ++ .../botbuilder/ai/luis/luis_recognizer_internal.py | 3 +++ .../botbuilder/ai/luis/luis_recognizer_options.py | 4 +++- .../botbuilder/ai/luis/luis_recognizer_options_v2.py | 4 +++- .../botbuilder/ai/luis/luis_recognizer_options_v3.py | 3 +++ .../botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py | 3 +++ .../botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py | 3 +++ 7 files changed, 20 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py index 335e98b08..bdd15ccd0 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py @@ -3,6 +3,8 @@ from botbuilder.core import BotTelemetryClient, NullTelemetryClient +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. class LuisPredictionOptions: """ diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py index b20410e39..66ec5a4ce 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_internal.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import ABC, abstractmethod from botbuilder.core import TurnContext from .luis_application import LuisApplication diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py index 36fb32d95..a37801f5f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py @@ -1,5 +1,7 @@ -from botbuilder.core import BotTelemetryClient, NullTelemetryClient +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botbuilder.core import BotTelemetryClient, NullTelemetryClient class LuisRecognizerOptions: def __init__( diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py index f8d4198c4..66ef025a9 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py @@ -1,7 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from botbuilder.core import BotTelemetryClient, NullTelemetryClient from .luis_recognizer_options import LuisRecognizerOptions - class LuisRecognizerOptionsV2(LuisRecognizerOptions): def __init__( self, diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py index bd5d7f4c1..7c45e900b 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import List from botbuilder.core import BotTelemetryClient, NullTelemetryClient diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py index 9e182488b..c1ed5ed6b 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v2.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Dict from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient from azure.cognitiveservices.language.luis.runtime.models import LuisResult diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py index c73b6c1fd..b7ad497f0 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import re from typing import Dict From 511a6008316cbde7237dee235dc096f37c187aeb Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 05:11:32 -0400 Subject: [PATCH 442/616] Fixing black --- .../botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py | 2 -- .../botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py | 1 + .../botbuilder/ai/luis/luis_recognizer_options_v2.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py index bdd15ccd0..335e98b08 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py @@ -3,8 +3,6 @@ from botbuilder.core import BotTelemetryClient, NullTelemetryClient -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. class LuisPredictionOptions: """ diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py index a37801f5f..4368aa443 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options.py @@ -3,6 +3,7 @@ from botbuilder.core import BotTelemetryClient, NullTelemetryClient + class LuisRecognizerOptions: def __init__( self, diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py index 66ef025a9..a06c6c5cc 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v2.py @@ -4,6 +4,7 @@ from botbuilder.core import BotTelemetryClient, NullTelemetryClient from .luis_recognizer_options import LuisRecognizerOptions + class LuisRecognizerOptionsV2(LuisRecognizerOptions): def __init__( self, From 65149c5752c6465903cf4e0afed1c1e9fde296e3 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 May 2020 08:01:43 -0500 Subject: [PATCH 443/616] Corrected Activity.create_reply to set recipient --- libraries/botbuilder-schema/botbuilder/schema/_models_py3.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 13f3856ad..aa49a4905 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -296,6 +296,10 @@ def create_reply(self, text: str = None, locale: str = None): id=self.recipient.id if self.recipient else None, name=self.recipient.name if self.recipient else None, ), + recipient=ChannelAccount( + id=self.from_property.id if self.from_property else None, + name=self.from_property.name if self.from_property else None, + ), reply_to_id=self.id, service_url=self.service_url, channel_id=self.channel_id, From a1f6f0427fd4ec591839f6ca5343e61a95024077 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 11:40:07 -0400 Subject: [PATCH 444/616] Fixing PR comments --- .../botbuilder/ai/luis/luis_recognizer_options_v3.py | 4 ++-- .../tests/luis/test_data/ExternalEntitiesAndRegex_v3.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py index 7c45e900b..4793e36f8 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_options_v3.py @@ -13,11 +13,11 @@ def __init__( include_all_intents: bool = False, include_instance_data: bool = True, log: bool = True, - prefer_external_entities: bool = False, + prefer_external_entities: bool = True, datetime_reference: str = None, dynamic_lists: List = None, external_entities: List = None, - slot: str = "production" or "staging", + slot: str = "production", version: str = None, include_api_results: bool = True, telemetry_client: BotTelemetryClient = NullTelemetryClient(), diff --git a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json index 3a92a6ef7..fa8566eb3 100644 --- a/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json +++ b/libraries/botbuilder-ai/tests/luis/test_data/ExternalEntitiesAndRegex_v3.json @@ -71,7 +71,7 @@ "text": "42ski is a part like kb423", "v3": { "options": { - "ExternalEntities": [ + "externalEntities": [ { "entityLength": 5, "entityName": "Part", From d6dd8629ea3d9a889dfac193be29975eb9243f85 Mon Sep 17 00:00:00 2001 From: Emilio Munoz Date: Mon, 4 May 2020 14:57:02 -0400 Subject: [PATCH 445/616] Dissbling ssl --- .../botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py index b7ad497f0..61fdfef6f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer_v3.py @@ -65,7 +65,9 @@ async def recognizer_internal(self, turn_context: TurnContext): } async with aiohttp.ClientSession() as session: - async with session.post(url, json=body, headers=headers) as result: + async with session.post( + url, json=body, headers=headers, ssl=False + ) as result: luis_result = await result.json() recognizer_result = RecognizerResult( From 3ad17c14e29ce7c1279861bca8a3ac5d0c3e2354 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 4 May 2020 14:56:37 -0500 Subject: [PATCH 446/616] Used HTTPStatus enum in SkillDialog tests --- .../tests/test_skill_dialog.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py index 4319d7d61..53b0a1d31 100644 --- a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py +++ b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import uuid +from http import HTTPStatus from typing import Callable, Union from unittest.mock import Mock @@ -233,9 +234,9 @@ async def test_should_intercept_oauth_cards_for_sso(self): async def post_return(): nonlocal sequence if sequence == 0: - result = InvokeResponse(body=first_response, status=200) + result = InvokeResponse(body=first_response, status=HTTPStatus.OK) else: - result = InvokeResponse(status=200) + result = InvokeResponse(status=HTTPStatus.OK) sequence += 1 return result @@ -277,9 +278,9 @@ async def test_should_not_intercept_oauth_cards_for_empty_connection_name(self): async def post_return(): nonlocal sequence if sequence == 0: - result = InvokeResponse(body=first_response, status=200) + result = InvokeResponse(body=first_response, status=HTTPStatus.OK) else: - result = InvokeResponse(status=200) + result = InvokeResponse(status=HTTPStatus.OK) sequence += 1 return result @@ -319,9 +320,9 @@ async def test_should_not_intercept_oauth_cards_for_empty_token(self): async def post_return(): nonlocal sequence if sequence == 0: - result = InvokeResponse(body=first_response, status=200) + result = InvokeResponse(body=first_response, status=HTTPStatus.OK) else: - result = InvokeResponse(status=200) + result = InvokeResponse(status=HTTPStatus.OK) sequence += 1 return result @@ -360,9 +361,9 @@ async def test_should_not_intercept_oauth_cards_for_token_exception(self): async def post_return(): nonlocal sequence if sequence == 0: - result = InvokeResponse(body=first_response, status=200) + result = InvokeResponse(body=first_response, status=HTTPStatus.OK) else: - result = InvokeResponse(status=200) + result = InvokeResponse(status=HTTPStatus.OK) sequence += 1 return result @@ -402,9 +403,9 @@ async def test_should_not_intercept_oauth_cards_for_bad_request(self): async def post_return(): nonlocal sequence if sequence == 0: - result = InvokeResponse(body=first_response, status=200) + result = InvokeResponse(body=first_response, status=HTTPStatus.OK) else: - result = InvokeResponse(status=409) + result = InvokeResponse(status=HTTPStatus.CONFLICT) sequence += 1 return result From d1168bc77c6cf5ec98e2b9485afa8fc7df7dec77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 5 May 2020 14:13:34 -0700 Subject: [PATCH 447/616] Adding oauth to exported packages --- libraries/botbuilder-core/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index 21a356b49..feef77146 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -38,6 +38,7 @@ "botbuilder.core.integration", "botbuilder.core.skills", "botbuilder.core.teams", + "botbuilder.core.oauth", ], install_requires=REQUIRES, classifiers=[ From c88c5390ec562186232fb967e635b5945e31a76d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 5 May 2020 16:29:33 -0500 Subject: [PATCH 448/616] Updated master to 4.10.0 (#1051) * Updated master to 4.10 * Updated master to 4.10.0 (the correct way this time) * Udpated CI yaml to use 3.7.7 to make DevOps happy --- .../botbuilder/adapters/slack/about.py | 2 +- libraries/botbuilder-adapters-slack/requirements.txt | 2 +- libraries/botbuilder-adapters-slack/setup.py | 6 +++--- libraries/botbuilder-ai/botbuilder/ai/about.py | 2 +- libraries/botbuilder-ai/requirements.txt | 4 ++-- libraries/botbuilder-ai/setup.py | 4 ++-- .../botbuilder/applicationinsights/about.py | 2 +- libraries/botbuilder-applicationinsights/requirements.txt | 2 +- libraries/botbuilder-applicationinsights/setup.py | 6 +++--- libraries/botbuilder-azure/botbuilder/azure/about.py | 2 +- libraries/botbuilder-azure/setup.py | 4 ++-- libraries/botbuilder-core/botbuilder/core/about.py | 2 +- libraries/botbuilder-core/requirements.txt | 4 ++-- libraries/botbuilder-core/setup.py | 6 +++--- libraries/botbuilder-dialogs/botbuilder/dialogs/about.py | 2 +- libraries/botbuilder-dialogs/requirements.txt | 6 +++--- libraries/botbuilder-dialogs/setup.py | 6 +++--- .../botbuilder/integration/aiohttp/about.py | 2 +- libraries/botbuilder-integration-aiohttp/requirements.txt | 4 ++-- libraries/botbuilder-integration-aiohttp/setup.py | 8 ++++---- .../integration/applicationinsights/aiohttp/about.py | 2 +- .../setup.py | 8 ++++---- libraries/botbuilder-schema/setup.py | 2 +- libraries/botbuilder-testing/botbuilder/testing/about.py | 2 +- libraries/botbuilder-testing/requirements.txt | 6 +++--- libraries/botbuilder-testing/setup.py | 6 +++--- libraries/botframework-connector/requirements.txt | 2 +- libraries/botframework-connector/setup.py | 4 ++-- .../functional-tests/functionaltestbot/requirements.txt | 2 +- libraries/functional-tests/functionaltestbot/setup.py | 2 +- pipelines/botbuilder-python-ci.yml | 2 +- 31 files changed, 57 insertions(+), 57 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py index 2babae85d..3d082bf1e 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-adapters-slack" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-adapters-slack/requirements.txt b/libraries/botbuilder-adapters-slack/requirements.txt index 4d6cdb67c..03bb1696b 100644 --- a/libraries/botbuilder-adapters-slack/requirements.txt +++ b/libraries/botbuilder-adapters-slack/requirements.txt @@ -1,4 +1,4 @@ aiohttp pyslack -botbuilder-core>=4.7.1 +botbuilder-core>=4.10.0 slackclient \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py index d154572f2..5669ed41a 100644 --- a/libraries/botbuilder-adapters-slack/setup.py +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema>=4.7.0", - "botframework-connector>=4.7.0", - "botbuilder-core>=4.7.0", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", + "botbuilder-core>=4.10.0", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-ai/botbuilder/ai/about.py b/libraries/botbuilder-ai/botbuilder/ai/about.py index 2fe559dac..dacddbf78 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/about.py +++ b/libraries/botbuilder-ai/botbuilder/ai/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-ai" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index dc2867a87..8800f3187 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botbuilder-schema>=4.7.1 -botbuilder-core>=4.7.1 +botbuilder-schema>=4.10.0 +botbuilder-core>=4.10.0 requests==2.22.0 aiounittest==1.3.0 azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index 72f112a5a..6ed1232dd 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -6,8 +6,8 @@ REQUIRES = [ "azure-cognitiveservices-language-luis==0.2.0", - "botbuilder-schema>=4.7.1", - "botbuilder-core>=4.7.1", + "botbuilder-schema>=4.10.0", + "botbuilder-core>=4.10.0", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py index 4c5006f5f..a23f9b305 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-applicationinsights" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-applicationinsights/requirements.txt b/libraries/botbuilder-applicationinsights/requirements.txt index e89251ff0..219d8769f 100644 --- a/libraries/botbuilder-applicationinsights/requirements.txt +++ b/libraries/botbuilder-applicationinsights/requirements.txt @@ -1,3 +1,3 @@ msrest==0.6.10 -botbuilder-core>=4.7.1 +botbuilder-core>=4.10.0 aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 0e4429065..5563b06ec 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -6,9 +6,9 @@ REQUIRES = [ "applicationinsights>=0.11.9", - "botbuilder-schema>=4.7.1", - "botframework-connector>=4.7.1", - "botbuilder-core>=4.7.1", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", + "botbuilder-core>=4.10.0", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-azure/botbuilder/azure/about.py b/libraries/botbuilder-azure/botbuilder/azure/about.py index a2276b583..bd82fa9c9 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/about.py +++ b/libraries/botbuilder-azure/botbuilder/azure/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-azure" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index 2245c3bbc..e92b2def0 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -7,8 +7,8 @@ REQUIRES = [ "azure-cosmos==3.1.2", "azure-storage-blob==2.1.0", - "botbuilder-schema>=4.7.1", - "botframework-connector>=4.7.1", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", "jsonpickle==1.2", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-core/botbuilder/core/about.py b/libraries/botbuilder-core/botbuilder/core/about.py index f47c7fd78..cff5f77f6 100644 --- a/libraries/botbuilder-core/botbuilder/core/about.py +++ b/libraries/botbuilder-core/botbuilder/core/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-core" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-core/requirements.txt b/libraries/botbuilder-core/requirements.txt index 330c0f2c4..987bd67bb 100644 --- a/libraries/botbuilder-core/requirements.txt +++ b/libraries/botbuilder-core/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botframework-connector>=4.7.1 -botbuilder-schema>=4.7.1 +botframework-connector>=4.10.0 +botbuilder-schema>=4.10.0 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8.0 diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index 21a356b49..f4e993c1f 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -4,10 +4,10 @@ import os from setuptools import setup -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = [ - "botbuilder-schema>=4.7.1", - "botframework-connector>=4.7.1", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", "jsonpickle==1.2", ] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py index 2f0ceb142..e4a8063ac 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-dialogs" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index e9dd4585d..70ab21445 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -1,7 +1,7 @@ msrest==0.6.10 -botframework-connector>=4.7.1 -botbuilder-schema>=4.7.1 -botbuilder-core>=4.7.1 +botframework-connector>=4.10.0 +botbuilder-schema>=4.10.0 +botbuilder-core>=4.10.0 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8 diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index f242baec4..67d31f34e 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -12,9 +12,9 @@ "recognizers-text>=1.0.2a1", "recognizers-text-choice>=1.0.2a1", "babel==2.7.0", - "botbuilder-schema>=4.7.1", - "botframework-connector>=4.7.1", - "botbuilder-core>=4.7.1", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", + "botbuilder-core>=4.10.0", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py index c0bfc2c92..eedd1bfdc 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-integration-aiohttp" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-integration-aiohttp/requirements.txt b/libraries/botbuilder-integration-aiohttp/requirements.txt index 1d6f7ab31..11a60fd4c 100644 --- a/libraries/botbuilder-integration-aiohttp/requirements.txt +++ b/libraries/botbuilder-integration-aiohttp/requirements.txt @@ -1,4 +1,4 @@ msrest==0.6.10 -botframework-connector>=4.7.1 -botbuilder-schema>=4.7.1 +botframework-connector>=4.10.0 +botbuilder-schema>=4.10.0 aiohttp>=3.6.2 \ No newline at end of file diff --git a/libraries/botbuilder-integration-aiohttp/setup.py b/libraries/botbuilder-integration-aiohttp/setup.py index df1778810..fc1be7607 100644 --- a/libraries/botbuilder-integration-aiohttp/setup.py +++ b/libraries/botbuilder-integration-aiohttp/setup.py @@ -4,11 +4,11 @@ import os from setuptools import setup -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = [ - "botbuilder-schema>=4.7.1", - "botframework-connector>=4.7.1", - "botbuilder-core>=4.7.1", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", + "botbuilder-core>=4.10.0", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py index 552d52e6e..51c0f5598 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-integration-applicationinsights-aiohttp" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py index ea8c2f359..36a1224fb 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py @@ -7,10 +7,10 @@ REQUIRES = [ "applicationinsights>=0.11.9", "aiohttp==3.6.2", - "botbuilder-schema>=4.7.1", - "botframework-connector>=4.7.1", - "botbuilder-core>=4.7.1", - "botbuilder-applicationinsights>=4.7.1", + "botbuilder-schema>=4.10.0", + "botframework-connector>=4.10.0", + "botbuilder-core>=4.10.0", + "botbuilder-applicationinsights>=4.10.0", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-schema/setup.py b/libraries/botbuilder-schema/setup.py index bb183e091..98cd8d7d9 100644 --- a/libraries/botbuilder-schema/setup.py +++ b/libraries/botbuilder-schema/setup.py @@ -5,7 +5,7 @@ from setuptools import setup NAME = "botbuilder-schema" -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = ["msrest==0.6.10"] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-testing/botbuilder/testing/about.py b/libraries/botbuilder-testing/botbuilder/testing/about.py index 09585f325..9688528e4 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/about.py +++ b/libraries/botbuilder-testing/botbuilder/testing/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-testing" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-testing/requirements.txt b/libraries/botbuilder-testing/requirements.txt index 91ef96796..3ca75dde5 100644 --- a/libraries/botbuilder-testing/requirements.txt +++ b/libraries/botbuilder-testing/requirements.txt @@ -1,4 +1,4 @@ -botbuilder-schema>=4.7.1 -botbuilder-core>=4.7.1 -botbuilder-dialogs>=4.7.1 +botbuilder-schema>=4.10.0 +botbuilder-core>=4.10.0 +botbuilder-dialogs>=4.10.0 aiounittest==1.3.0 diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py index 433235306..e45e0d1f2 100644 --- a/libraries/botbuilder-testing/setup.py +++ b/libraries/botbuilder-testing/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema>=4.7.1", - "botbuilder-core>=4.7.1", - "botbuilder-dialogs>=4.7.1", + "botbuilder-schema>=4.10.0", + "botbuilder-core>=4.10.0", + "botbuilder-dialogs>=4.10.0", ] TESTS_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 10b0b51a4..2511e913e 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -1,5 +1,5 @@ msrest==0.6.10 -botbuilder-schema>=4.7.1 +botbuilder-schema>=4.10.0 requests==2.22.0 PyJWT==1.5.3 cryptography==2.8.0 diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 792c5d374..1cc2844f1 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -4,13 +4,13 @@ from setuptools import setup NAME = "botframework-connector" -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = [ "msrest==0.6.10", "requests==2.22.0", "cryptography==2.8.0", "PyJWT==1.5.3", - "botbuilder-schema>=4.7.1", + "botbuilder-schema>=4.10.0", "adal==1.2.1", "msal==1.2.0", ] diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index a348b59af..b099b1ffd 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -1,5 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -botbuilder-core>=4.5.0.b4 +botbuilder-core>=4.10 flask==1.1.1 diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py index 1378ac4b0..8ad9c44c9 100644 --- a/libraries/functional-tests/functionaltestbot/setup.py +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -5,7 +5,7 @@ from setuptools import setup REQUIRES = [ - "botbuilder-core>=4.5.0.b4", + "botbuilder-core>=4.10", "flask==1.1.1", ] diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index d5c852b9e..bd3f81002 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -7,7 +7,7 @@ variables: COVERALLS_SERVICE_JOB_ID: $(Build.BuildId) COVERALLS_SERVICE_NAME: python-ci python.36: 3.6.10 - python.37: 3.7.6 + python.37: 3.7.7 python.38: 3.8.2 # PythonCoverallsToken: get this from Azure From e1c8afaf7449ae1520146ac5cfcb3ebc3a883639 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 6 May 2020 14:57:26 -0500 Subject: [PATCH 449/616] SkillDialog activity type handling updates (#1057) --- .../botbuilder/core/bot_framework_adapter.py | 20 ++-- .../botbuilder/core/invoke_response.py | 8 ++ .../skills/begin_skill_dialog_options.py | 12 +-- .../botbuilder/dialogs/skills/skill_dialog.py | 85 ++++++++++------ .../dialogs/skills/skill_dialog_options.py | 2 + .../tests/test_skill_dialog.py | 98 ++++++++++++++++--- 6 files changed, 163 insertions(+), 62 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 24c84a89c..731e37b70 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -481,14 +481,8 @@ async def process_activity_with_identity( await self.run_pipeline(context, logic) - if activity.type == ActivityTypes.invoke: - invoke_response = context.turn_state.get( - BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access - ) - if invoke_response is None: - return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) - return invoke_response.value - + # Handle ExpectedReplies scenarios where the all the activities have been buffered and sent back at once + # in an invoke response. # Return the buffered activities in the response. In this case, the invoker # should deserialize accordingly: # activities = ExpectedReplies().deserialize(response.body).activities @@ -498,6 +492,16 @@ async def process_activity_with_identity( ).serialize() return InvokeResponse(status=int(HTTPStatus.OK), body=expected_replies) + # Handle Invoke scenarios, which deviate from the request/request model in that + # the Bot will return a specific body and return code. + if activity.type == ActivityTypes.invoke: + invoke_response = context.turn_state.get( + BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access + ) + if invoke_response is None: + return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) + return invoke_response.value + return None async def __generate_callerid(self, claims_identity: ClaimsIdentity) -> str: diff --git a/libraries/botbuilder-core/botbuilder/core/invoke_response.py b/libraries/botbuilder-core/botbuilder/core/invoke_response.py index 7d258559e..fa0b74577 100644 --- a/libraries/botbuilder-core/botbuilder/core/invoke_response.py +++ b/libraries/botbuilder-core/botbuilder/core/invoke_response.py @@ -24,3 +24,11 @@ def __init__(self, status: int = None, body: object = None): """ self.status = status self.body = body + + def is_successful_status_code(self) -> bool: + """ + Gets a value indicating whether the invoke response was successful. + :return: A value that indicates if the HTTP response was successful. true if status is in + the Successful range (200-299); otherwise false. + """ + return 200 <= self.status <= 299 diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py index da2d39914..a9d21ca3f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py @@ -5,19 +5,13 @@ class BeginSkillDialogOptions: - def __init__( - self, activity: Activity, connection_name: str = None - ): # pylint: disable=unused-argument + def __init__(self, activity: Activity): self.activity = activity - self.connection_name = connection_name @staticmethod def from_object(obj: object) -> "BeginSkillDialogOptions": if isinstance(obj, dict) and "activity" in obj: - return BeginSkillDialogOptions(obj["activity"], obj.get("connection_name")) + return BeginSkillDialogOptions(obj["activity"]) if hasattr(obj, "activity"): - return BeginSkillDialogOptions( - obj.activity, - obj.connection_name if hasattr(obj, "connection_name") else None, - ) + return BeginSkillDialogOptions(obj.activity) return None diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py index f0ed30d38..b26fa6341 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -36,7 +36,6 @@ def __init__(self, dialog_options: SkillDialogOptions, dialog_id: str): self.dialog_options = dialog_options self._deliver_mode_state_key = "deliverymode" - self._sso_connection_name_key = "SkillDialog.SSOConnectionName" async def begin_dialog(self, dialog_context: DialogContext, options: object = None): """ @@ -44,7 +43,7 @@ async def begin_dialog(self, dialog_context: DialogContext, options: object = No :param dialog_context: The dialog context for the current turn of conversation. :param options: (Optional) additional argument(s) to pass to the dialog being started. """ - dialog_args = SkillDialog._validate_begin_dialog_args(options) + dialog_args = self._validate_begin_dialog_args(options) await dialog_context.context.send_trace_activity( f"{SkillDialog.__name__}.BeginDialogAsync()", @@ -61,24 +60,22 @@ async def begin_dialog(self, dialog_context: DialogContext, options: object = No is_incoming=True, ) + # Store delivery mode in dialog state for later use. dialog_context.active_dialog.state[ self._deliver_mode_state_key ] = dialog_args.activity.delivery_mode - dialog_context.active_dialog.state[ - self._sso_connection_name_key - ] = dialog_args.connection_name - # Send the activity to the skill. - eoc_activity = await self._send_to_skill( - dialog_context.context, skill_activity, dialog_args.connection_name - ) + eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity) if eoc_activity: return await dialog_context.end_dialog(eoc_activity.value) return self.end_of_turn async def continue_dialog(self, dialog_context: DialogContext): + if not self._on_validate_activity(dialog_context.context.activity): + return self.end_of_turn + await dialog_context.context.send_trace_activity( f"{SkillDialog.__name__}.continue_dialog()", label=f"ActivityType: {dialog_context.context.activity.type}", @@ -98,17 +95,13 @@ async def continue_dialog(self, dialog_context: DialogContext): # Create deep clone of the original activity to avoid altering it before forwarding it. skill_activity = deepcopy(dialog_context.context.activity) + skill_activity.delivery_mode = dialog_context.active_dialog.state[ self._deliver_mode_state_key ] - connection_name = dialog_context.active_dialog.state[ - self._sso_connection_name_key - ] # Just forward to the remote skill - eoc_activity = await self._send_to_skill( - dialog_context.context, skill_activity, connection_name - ) + eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity) if eoc_activity: return await dialog_context.end_dialog(eoc_activity.value) @@ -163,8 +156,7 @@ async def end_dialog( await super().end_dialog(context, instance, reason) - @staticmethod - def _validate_begin_dialog_args(options: object) -> BeginSkillDialogOptions: + def _validate_begin_dialog_args(self, options: object) -> BeginSkillDialogOptions: if not options: raise TypeError("options cannot be None.") @@ -182,26 +174,36 @@ def _validate_begin_dialog_args(options: object) -> BeginSkillDialogOptions: return dialog_args + def _on_validate_activity( + self, activity: Activity # pylint: disable=unused-argument + ) -> bool: + """ + Validates the activity sent during continue_dialog. + + Override this method to implement a custom validator for the activity being sent during continue_dialog. + This method can be used to ignore activities of a certain type if needed. + If this method returns false, the dialog will end the turn without processing the activity. + """ + return True + async def _send_to_skill( - self, context: TurnContext, activity: Activity, connection_name: str = None + self, context: TurnContext, activity: Activity ) -> Activity: - # Create a conversationId to interact with the skill and send the activity - conversation_id_factory_options = SkillConversationIdFactoryOptions( - from_bot_oauth_scope=context.turn_state.get(BotAdapter.BOT_OAUTH_SCOPE_KEY), - from_bot_id=self.dialog_options.bot_id, - activity=activity, - bot_framework_skill=self.dialog_options.skill, - ) - - skill_conversation_id = await self.dialog_options.conversation_id_factory.create_skill_conversation_id( - conversation_id_factory_options + if activity.type == ActivityTypes.invoke: + # Force ExpectReplies for invoke activities so we can get the replies right away and send + # them back to the channel if needed. This makes sure that the dialog will receive the Invoke + # response from the skill and any other activities sent, including EoC. + activity.delivery_mode = DeliveryModes.expect_replies + + skill_conversation_id = await self._create_skill_conversation_id( + context, activity ) # Always save state before forwarding # (the dialog stack won't get updated with the skillDialog and things won't work if you don't) - skill_info = self.dialog_options.skill await self.dialog_options.conversation_state.save_changes(context, True) + skill_info = self.dialog_options.skill response = await self.dialog_options.skill_client.post_activity( self.dialog_options.bot_id, skill_info.app_id, @@ -229,7 +231,7 @@ async def _send_to_skill( # Capture the EndOfConversation activity if it was sent from skill eoc_activity = from_skill_activity elif await self._intercept_oauth_cards( - context, from_skill_activity, connection_name + context, from_skill_activity, self.dialog_options.connection_name ): # do nothing. Token exchange succeeded, so no oauthcard needs to be shown to the user pass @@ -239,6 +241,21 @@ async def _send_to_skill( return eoc_activity + async def _create_skill_conversation_id( + self, context: TurnContext, activity: Activity + ) -> str: + # Create a conversationId to interact with the skill and send the activity + conversation_id_factory_options = SkillConversationIdFactoryOptions( + from_bot_oauth_scope=context.turn_state.get(BotAdapter.BOT_OAUTH_SCOPE_KEY), + from_bot_id=self.dialog_options.bot_id, + activity=activity, + bot_framework_skill=self.dialog_options.skill, + ) + skill_conversation_id = await self.dialog_options.conversation_id_factory.create_skill_conversation_id( + conversation_id_factory_options + ) + return skill_conversation_id + async def _intercept_oauth_cards( self, context: TurnContext, activity: Activity, connection_name: str ): @@ -248,6 +265,8 @@ async def _intercept_oauth_cards( if not connection_name or not isinstance( context.adapter, ExtendedUserTokenProvider ): + # The adapter may choose not to support token exchange, in which case we fallback to + # showing an oauth card to the user. return False oauth_card_attachment = next( @@ -273,6 +292,8 @@ async def _intercept_oauth_cards( ) if result and result.token: + # If token above is null, then SSO has failed and hence we return false. + # If not, send an invoke to the skill with the token. return await self._send_token_exchange_invoke_to_skill( activity, oauth_card.token_exchange_resource.id, @@ -280,6 +301,8 @@ async def _intercept_oauth_cards( result.token, ) except: + # Failures in token exchange are not fatal. They simply mean that the user needs + # to be shown the OAuth card. return False return False @@ -310,4 +333,4 @@ async def _send_token_exchange_invoke_to_skill( ) # Check response status: true if success, false if failure - return response.status / 100 == 2 + return response.is_successful_status_code() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py index 53d56f72e..028490a40 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog_options.py @@ -18,6 +18,7 @@ def __init__( skill: BotFrameworkSkill = None, conversation_id_factory: ConversationIdFactoryBase = None, conversation_state: ConversationState = None, + connection_name: str = None, ): self.bot_id = bot_id self.skill_client = skill_client @@ -25,3 +26,4 @@ def __init__( self.skill = skill self.conversation_id_factory = conversation_id_factory self.conversation_state = conversation_state + self.connection_name = connection_name diff --git a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py index 53b0a1d31..4b246189f 100644 --- a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py +++ b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py @@ -104,7 +104,13 @@ async def test_begin_dialog_options_validation(self): with self.assertRaises(TypeError): await client.send_activity("irrelevant") - async def test_begin_dialog_calls_skill(self): + async def test_begin_dialog_calls_skill_no_deliverymode(self): + return await self.begin_dialog_calls_skill(None) + + async def test_begin_dialog_calls_skill_expect_replies(self): + return await self.begin_dialog_calls_skill(DeliveryModes.expect_replies) + + async def begin_dialog_calls_skill(self, deliver_mode: str): activity_sent = None from_bot_id_sent = None to_bot_id_sent = None @@ -133,6 +139,67 @@ async def capture( sut = SkillDialog(dialog_options, "dialog_id") activity_to_send = MessageFactory.text(str(uuid.uuid4())) + activity_to_send.delivery_mode = deliver_mode + + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity=activity_to_send), + conversation_state=conversation_state, + ) + + # Send something to the dialog to start it + await client.send_activity(MessageFactory.text("irrelevant")) + + # Assert results and data sent to the SkillClient for fist turn + assert dialog_options.bot_id == from_bot_id_sent + assert dialog_options.skill.app_id == to_bot_id_sent + assert dialog_options.skill.skill_endpoint == to_url_sent + assert activity_to_send.text == activity_sent.text + assert DialogTurnStatus.Waiting == client.dialog_turn_result.status + + # Send a second message to continue the dialog + await client.send_activity(MessageFactory.text("Second message")) + + # Assert results for second turn + assert activity_sent.text == "Second message" + assert DialogTurnStatus.Waiting == client.dialog_turn_result.status + + # Send EndOfConversation to the dialog + await client.send_activity(Activity(type=ActivityTypes.end_of_conversation)) + + # Assert we are done. + assert DialogTurnStatus.Complete == client.dialog_turn_result.status + + async def test_should_handle_invoke_activities(self): + activity_sent = None + from_bot_id_sent = None + to_bot_id_sent = None + to_url_sent = None + + async def capture( + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, # pylint: disable=unused-argument + conversation_id: str, # pylint: disable=unused-argument + activity: Activity, + ): + nonlocal from_bot_id_sent, to_bot_id_sent, to_url_sent, activity_sent + from_bot_id_sent = from_bot_id + to_bot_id_sent = to_bot_id + to_url_sent = to_url + activity_sent = activity + + mock_skill_client = self._create_mock_skill_client(capture) + + conversation_state = ConversationState(MemoryStorage()) + dialog_options = SkillDialogTests.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + + sut = SkillDialog(dialog_options, "dialog_id") + activity_to_send = Activity(type=ActivityTypes.invoke, name=str(uuid.uuid4()),) client = DialogTestClient( "test", @@ -141,21 +208,27 @@ async def capture( conversation_state=conversation_state, ) + # Send something to the dialog to start it await client.send_activity(MessageFactory.text("irrelevant")) + # Assert results and data sent to the SkillClient for fist turn assert dialog_options.bot_id == from_bot_id_sent assert dialog_options.skill.app_id == to_bot_id_sent assert dialog_options.skill.skill_endpoint == to_url_sent assert activity_to_send.text == activity_sent.text assert DialogTurnStatus.Waiting == client.dialog_turn_result.status + # Send a second message to continue the dialog await client.send_activity(MessageFactory.text("Second message")) + # Assert results for second turn assert activity_sent.text == "Second message" assert DialogTurnStatus.Waiting == client.dialog_turn_result.status + # Send EndOfConversation to the dialog await client.send_activity(Activity(type=ActivityTypes.end_of_conversation)) + # Assert we are done. assert DialogTurnStatus.Complete == client.dialog_turn_result.status async def test_cancel_dialog_sends_eoc(self): @@ -244,7 +317,7 @@ async def post_return(): conversation_state = ConversationState(MemoryStorage()) dialog_options = SkillDialogTests.create_skill_dialog_options( - conversation_state, mock_skill_client + conversation_state, mock_skill_client, connection_name ) sut = SkillDialog(dialog_options, dialog_id="dialog") activity_to_send = SkillDialogTests.create_send_activity() @@ -252,9 +325,7 @@ async def post_return(): client = DialogTestClient( "test", sut, - BeginSkillDialogOptions( - activity=activity_to_send, connection_name=connection_name, - ), + BeginSkillDialogOptions(activity=activity_to_send,), conversation_state=conversation_state, ) @@ -371,13 +442,11 @@ async def post_return(): conversation_state = ConversationState(MemoryStorage()) dialog_options = SkillDialogTests.create_skill_dialog_options( - conversation_state, mock_skill_client + conversation_state, mock_skill_client, connection_name ) sut = SkillDialog(dialog_options, dialog_id="dialog") activity_to_send = SkillDialogTests.create_send_activity() - initial_dialog_options = BeginSkillDialogOptions( - activity=activity_to_send, connection_name=connection_name, - ) + initial_dialog_options = BeginSkillDialogOptions(activity=activity_to_send,) client = DialogTestClient( "test", sut, initial_dialog_options, conversation_state=conversation_state, @@ -413,7 +482,7 @@ async def post_return(): conversation_state = ConversationState(MemoryStorage()) dialog_options = SkillDialogTests.create_skill_dialog_options( - conversation_state, mock_skill_client + conversation_state, mock_skill_client, connection_name ) sut = SkillDialog(dialog_options, dialog_id="dialog") activity_to_send = SkillDialogTests.create_send_activity() @@ -421,9 +490,7 @@ async def post_return(): client = DialogTestClient( "test", sut, - BeginSkillDialogOptions( - activity=activity_to_send, connection_name=connection_name, - ), + BeginSkillDialogOptions(activity=activity_to_send,), conversation_state=conversation_state, ) @@ -437,7 +504,9 @@ async def post_return(): @staticmethod def create_skill_dialog_options( - conversation_state: ConversationState, skill_client: BotFrameworkClient + conversation_state: ConversationState, + skill_client: BotFrameworkClient, + connection_name: str = None, ): return SkillDialogOptions( bot_id=str(uuid.uuid4()), @@ -449,6 +518,7 @@ def create_skill_dialog_options( app_id=str(uuid.uuid4()), skill_endpoint="http://testskill.contoso.com/api/messages", ), + connection_name=connection_name, ) @staticmethod From 3a242b11d536c6fa86c2bb4f8d98a31094ef19b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 11 May 2020 12:52:43 -0700 Subject: [PATCH 450/616] Create UsingTestPyPI.md (#1061) --- UsingTestPyPI.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 UsingTestPyPI.md diff --git a/UsingTestPyPI.md b/UsingTestPyPI.md new file mode 100644 index 000000000..4bbe31a4f --- /dev/null +++ b/UsingTestPyPI.md @@ -0,0 +1,19 @@ +# Using TestPyPI to consume rc builds +The BotBuilder SDK rc build feed is found on [TestPyPI](https://test.pypi.org/). + +The daily builds will be available soon through the mentioned feed as well. + + +# Configure TestPyPI + +You can tell pip to download packages from TestPyPI instead of PyPI by specifying the --index-url flag (in the example below, replace 'botbuilder-core' for the name of the library you want to install) + +```bash +$ pip install --index-url https://test.pypi.org/simple/ botbuilder-core +``` +If you want to allow pip to also pull other packages from PyPI you can specify --extra-index-url to point to PyPI. +This is useful when the package you’re testing has dependencies: + +```bash +pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ botbuilder-core +``` From eacac2d1ca9ec7a56d34fab20bc837f3e76e03ef Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Thu, 14 May 2020 11:59:28 -0700 Subject: [PATCH 451/616] Do NOT call TeamsInfo.get_member for the bot (#1066) * Do not call TeamsInfo.get_member for the bot * fix botid for test --- .../core/teams/teams_activity_handler.py | 6 +++- .../teams/test_teams_activity_handler.py | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 72cd23022..b5dc77e99 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -350,7 +350,11 @@ async def on_teams_members_added_dispatch( # pylint: disable=unused-argument team_members_added = [] for member in members_added: - if member.additional_properties != {}: + is_bot = ( + turn_context.activity.recipient is not None + and member.id == turn_context.activity.recipient.id + ) + if member.additional_properties != {} or is_bot: team_members_added.append( deserializer_helper(TeamsChannelAccount, member) ) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 2ad52f76e..2bbeb6ee4 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -385,6 +385,38 @@ async def test_on_teams_members_added_activity(self): assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_members_added" + async def test_bot_on_teams_members_added_activity(self): + # arrange + activity = Activity( + recipient=ChannelAccount(id="botid"), + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamMemberAdded", + "team": {"id": "team_id_1", "name": "new_team_name"}, + }, + members_added=[ + ChannelAccount( + id="botid", + name="test_user", + aad_object_id="asdfqwerty", + role="tester", + ) + ], + channel_id=Channels.ms_teams, + conversation=ConversationAccount(id="456"), + ) + + turn_context = TurnContext(SimpleAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_members_added" + async def test_on_teams_members_removed_activity(self): # arrange activity = Activity( From a15d9f300170a18af2e5fccc1337ec6a8b2beb66 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Fri, 15 May 2020 00:15:07 -0700 Subject: [PATCH 452/616] adding TeamInfo to members removed for C# parity' --- .../botbuilder/core/teams/teams_activity_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 72cd23022..41b84ff0f 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -408,10 +408,10 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument TeamsChannelAccount().deserialize(new_account_json) ) - return await self.on_teams_members_removed(teams_members_removed, turn_context) + return await self.on_teams_members_removed(teams_members_removed, team_info, turn_context) async def on_teams_members_removed( - self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + self, teams_members_removed: [TeamsChannelAccount], teams_info: TeamInfo, turn_context: TurnContext ): members_removed = [ ChannelAccount().deserialize(member.serialize()) From 11b08778beb6f3d31c65fb35b54beabb89fb4bdc Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 15 May 2020 08:37:44 -0500 Subject: [PATCH 453/616] Updated requests to 2.23.0, pinned more dependencies --- libraries/botbuilder-adapters-slack/requirements.txt | 4 ++-- libraries/botbuilder-adapters-slack/setup.py | 6 +++--- libraries/botbuilder-ai/requirements.txt | 6 +++--- libraries/botbuilder-ai/setup.py | 4 ++-- libraries/botbuilder-applicationinsights/requirements.txt | 2 +- libraries/botbuilder-applicationinsights/setup.py | 8 ++++---- libraries/botbuilder-azure/setup.py | 4 ++-- libraries/botbuilder-core/requirements.txt | 6 +++--- libraries/botbuilder-core/setup.py | 4 ++-- libraries/botbuilder-dialogs/requirements.txt | 8 ++++---- libraries/botbuilder-dialogs/setup.py | 6 +++--- libraries/botbuilder-integration-aiohttp/requirements.txt | 6 +++--- libraries/botbuilder-integration-aiohttp/setup.py | 6 +++--- .../setup.py | 8 ++++---- libraries/botbuilder-testing/requirements.txt | 6 +++--- libraries/botbuilder-testing/setup.py | 6 +++--- libraries/botframework-connector/requirements.txt | 4 ++-- libraries/botframework-connector/setup.py | 4 ++-- 18 files changed, 49 insertions(+), 49 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/requirements.txt b/libraries/botbuilder-adapters-slack/requirements.txt index 03bb1696b..21f25976c 100644 --- a/libraries/botbuilder-adapters-slack/requirements.txt +++ b/libraries/botbuilder-adapters-slack/requirements.txt @@ -1,4 +1,4 @@ -aiohttp +aiohttp==3.6.2 pyslack -botbuilder-core>=4.10.0 +botbuilder-core==4.10.0 slackclient \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py index 5669ed41a..666e321f2 100644 --- a/libraries/botbuilder-adapters-slack/setup.py +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", - "botbuilder-core>=4.10.0", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", + "botbuilder-core==4.10.0", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index 8800f3187..3fc0566e9 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botbuilder-schema>=4.10.0 -botbuilder-core>=4.10.0 -requests==2.22.0 +botbuilder-schema==4.10.0 +botbuilder-core==4.10.0 +requests==2.23.0 aiounittest==1.3.0 azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index 6ed1232dd..ce4aeff18 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -6,8 +6,8 @@ REQUIRES = [ "azure-cognitiveservices-language-luis==0.2.0", - "botbuilder-schema>=4.10.0", - "botbuilder-core>=4.10.0", + "botbuilder-schema==4.10.0", + "botbuilder-core==4.10.0", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-applicationinsights/requirements.txt b/libraries/botbuilder-applicationinsights/requirements.txt index 219d8769f..ace87c47c 100644 --- a/libraries/botbuilder-applicationinsights/requirements.txt +++ b/libraries/botbuilder-applicationinsights/requirements.txt @@ -1,3 +1,3 @@ msrest==0.6.10 -botbuilder-core>=4.10.0 +botbuilder-core==4.10.0 aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 5563b06ec..91de6d1bb 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -5,10 +5,10 @@ from setuptools import setup REQUIRES = [ - "applicationinsights>=0.11.9", - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", - "botbuilder-core>=4.10.0", + "applicationinsights==0.11.9", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", + "botbuilder-core==4.10.0", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index e92b2def0..7b1a77c64 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -7,8 +7,8 @@ REQUIRES = [ "azure-cosmos==3.1.2", "azure-storage-blob==2.1.0", - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", "jsonpickle==1.2", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-core/requirements.txt b/libraries/botbuilder-core/requirements.txt index 987bd67bb..06e8b3261 100644 --- a/libraries/botbuilder-core/requirements.txt +++ b/libraries/botbuilder-core/requirements.txt @@ -1,7 +1,7 @@ msrest==0.6.10 -botframework-connector>=4.10.0 -botbuilder-schema>=4.10.0 -requests==2.22.0 +botframework-connector==4.10.0 +botbuilder-schema==4.10.0 +requests==2.23.0 PyJWT==1.5.3 cryptography==2.8.0 aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index a3d106ac5..fd6e62a24 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -6,8 +6,8 @@ VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = [ - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", "jsonpickle==1.2", ] diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index 70ab21445..afa25c24e 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -1,8 +1,8 @@ msrest==0.6.10 -botframework-connector>=4.10.0 -botbuilder-schema>=4.10.0 -botbuilder-core>=4.10.0 -requests==2.22.0 +botframework-connector==4.10.0 +botbuilder-schema==4.10.0 +botbuilder-core==4.10.0 +requests==2.23.0 PyJWT==1.5.3 cryptography==2.8 aiounittest==1.3.0 diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index 67d31f34e..a0719ef81 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -12,9 +12,9 @@ "recognizers-text>=1.0.2a1", "recognizers-text-choice>=1.0.2a1", "babel==2.7.0", - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", - "botbuilder-core>=4.10.0", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", + "botbuilder-core==4.10.0", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-integration-aiohttp/requirements.txt b/libraries/botbuilder-integration-aiohttp/requirements.txt index 11a60fd4c..b2706949b 100644 --- a/libraries/botbuilder-integration-aiohttp/requirements.txt +++ b/libraries/botbuilder-integration-aiohttp/requirements.txt @@ -1,4 +1,4 @@ msrest==0.6.10 -botframework-connector>=4.10.0 -botbuilder-schema>=4.10.0 -aiohttp>=3.6.2 \ No newline at end of file +botframework-connector==4.10.0 +botbuilder-schema==4.10.0 +aiohttp==3.6.2 \ No newline at end of file diff --git a/libraries/botbuilder-integration-aiohttp/setup.py b/libraries/botbuilder-integration-aiohttp/setup.py index fc1be7607..7e8376ff3 100644 --- a/libraries/botbuilder-integration-aiohttp/setup.py +++ b/libraries/botbuilder-integration-aiohttp/setup.py @@ -6,9 +6,9 @@ VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = [ - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", - "botbuilder-core>=4.10.0", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", + "botbuilder-core==4.10.0", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py index 36a1224fb..979e3684a 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py @@ -7,10 +7,10 @@ REQUIRES = [ "applicationinsights>=0.11.9", "aiohttp==3.6.2", - "botbuilder-schema>=4.10.0", - "botframework-connector>=4.10.0", - "botbuilder-core>=4.10.0", - "botbuilder-applicationinsights>=4.10.0", + "botbuilder-schema==4.10.0", + "botframework-connector==4.10.0", + "botbuilder-core==4.10.0", + "botbuilder-applicationinsights==4.10.0", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-testing/requirements.txt b/libraries/botbuilder-testing/requirements.txt index 3ca75dde5..d6350bd0c 100644 --- a/libraries/botbuilder-testing/requirements.txt +++ b/libraries/botbuilder-testing/requirements.txt @@ -1,4 +1,4 @@ -botbuilder-schema>=4.10.0 -botbuilder-core>=4.10.0 -botbuilder-dialogs>=4.10.0 +botbuilder-schema==4.10.0 +botbuilder-core==4.10.0 +botbuilder-dialogs==4.10.0 aiounittest==1.3.0 diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py index e45e0d1f2..21cb2f684 100644 --- a/libraries/botbuilder-testing/setup.py +++ b/libraries/botbuilder-testing/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema>=4.10.0", - "botbuilder-core>=4.10.0", - "botbuilder-dialogs>=4.10.0", + "botbuilder-schema==4.10.0", + "botbuilder-core==4.10.0", + "botbuilder-dialogs==4.10.0", ] TESTS_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 2511e913e..1d47eebff 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botbuilder-schema>=4.10.0 -requests==2.22.0 +botbuilder-schema==4.10.0 +requests==2.23.0 PyJWT==1.5.3 cryptography==2.8.0 msal==1.2.0 diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 1cc2844f1..fc3fc82e1 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -7,10 +7,10 @@ VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" REQUIRES = [ "msrest==0.6.10", - "requests==2.22.0", + "requests==2.23.0", "cryptography==2.8.0", "PyJWT==1.5.3", - "botbuilder-schema>=4.10.0", + "botbuilder-schema==4.10.0", "adal==1.2.1", "msal==1.2.0", ] From 492ab5eef5e1161faa02ac3b9870dbc0cf388207 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Fri, 15 May 2020 09:48:11 -0700 Subject: [PATCH 454/616] singular team --- .../botbuilder/core/teams/teams_activity_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 41b84ff0f..4778ce444 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -411,7 +411,7 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument return await self.on_teams_members_removed(teams_members_removed, team_info, turn_context) async def on_teams_members_removed( - self, teams_members_removed: [TeamsChannelAccount], teams_info: TeamInfo, turn_context: TurnContext + self, teams_members_removed: [TeamsChannelAccount], team_info: TeamInfo, turn_context: TurnContext ): members_removed = [ ChannelAccount().deserialize(member.serialize()) From 5c08d55f3950c8ff01f2d08eac6cabce080bd149 Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Mon, 18 May 2020 13:44:08 -0700 Subject: [PATCH 455/616] prompt hardening --- .../botbuilder/dialogs/prompts/choice_prompt.py | 2 ++ .../botbuilder/dialogs/prompts/confirm_prompt.py | 8 +++++--- .../botbuilder/dialogs/prompts/datetime_prompt.py | 12 +++++++++--- .../botbuilder/dialogs/prompts/number_prompt.py | 6 ++++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py index 3332f3994..93bf929dd 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/choice_prompt.py @@ -152,6 +152,8 @@ async def on_recognize( if turn_context.activity.type == ActivityTypes.message: activity: Activity = turn_context.activity utterance: str = activity.text + if not utterance: + return result opt: FindChoicesOptions = self.recognizer_options if self.recognizer_options else FindChoicesOptions() opt.locale = ( activity.locale diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index 706369cc6..b5f902c50 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -122,9 +122,11 @@ async def on_recognize( result = PromptRecognizerResult() if turn_context.activity.type == ActivityTypes.message: # Recognize utterance - message = turn_context.activity + utterance = turn_context.activity.text + if not utterance: + return result culture = self.determine_culture(turn_context.activity) - results = recognize_boolean(message.text, culture) + results = recognize_boolean(utterance, culture) if results: first = results[0] if "value" in first.resolution: @@ -151,7 +153,7 @@ async def on_recognize( ) choices = {confirm_choices[0], confirm_choices[1]} second_attempt_results = ChoiceRecognizers.recognize_choices( - message.text, choices + utterance, choices ) if second_attempt_results: result.succeeded = True diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py index 3eceeb184..907d81f7d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py @@ -50,11 +50,17 @@ async def on_recognize( result = PromptRecognizerResult() if turn_context.activity.type == ActivityTypes.message: # Recognize utterance - message = turn_context.activity + utterance = turn_context.activity.text + if not utterance: + return result # TODO: English constant needs to be ported. - culture = message.locale if message.locale is not None else "English" + culture = ( + turn_context.activity.locale + if turn_context.activity.locale is not None + else "English" + ) - results = recognize_datetime(message.text, culture) + results = recognize_datetime(utterance, culture) if results: result.succeeded = True result.value = [] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py index ed757c391..519ba39c9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py @@ -55,9 +55,11 @@ async def on_recognize( result = PromptRecognizerResult() if turn_context.activity.type == ActivityTypes.message: - message = turn_context.activity + utterance = turn_context.activity.text + if not utterance: + return result culture = self._get_culture(turn_context) - results: [ModelResult] = recognize_number(message.text, culture) + results: [ModelResult] = recognize_number(utterance, culture) if results: result.succeeded = True From c4cac78c9eb443896fb126e90b78eafed7be08a1 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Fri, 15 May 2020 00:15:07 -0700 Subject: [PATCH 456/616] adding TeamInfo to members removed for C# parity' --- .../botbuilder/core/teams/teams_activity_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index b5dc77e99..59a3a624d 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -412,10 +412,10 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument TeamsChannelAccount().deserialize(new_account_json) ) - return await self.on_teams_members_removed(teams_members_removed, turn_context) + return await self.on_teams_members_removed(teams_members_removed, team_info, turn_context) async def on_teams_members_removed( - self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + self, teams_members_removed: [TeamsChannelAccount], teams_info: TeamInfo, turn_context: TurnContext ): members_removed = [ ChannelAccount().deserialize(member.serialize()) From 56f3ebd5c4baf56a8a1b3e5f9f308aa800ba175e Mon Sep 17 00:00:00 2001 From: LocalizationBuildProcess Date: Mon, 18 May 2020 17:57:33 -0700 Subject: [PATCH 457/616] Try version 4.10.0a --- libraries/functional-tests/functionaltestbot/requirements.txt | 2 +- libraries/functional-tests/functionaltestbot/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index b099b1ffd..7cd9b8cd9 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -1,5 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -botbuilder-core>=4.10 +botbuilder-core>=4.10.0a flask==1.1.1 diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py index 8ad9c44c9..d8dac6b70 100644 --- a/libraries/functional-tests/functionaltestbot/setup.py +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -5,7 +5,7 @@ from setuptools import setup REQUIRES = [ - "botbuilder-core>=4.10", + "botbuilder-core>=4.10.0a", "flask==1.1.1", ] From 2f8aeed65cfe9d93a1bef83afde6ceef4b20889a Mon Sep 17 00:00:00 2001 From: LocalizationBuildProcess Date: Mon, 18 May 2020 17:59:18 -0700 Subject: [PATCH 458/616] Try 4.10.0a0 --- libraries/functional-tests/functionaltestbot/requirements.txt | 2 +- libraries/functional-tests/functionaltestbot/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index 7cd9b8cd9..8a450e33e 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -1,5 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -botbuilder-core>=4.10.0a +botbuilder-core>=4.10.0a0 flask==1.1.1 diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py index d8dac6b70..777961fca 100644 --- a/libraries/functional-tests/functionaltestbot/setup.py +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -5,7 +5,7 @@ from setuptools import setup REQUIRES = [ - "botbuilder-core>=4.10.0a", + "botbuilder-core>=4.10.0a0", "flask==1.1.1", ] From 758d2e7d33764ffe7d0c9ebdd76040c33a888889 Mon Sep 17 00:00:00 2001 From: LocalizationBuildProcess Date: Mon, 18 May 2020 18:02:41 -0700 Subject: [PATCH 459/616] Try 4.9.0 --- libraries/functional-tests/functionaltestbot/requirements.txt | 2 +- libraries/functional-tests/functionaltestbot/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/functional-tests/functionaltestbot/requirements.txt b/libraries/functional-tests/functionaltestbot/requirements.txt index 8a450e33e..313eb980c 100644 --- a/libraries/functional-tests/functionaltestbot/requirements.txt +++ b/libraries/functional-tests/functionaltestbot/requirements.txt @@ -1,5 +1,5 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -botbuilder-core>=4.10.0a0 +botbuilder-core>=4.9.0 flask==1.1.1 diff --git a/libraries/functional-tests/functionaltestbot/setup.py b/libraries/functional-tests/functionaltestbot/setup.py index 777961fca..85d198662 100644 --- a/libraries/functional-tests/functionaltestbot/setup.py +++ b/libraries/functional-tests/functionaltestbot/setup.py @@ -5,7 +5,7 @@ from setuptools import setup REQUIRES = [ - "botbuilder-core>=4.10.0a0", + "botbuilder-core>=4.9.0", "flask==1.1.1", ] From 73aac32208835f00f2044c6c364036c790a9d687 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 19 May 2020 10:14:00 -0700 Subject: [PATCH 460/616] updating unit test --- .../tests/teams/test_teams_activity_handler.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 2bbeb6ee4..ad9568da7 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -50,11 +50,11 @@ async def on_teams_members_added( # pylint: disable=unused-argument ) async def on_teams_members_removed( - self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext + self, teams_members_removed: [TeamsChannelAccount], team_info: TeamInfo, turn_context: TurnContext ): self.record.append("on_teams_members_removed") return await super().on_teams_members_removed( - teams_members_removed, turn_context + teams_members_removed, team_info, turn_context ) async def on_message_activity(self, turn_context: TurnContext): @@ -421,7 +421,10 @@ async def test_on_teams_members_removed_activity(self): # arrange activity = Activity( type=ActivityTypes.conversation_update, - channel_data={"eventType": "teamMemberRemoved"}, + channel_data={ + "eventType": "teamMemberRemoved", + "team": {"id": "team_id_1", "name": "new_team_name"} + }, members_removed=[ ChannelAccount( id="123", From 7fad7bf907db6adb1bc7f825adb22b856195d802 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Tue, 26 May 2020 15:47:53 -0700 Subject: [PATCH 461/616] Upgrade python to 3.8.3. (#1100) --- pipelines/botbuilder-python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index bd3f81002..5d1186c65 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -8,7 +8,7 @@ variables: COVERALLS_SERVICE_NAME: python-ci python.36: 3.6.10 python.37: 3.7.7 - python.38: 3.8.2 + python.38: 3.8.3 # PythonCoverallsToken: get this from Azure jobs: From a8347a3ae741b157dace0a94129658f0c2af4a68 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 27 May 2020 15:51:05 -0700 Subject: [PATCH 462/616] Formatting --- .../botbuilder/core/teams/teams_activity_handler.py | 9 +++++++-- .../tests/teams/test_teams_activity_handler.py | 11 +++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 0f7956058..7b1a88814 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -412,10 +412,15 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument TeamsChannelAccount().deserialize(new_account_json) ) - return await self.on_teams_members_removed(teams_members_removed, team_info, turn_context) + return await self.on_teams_members_removed( + teams_members_removed, team_info, turn_context + ) async def on_teams_members_removed( # pylint: disable=unused-argument - self, teams_members_removed: [TeamsChannelAccount], team_info: TeamInfo, turn_context: TurnContext + self, + teams_members_removed: [TeamsChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, ): members_removed = [ ChannelAccount().deserialize(member.serialize()) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index ad9568da7..7c70ef36c 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -50,7 +50,10 @@ async def on_teams_members_added( # pylint: disable=unused-argument ) async def on_teams_members_removed( - self, teams_members_removed: [TeamsChannelAccount], team_info: TeamInfo, turn_context: TurnContext + self, + teams_members_removed: [TeamsChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, ): self.record.append("on_teams_members_removed") return await super().on_teams_members_removed( @@ -422,9 +425,9 @@ async def test_on_teams_members_removed_activity(self): activity = Activity( type=ActivityTypes.conversation_update, channel_data={ - "eventType": "teamMemberRemoved", - "team": {"id": "team_id_1", "name": "new_team_name"} - }, + "eventType": "teamMemberRemoved", + "team": {"id": "team_id_1", "name": "new_team_name"}, + }, members_removed=[ ChannelAccount( id="123", From 5ad2c5c1802adb9e17f34cf5e11e56956b119e9c Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Wed, 27 May 2020 17:35:57 -0700 Subject: [PATCH 463/616] Set up CI with Azure Pipelines [skip ci] --- ...mental-create-azure-container-registry.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 pipelines/experimental-create-azure-container-registry.yml diff --git a/pipelines/experimental-create-azure-container-registry.yml b/pipelines/experimental-create-azure-container-registry.yml new file mode 100644 index 000000000..aa912913d --- /dev/null +++ b/pipelines/experimental-create-azure-container-registry.yml @@ -0,0 +1,19 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +trigger: +- master + +pool: + vmImage: 'ubuntu-latest' + +steps: +- script: echo Hello, world! + displayName: 'Run a one-line script' + +- script: | + echo Add other tasks to build, test, and deploy your project. + echo See https://aka.ms/yaml + displayName: 'Run a multi-line script' From 7a0698275168026250cdee103ce7f4e3e367ffd5 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Wed, 27 May 2020 17:58:41 -0700 Subject: [PATCH 464/616] Update experimental-create-azure-container-registry.yml for Azure Pipelines --- ...mental-create-azure-container-registry.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pipelines/experimental-create-azure-container-registry.yml b/pipelines/experimental-create-azure-container-registry.yml index aa912913d..ef76d236c 100644 --- a/pipelines/experimental-create-azure-container-registry.yml +++ b/pipelines/experimental-create-azure-container-registry.yml @@ -10,6 +10,31 @@ pool: vmImage: 'ubuntu-latest' steps: +- task: AzurePowerShell@5 + inputs: + azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' + ScriptType: 'InlineScript' + azurePowerShellVersion: 'LatestVersion' + +- task: AzurePowerShell@5 + inputs: + azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' + ScriptType: 'InlineScript' + Inline: | + # You can write your azure powershell scripts inline here. + # You can also pass predefined and custom variables to this script using arguments + Write-Host 'blah' + az group create --name NightlyPythonFunctionalTestContainerRegistryRG --location eastus + az acr create --resource-group NightlyPythonFunctionalTestContainerRegistryRG --name NightlyPythonFunctionalTestContainerRegistry --sku Basic + az acr login --name NightlyPythonFunctionalTestContainerRegistry + docker pull hello-world + docker tag hello-world nightlypythonfunctionaltestcontainerregistry.azurecr.io/hello-world:v1 + docker push nightlypythonfunctionaltestcontainerregistry.azurecr.io/hello-world:v1 + docker rmi nightlypythonfunctionaltestcontainerregistry.azurecr.io/hello-world:v1 + az acr repository list --name NightlyPythonFunctionalTestContainerRegistry --output table + az acr repository show-tags --name NightlyPythonFunctionalTestContainerRegistry --repository hello-world --output table + azurePowerShellVersion: 'LatestVersion' + - script: echo Hello, world! displayName: 'Run a one-line script' From f441060f7c4bede29f8ae89c957ae5f1cee2a0c2 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Wed, 27 May 2020 18:02:09 -0700 Subject: [PATCH 465/616] Set triggers = none --- pipelines/experimental-create-azure-container-registry.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pipelines/experimental-create-azure-container-registry.yml b/pipelines/experimental-create-azure-container-registry.yml index ef76d236c..f05c1e939 100644 --- a/pipelines/experimental-create-azure-container-registry.yml +++ b/pipelines/experimental-create-azure-container-registry.yml @@ -3,8 +3,9 @@ # Add steps that build, run tests, deploy, and more: # https://aka.ms/yaml -trigger: -- master +trigger: none # no ci trigger + +pr: none # no pr trigger pool: vmImage: 'ubuntu-latest' From 66276efb71d87da493636001cd37d32638abe372 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Wed, 27 May 2020 18:15:24 -0700 Subject: [PATCH 466/616] Update experimental-create-azure-container-registry.yml for Azure Pipelines --- .../experimental-create-azure-container-registry.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/pipelines/experimental-create-azure-container-registry.yml b/pipelines/experimental-create-azure-container-registry.yml index f05c1e939..d1edd8bbe 100644 --- a/pipelines/experimental-create-azure-container-registry.yml +++ b/pipelines/experimental-create-azure-container-registry.yml @@ -1,8 +1,3 @@ -# Starter pipeline -# Start with a minimal pipeline that you can customize to build and deploy your code. -# Add steps that build, run tests, deploy, and more: -# https://aka.ms/yaml - trigger: none # no ci trigger pr: none # no pr trigger @@ -22,10 +17,11 @@ steps: azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' ScriptType: 'InlineScript' Inline: | - # You can write your azure powershell scripts inline here. - # You can also pass predefined and custom variables to this script using arguments - Write-Host 'blah' + Set-PSDebug -Trace 1; + Write-Host 'blah'; + Write-Host 'az group create --name NightlyPythonFunctionalTestContainerRegistryRG --location eastus' az group create --name NightlyPythonFunctionalTestContainerRegistryRG --location eastus + Write-Host 'az acr create --resource-group NightlyPythonFunctionalTestContainerRegistryRG --name NightlyPythonFunctionalTestContainerRegistry --sku Basic' az acr create --resource-group NightlyPythonFunctionalTestContainerRegistryRG --name NightlyPythonFunctionalTestContainerRegistry --sku Basic az acr login --name NightlyPythonFunctionalTestContainerRegistry docker pull hello-world From 6895a8bfb52ac514f48e08559bbaeb698c558ff1 Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Wed, 27 May 2020 18:21:09 -0700 Subject: [PATCH 467/616] Update experimental-create-azure-container-registry.yml for Azure Pipelines --- pipelines/experimental-create-azure-container-registry.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pipelines/experimental-create-azure-container-registry.yml b/pipelines/experimental-create-azure-container-registry.yml index d1edd8bbe..9bf7d4f20 100644 --- a/pipelines/experimental-create-azure-container-registry.yml +++ b/pipelines/experimental-create-azure-container-registry.yml @@ -7,12 +7,7 @@ pool: steps: - task: AzurePowerShell@5 - inputs: - azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' - ScriptType: 'InlineScript' - azurePowerShellVersion: 'LatestVersion' - -- task: AzurePowerShell@5 + displayName: 'Create container' inputs: azureSubscription: 'FUSE Temporary (174c5021-8109-4087-a3e2-a1de20420569)' ScriptType: 'InlineScript' From 0e96543724c4066e1a0ae9193ee8df70d0d03064 Mon Sep 17 00:00:00 2001 From: Gary Pretty Date: Mon, 15 Jun 2020 21:32:18 +0100 Subject: [PATCH 468/616] Adding community docs as per guidelines (#1132) * Create CODE_OF_CONDUCT Linking to the Microsoft Open Source Code of COnduct * Added community docs * Amends for code of conduct --- .github/PULL_REQUEST_TEMPLATE.md | 14 ++++++++++++++ CODE_OF_CONDUCT.md | 5 +++++ Contributing.md | 23 +++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 Contributing.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..e8870cd7e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +Fixes # + +## Description + + +## Specific Changes + + + - + - + - + +## Testing + \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..d3ff17639 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +## Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact + [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. \ No newline at end of file diff --git a/Contributing.md b/Contributing.md new file mode 100644 index 000000000..41a2e6153 --- /dev/null +++ b/Contributing.md @@ -0,0 +1,23 @@ +# Instructions for Contributing Code + +## Contributing bug fixes and features + +The Bot Framework team is currently accepting contributions in the form of bug fixes and new +features. Any submission must have an issue tracking it in the issue tracker that has + been approved by the Bot Framework team. Your pull request should include a link to + the bug that you are fixing. If you've submitted a PR for a bug, please post a + comment in the bug to avoid duplication of effort. + +## Legal + +If your contribution is more than 15 lines of code, you will need to complete a Contributor +License Agreement (CLA). Briefly, this agreement testifies that you are granting us permission + to use the submitted change according to the terms of the project's license, and that the work + being submitted is under appropriate copyright. + +Please submit a Contributor License Agreement (CLA) before submitting a pull request. +You may visit https://cla.azure.com to sign digitally. Alternatively, download the +agreement ([Microsoft Contribution License Agreement.docx](https://www.codeplex.com/Download?ProjectName=typescript&DownloadId=822190) or + [Microsoft Contribution License Agreement.pdf](https://www.codeplex.com/Download?ProjectName=typescript&DownloadId=921298)), sign, scan, + and email it back to . Be sure to include your github user name along with the agreement. Once we have received the + signed CLA, we'll review the request. \ No newline at end of file From 9837f6f7e27b3e71e392e30e96632a617e6635dd Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Wed, 17 Jun 2020 14:58:32 -0700 Subject: [PATCH 469/616] add e_tag to storeItems tests --- .../botbuilder/testing/storage_base_tests.py | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py index 96044f9de..ca46a2642 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py +++ b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py @@ -8,6 +8,13 @@ All tests return true if assertions pass to indicate that the code ran to completion, passing internal assertions. Therefore, all tests using theses static tests should strictly check that the method returns true. +Note: Python cannot have dicts with properties with a None value like other SDKs can have properties with null values. + Because of this, StoreItem tests have "e_tag: *" where the tests in the other SDKs do not. + This has also caused us to comment out some parts of these tests where we assert that "e_tag" is None for the same reason. + A null e_tag should work just like a * e_tag when writing, as far as the storage adapters are concerened, + so this shouldn't cause issues. + + :Example: async def test_handle_null_keys_when_reading(self): await reset() @@ -83,7 +90,7 @@ async def does_not_raise_when_writing_no_items(storage) -> bool: async def create_object(storage) -> bool: store_items = { "createPoco": {"id": 1}, - "createPocoStoreItem": {"id": 2}, + "createPocoStoreItem": {"id": 2, "e_tag": "*"}, } await storage.write(store_items) @@ -95,11 +102,10 @@ async def create_object(storage) -> bool: store_items["createPocoStoreItem"]["id"] == read_store_items["createPocoStoreItem"]["id"] ) - """ - If decided to validate e_tag integrity aagain, uncomment this code - assert read_store_items["createPoco"]["e_tag"] is not None + + # If decided to validate e_tag integrity again, uncomment this code + # assert read_store_items["createPoco"]["e_tag"] is not None assert read_store_items["createPocoStoreItem"]["e_tag"] is not None - """ return True @@ -122,7 +128,7 @@ async def handle_crazy_keys(storage) -> bool: async def update_object(storage) -> bool: original_store_items = { "pocoItem": {"id": 1, "count": 1}, - "pocoStoreItem": {"id": 1, "count": 1}, + "pocoStoreItem": {"id": 1, "count": 1, "e_tag": "*"}, } # 1st write should work @@ -131,9 +137,9 @@ async def update_object(storage) -> bool: loaded_store_items = await storage.read(["pocoItem", "pocoStoreItem"]) update_poco_item = loaded_store_items["pocoItem"] - # update_poco_item["e_tag"] = None + update_poco_item["e_tag"] = None update_poco_store_item = loaded_store_items["pocoStoreItem"] - # assert update_poco_store_item["e_tag"] is not None + assert update_poco_store_item["e_tag"] is not None # 2nd write should work update_poco_item["count"] += 1 @@ -153,11 +159,13 @@ async def update_object(storage) -> bool: update_poco_item["count"] = 123 await storage.write({"pocoItem": update_poco_item}) - """ - If decided to validate e_tag integrity aagain, uncomment this code # Write with old eTag should FAIL for storeItem update_poco_store_item["count"] = 123 + """ + This assert exists in the other SDKs but can't in python, currently + due to using "e_tag: *" above (see comment near the top of this file for details). + with pytest.raises(Exception) as err: await storage.write({"pocoStoreItem": update_poco_store_item}) assert err.value is not None @@ -166,7 +174,7 @@ async def update_object(storage) -> bool: reloaded_store_items2 = await storage.read(["pocoItem", "pocoStoreItem"]) reloaded_poco_item2 = reloaded_store_items2["pocoItem"] - # reloaded_poco_item2["e_tag"] = None + reloaded_poco_item2["e_tag"] = None reloaded_poco_store_item2 = reloaded_store_items2["pocoStoreItem"] assert reloaded_poco_item2["count"] == 123 @@ -175,7 +183,7 @@ async def update_object(storage) -> bool: # write with wildcard etag should work reloaded_poco_item2["count"] = 100 reloaded_poco_store_item2["count"] = 100 - # reloaded_poco_store_item2["e_tag"] = "*" + reloaded_poco_store_item2["e_tag"] = "*" wildcard_etag_dict = { "pocoItem": reloaded_poco_item2, @@ -195,15 +203,12 @@ async def update_object(storage) -> bool: assert reloaded_store_item4 is not None - """ - If decided to validate e_tag integrity aagain, uncomment this code reloaded_store_item4["e_tag"] = "" dict2 = {"pocoStoreItem": reloaded_store_item4} with pytest.raises(Exception) as err: await storage.write(dict2) assert err.value is not None - """ final_store_items = await storage.read(["pocoItem", "pocoStoreItem"]) assert final_store_items["pocoItem"]["count"] == 100 @@ -213,13 +218,13 @@ async def update_object(storage) -> bool: @staticmethod async def delete_object(storage) -> bool: - store_items = {"delete1": {"id": 1, "count": 1}} + store_items = {"delete1": {"id": 1, "count": 1, "e_tag": "*"}} await storage.write(store_items) read_store_items = await storage.read(["delete1"]) - # assert read_store_items["delete1"]["e_tag"] + assert read_store_items["delete1"]["e_tag"] assert read_store_items["delete1"]["count"] == 1 await storage.delete(["delete1"]) From 3748524cfe0d6fb0414472be788d3f3a020df95d Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Wed, 17 Jun 2020 15:07:26 -0700 Subject: [PATCH 470/616] pylint/black --- .../botbuilder/testing/storage_base_tests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py index ca46a2642..1a307d336 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py +++ b/libraries/botbuilder-testing/botbuilder/testing/storage_base_tests.py @@ -10,10 +10,10 @@ Note: Python cannot have dicts with properties with a None value like other SDKs can have properties with null values. Because of this, StoreItem tests have "e_tag: *" where the tests in the other SDKs do not. - This has also caused us to comment out some parts of these tests where we assert that "e_tag" is None for the same reason. - A null e_tag should work just like a * e_tag when writing, as far as the storage adapters are concerened, - so this shouldn't cause issues. - + This has also caused us to comment out some parts of these tests where we assert that "e_tag" + is None for the same reason. A null e_tag should work just like a * e_tag when writing, + as far as the storage adapters are concerened, so this shouldn't cause issues. + :Example: async def test_handle_null_keys_when_reading(self): @@ -102,7 +102,7 @@ async def create_object(storage) -> bool: store_items["createPocoStoreItem"]["id"] == read_store_items["createPocoStoreItem"]["id"] ) - + # If decided to validate e_tag integrity again, uncomment this code # assert read_store_items["createPoco"]["e_tag"] is not None assert read_store_items["createPocoStoreItem"]["e_tag"] is not None From 922a6224a4162fbd0962440d04967b3068d8648a Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 22 Jun 2020 12:17:49 -0700 Subject: [PATCH 471/616] Add aadGroupId to TeamInfo (#1145) * Add aadGroupId to TeamInfo * remove unused import --- .../tests/teams/test_teams_channel_data.py | 30 +++++++++++++++++++ .../botbuilder/schema/teams/_models.py | 4 +++ .../botbuilder/schema/teams/_models_py3.py | 8 ++++- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-core/tests/teams/test_teams_channel_data.py diff --git a/libraries/botbuilder-core/tests/teams/test_teams_channel_data.py b/libraries/botbuilder-core/tests/teams/test_teams_channel_data.py new file mode 100644 index 000000000..e468526bc --- /dev/null +++ b/libraries/botbuilder-core/tests/teams/test_teams_channel_data.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest + +from botbuilder.schema import Activity +from botbuilder.schema.teams import TeamsChannelData +from botbuilder.core.teams import teams_get_team_info + + +class TestTeamsChannelData(aiounittest.AsyncTestCase): + def test_teams_aad_group_id_deserialize(self): + # Arrange + raw_channel_data = {"team": {"aadGroupId": "teamGroup123"}} + + # Act + channel_data = TeamsChannelData().deserialize(raw_channel_data) + + # Assert + assert channel_data.team.aad_group_id == "teamGroup123" + + def test_teams_get_team_info(self): + # Arrange + activity = Activity(channel_data={"team": {"aadGroupId": "teamGroup123"}}) + + # Act + team_info = teams_get_team_info(activity) + + # Assert + assert team_info.aad_group_id == "teamGroup123" diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index 7b82da917..c44c22c21 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -1508,17 +1508,21 @@ class TeamInfo(Model): :type id: str :param name: Name of team. :type name: str + :param name: Azure AD Teams group ID. + :type name: str """ _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, + "aad_group_id": {"key": "aadGroupId", "type": "str"}, } def __init__(self, **kwargs): super(TeamInfo, self).__init__(**kwargs) self.id = kwargs.get("id", None) self.name = kwargs.get("name", None) + self.aad_group_id = kwargs.get("aad_group_id", None) class TeamsChannelAccount(ChannelAccount): diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index e8be1dc85..5a5db5174 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1773,17 +1773,23 @@ class TeamInfo(Model): :type id: str :param name: Name of team. :type name: str + :param name: Azure AD Teams group ID. + :type name: str """ _attribute_map = { "id": {"key": "id", "type": "str"}, "name": {"key": "name", "type": "str"}, + "aad_group_id": {"key": "aadGroupId", "type": "str"}, } - def __init__(self, *, id: str = None, name: str = None, **kwargs) -> None: + def __init__( + self, *, id: str = None, name: str = None, aad_group_id: str = None, **kwargs + ) -> None: super(TeamInfo, self).__init__(**kwargs) self.id = id self.name = name + self.aad_group_id = aad_group_id class TeamsChannelAccount(ChannelAccount): From 3dbc27368a0e5386de0d272c0b03fd0c6efb1833 Mon Sep 17 00:00:00 2001 From: Gary Pretty Date: Tue, 23 Jun 2020 18:48:52 +0100 Subject: [PATCH 472/616] Updates to readme as part of docs pillar review --- README.md | 160 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 129 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 35ddc6df8..60add0019 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,143 @@ # ![Bot Framework SDK v4 Python](./doc/media/FrameWorkPython.png) -### [Click here to find out what's new with Bot Framework](https://docs.microsoft.com/en-us/azure/bot-service/what-is-new?view=azure-bot-service-4.0) +### [What's new with Bot Framework](https://docs.microsoft.com/en-us/azure/bot-service/what-is-new?view=azure-bot-service-4.0) -# Bot Framework SDK v4 for Python -[![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=master) -[![roadmap badge](https://img.shields.io/badge/visit%20the-roadmap-blue.svg)](https://github.com/Microsoft/botbuilder-python/wiki/Roadmap) -[![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Microsoft/botbuilder-python/blob/master/LICENSE) -[![Gitter](https://img.shields.io/gitter/room/Microsoft/BotBuilder.svg)](https://gitter.im/Microsoft/BotBuilder) +This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botframework-sdk), which is part of the Microsoft Bot Framework - a comprehensive framework for building enterprise-grade conversational AI experiences. -This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botbuilder). The Bot Framework SDK v4 enables developers to model conversation and build sophisticated bot applications using Python. This Python version is GA and ready for production usage. +This SDK enables developers to model conversation and build sophisticated bot applications using Python. SDKs for [JavaScript](https://github.com/Microsoft/botbuilder-js), [.NET](https://github.com/Microsoft/botbuilder-dotnet) and [Java (preview)](https://github.com/Microsoft/botbuilder-java) are also available. -This repo is part the [Microsoft Bot Framework](https://github.com/Microsoft/botframework) - a comprehensive framework for building enterprise-grade conversational AI experiences. +To get started building bots using the SDK, see the [Azure Bot Service Documentation](https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0). -In addition to the Python SDK, Bot Builder supports creating bots in other popular programming languages like [.Net SDK](https://github.com/Microsoft/botbuilder-dotnet), [JavaScript](https://github.com/Microsoft/botbuilder-js), and [Java](https://github.com/Microsoft/botbuilder-java). +For more information jump to a section below. -To get started see the [Azure Bot Service Documentation](https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0) for the v4 SDK. +* [Build status](#build-status) +* [Packages](#packages) +* [Getting started](#getting-started) +* [Getting support and providing feedback](#getting-support-and-providing-feedback) +* [Contributing and our code of conduct](contributing-and-our-code-of-conduct) +* [Reporting security sssues](#reporting-security-issues) + +## Build Status + +| Branch | Description | Build Status | Coverage Status | Code Style | + |----|---------------|--------------|-----------------|--| +| Master | 4.10.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=master) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | ## Packages -- [![PyPI version](https://badge.fury.io/py/botbuilder-ai.svg)](https://pypi.org/project/botbuilder-ai/) botbuilder-ai -- [![PyPI version](https://badge.fury.io/py/botbuilder-applicationinsights.svg)](https://pypi.org/project/botbuilder-applicationinsights/) botbuilder-applicationinsights -- [![PyPI version](https://badge.fury.io/py/botbuilder-azure.svg)](https://pypi.org/project/botbuilder-azure/) botbuilder-azure -- [![PyPI version](https://badge.fury.io/py/botbuilder-core.svg)](https://pypi.org/project/botbuilder-core/) botbuilder-core -- [![PyPI version](https://badge.fury.io/py/botbuilder-dialogs.svg)](https://pypi.org/project/botbuilder-dialogs/) botbuilder-dialogs -- [![PyPI version](https://badge.fury.io/py/botbuilder-schema.svg)](https://pypi.org/project/botbuilder-schema/) botbuilder-schema -- [![PyPI version](https://badge.fury.io/py/botframework-connector.svg)](https://pypi.org/project/botframework-connector/) botframework-connector - -## Contributing -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.microsoft.com. -When you submit a pull request, a CLA-bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +| Build | Released Package | + |----|---------------| +| botbuilder-ai | [![PyPI version](https://badge.fury.io/py/botbuilder-ai.svg)](https://pypi.org/project/botbuilder-ai/) | +| botbuilder-applicationinsights | [![PyPI version](https://badge.fury.io/py/botbuilder-applicationinsights.svg)](https://pypi.org/project/botbuilder-applicationinsights/) | +| botbuilder-azure | [![PyPI version](https://badge.fury.io/py/botbuilder-azure.svg)](https://pypi.org/project/botbuilder-azure/) | +| botbuilder-core | [![PyPI version](https://badge.fury.io/py/botbuilder-core.svg)](https://pypi.org/project/botbuilder-core/) | +| botbuilder-dialogs | [![PyPI version](https://badge.fury.io/py/botbuilder-dialogs.svg)](https://pypi.org/project/botbuilder-dialogs/) | +| botbuilder-schema | [![PyPI version](https://badge.fury.io/py/botbuilder-schema.svg)](https://pypi.org/project/botbuilder-schema/) | +| botframework-connector | [![PyPI version](https://badge.fury.io/py/botframework-connector.svg)](https://pypi.org/project/botframework-connector/) | + +## Getting Started +To get started building bots using the SDK, see the [Azure Bot Service Documentation](https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0). + +The [Bot Framework Samples](https://github.com/microsoft/botbuilder-samples) includes a rich set of samples repository. + +If you want to debug an issue, would like to [contribute](#contributing), or understand how the Bot Builder SDK works, instructions for building and testing the SDK are below. + +### Prerequisites +- [Git](https://git-scm.com/downloads) +- [Python 3.8.2](https://www.python.org/downloads/) + +Python "Virtual Environments" allow Python packages to be installed in an isolated location for a particular application, rather than being installed globally, as such it is common practice to use them. Click [here](https://packaging.python.org/tutorials/installing-packages/#creating-virtual-environments) to learn more about creating _and activating_ Virtual Environments in Python. + +### Clone +Clone a copy of the repo: +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +Change to the SDK's directory: +```bash +cd botbuilder-python +``` + +### Using the SDK locally + +You will need the following 3 packages installed in your environment: +- [botframework-connector](https://pypi.org/project/botframework-connector/) +- [botbuilder-core](https://pypi.org/project/botbuilder-core/) +- [botbuilder-schema](https://pypi.org/project/botbuilder-schema/) + +To use a local copy of the SDK you can link to these packages with the pip -e option. + +```bash +pip install -e ./libraries/botbuilder-schema +pip install -e ./libraries/botframework-connector +pip install -e ./libraries/botbuilder-core +pip install -e ./libraries/botbuilder-integration-aiohttp +pip install -e ./libraries/botbuilder-ai +pip install -e ./libraries/botbuilder-applicationinsights +pip install -e ./libraries/botbuilder-integration-applicationinsights-aiohttp +pip install -e ./libraries/botbuilder-dialogs +pip install -e ./libraries/botbuilder-azure +pip install -e ./libraries/botbuilder-adapters-slack +pip install -e ./libraries/botbuilder-testing +``` + +### Running unit tests +First execute the following command from the root level of the repo: +```bash +pip install -r ./libraries/botframework-connector/tests/requirements.txt +pip install -r ./libraries/botbuilder-core/tests/requirements.txt +pip install -r ./libraries/botbuilder-ai/tests/requirements.txt +``` + +Then enter run pytest by simply typing it into your CLI: + +```bash +pytest +``` + +This is the expected output: +```bash +============================= test session starts ============================= +platform win32 -- Python 3.8.2, pytest-3.4.0, py-1.5.2, pluggy-0.6.0 +rootdir: C:\projects\botbuilder-python, inifile: +plugins: cov-2.5.1 +... +``` + +## Getting support and providing feedback +Below are the various channels that are available to you for obtaining support and providing feedback. Please pay carful attention to which channel should be used for which type of content. e.g. general "how do I..." questions should be asked on Stack Overflow, Twitter or Gitter, with GitHub issues being for feature requests and bug reports. + +### Github issues +[Github issues](https://github.com/Microsoft/botbuilder-python/issues) should be used for bugs and feature requests. + +### Stack overflow +[Stack Overflow](https://stackoverflow.com/questions/tagged/botframework) is a great place for getting high-quality answers. Our support team, as well as many of our community members are already on Stack Overflow providing answers to 'how-to' questions. + +### Azure Support +If you issues relates to [Azure Bot Service](https://azure.microsoft.com/en-gb/services/bot-service/), you can take advantage of the available [Azure support options](https://azure.microsoft.com/en-us/support/options/). + +### Twitter +We use the [@botframework](https://twitter.com/botframework) account on twitter for announcements and members from the development team watch for tweets for @botframework. + +### Gitter Chat Room +The [Gitter Channel](https://gitter.im/Microsoft/BotBuilder) provides a place where the Community can get together and collaborate. + +## Contributing and our code of conduct +We welcome contributions and suggestions. Please see our [contributing guidelines](./contributing.md) for more information. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact + [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. ## Reporting Security Issues -Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) at [secure@microsoft.com](mailto:secure@microsoft.com). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Further information, including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155) key, can be found in the [Security TechCenter](https://technet.microsoft.com/en-us/security/default). +Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) +at [secure@microsoft.com](mailto:secure@microsoft.com). You should receive a response within 24 hours. If for some + reason you do not, please follow up via email to ensure we received your original message. Further information, + including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155) key, can be found in the +[Security TechCenter](https://technet.microsoft.com/en-us/security/default). Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the [MIT](./LICENSE.md) License. + + From c45a567dc41068da82370eb0dff9d5a5338743b6 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Tue, 23 Jun 2020 14:56:39 -0300 Subject: [PATCH 473/616] Add linkToMessage to the MessageActionsPayload model (#1167) * add linkToMessage in model * add link_to_message in model_py3 * add test_message_actions_payload * rename tests and add assign_message_type * add message_actions_payload properties tests * Add unit tests for MessageActionsPayload class * add aiounittest to requirements * format test file for black compliance Co-authored-by: Cecilia Avila --- .../botbuilder/schema/teams/_models.py | 4 + .../botbuilder/schema/teams/_models_py3.py | 5 + libraries/botbuilder-schema/requirements.txt | 1 + .../teams/test_message_actions_payload.py | 146 ++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 libraries/botbuilder-schema/tests/teams/test_message_actions_payload.py diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py index c44c22c21..a086439ed 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py @@ -459,6 +459,8 @@ class MessageActionsPayload(Model): :type importance: str or ~botframework.connector.teams.models.enum :param locale: Locale of the message set by the client. :type locale: str + :param link_to_message: Link back to the message. + :type link_to_message: str :param from_property: Sender of the message. :type from_property: ~botframework.connector.teams.models.MessageActionsPayloadFrom @@ -489,6 +491,7 @@ class MessageActionsPayload(Model): "summary": {"key": "summary", "type": "str"}, "importance": {"key": "importance", "type": "str"}, "locale": {"key": "locale", "type": "str"}, + "link_to_message": {"key": "linkToMessage", "type": "str"}, "from_property": {"key": "from", "type": "MessageActionsPayloadFrom"}, "body": {"key": "body", "type": "MessageActionsPayloadBody"}, "attachment_layout": {"key": "attachmentLayout", "type": "str"}, @@ -512,6 +515,7 @@ def __init__(self, **kwargs): self.summary = kwargs.get("summary", None) self.importance = kwargs.get("importance", None) self.locale = kwargs.get("locale", None) + self.link_to_message = kwargs.get("link_to_message", None) self.from_property = kwargs.get("from_property", None) self.body = kwargs.get("body", None) self.attachment_layout = kwargs.get("attachment_layout", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 5a5db5174..efa619fe3 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -550,6 +550,8 @@ class MessageActionsPayload(Model): :type importance: str :param locale: Locale of the message set by the client. :type locale: str + :param link_to_message: Link back to the message. + :type link_to_message: str :param from_property: Sender of the message. :type from_property: ~botframework.connector.teams.models.MessageActionsPayloadFrom @@ -580,6 +582,7 @@ class MessageActionsPayload(Model): "summary": {"key": "summary", "type": "str"}, "importance": {"key": "importance", "type": "str"}, "locale": {"key": "locale", "type": "str"}, + "link_to_message": {"key": "linkToMessage", "type": "str"}, "from_property": {"key": "from", "type": "MessageActionsPayloadFrom"}, "body": {"key": "body", "type": "MessageActionsPayloadBody"}, "attachment_layout": {"key": "attachmentLayout", "type": "str"}, @@ -604,6 +607,7 @@ def __init__( summary: str = None, importance=None, locale: str = None, + link_to_message: str = None, from_property=None, body=None, attachment_layout: str = None, @@ -623,6 +627,7 @@ def __init__( self.summary = summary self.importance = importance self.locale = locale + self.link_to_message = link_to_message self.from_property = from_property self.body = body self.attachment_layout = attachment_layout diff --git a/libraries/botbuilder-schema/requirements.txt b/libraries/botbuilder-schema/requirements.txt index 2969b6597..0361325e5 100644 --- a/libraries/botbuilder-schema/requirements.txt +++ b/libraries/botbuilder-schema/requirements.txt @@ -1 +1,2 @@ +aiounittest==1.3.0 msrest==0.6.10 \ No newline at end of file diff --git a/libraries/botbuilder-schema/tests/teams/test_message_actions_payload.py b/libraries/botbuilder-schema/tests/teams/test_message_actions_payload.py new file mode 100644 index 000000000..ef0cc55ee --- /dev/null +++ b/libraries/botbuilder-schema/tests/teams/test_message_actions_payload.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest +from botframework.connector.models import ( + MessageActionsPayloadFrom, + MessageActionsPayloadBody, + MessageActionsPayloadAttachment, + MessageActionsPayloadMention, + MessageActionsPayloadReaction, +) +from botbuilder.schema.teams import MessageActionsPayload + + +class TestingMessageActionsPayload(aiounittest.AsyncTestCase): + # Arrange + test_id = "01" + reply_to_id = "test_reply_to_id" + message_type = "test_message_type" + created_date_time = "01/01/2000" + last_modified_date_time = "01/01/2000" + deleted = False + subject = "test_subject" + summary = "test_summary" + importance = "high" + locale = "test_locale" + link_to_message = "https://teams.microsoft/com/l/message/testing-id" + from_property = MessageActionsPayloadFrom() + body = MessageActionsPayloadBody + attachment_layout = "test_attachment_layout" + attachments = [MessageActionsPayloadAttachment()] + mentions = [MessageActionsPayloadMention()] + reactions = [MessageActionsPayloadReaction()] + + # Act + message = MessageActionsPayload( + id=test_id, + reply_to_id=reply_to_id, + message_type=message_type, + created_date_time=created_date_time, + last_modified_date_time=last_modified_date_time, + deleted=deleted, + subject=subject, + summary=summary, + importance=importance, + locale=locale, + link_to_message=link_to_message, + from_property=from_property, + body=body, + attachment_layout=attachment_layout, + attachments=attachments, + mentions=mentions, + reactions=reactions, + ) + + def test_assign_id(self, message_action_payload=message, test_id=test_id): + # Assert + self.assertEqual(message_action_payload.id, test_id) + + def test_assign_reply_to_id( + self, message_action_payload=message, reply_to_id=reply_to_id + ): + # Assert + self.assertEqual(message_action_payload.reply_to_id, reply_to_id) + + def test_assign_message_type( + self, message_action_payload=message, message_type=message_type + ): + # Assert + self.assertEqual(message_action_payload.message_type, message_type) + + def test_assign_created_date_time( + self, message_action_payload=message, created_date_time=created_date_time + ): + # Assert + self.assertEqual(message_action_payload.created_date_time, created_date_time) + + def test_assign_last_modified_date_time( + self, + message_action_payload=message, + last_modified_date_time=last_modified_date_time, + ): + # Assert + self.assertEqual( + message_action_payload.last_modified_date_time, last_modified_date_time + ) + + def test_assign_deleted(self, message_action_payload=message, deleted=deleted): + # Assert + self.assertEqual(message_action_payload.deleted, deleted) + + def test_assign_subject(self, message_action_payload=message, subject=subject): + # Assert + self.assertEqual(message_action_payload.subject, subject) + + def test_assign_summary(self, message_action_payload=message, summary=summary): + # Assert + self.assertEqual(message_action_payload.summary, summary) + + def test_assign_importance( + self, message_action_payload=message, importance=importance + ): + # Assert + self.assertEqual(message_action_payload.importance, importance) + + def test_assign_locale(self, message_action_payload=message, locale=locale): + # Assert + self.assertEqual(message_action_payload.locale, locale) + + def test_assign_link_to_message( + self, message_action_payload=message, link_to_message=link_to_message + ): + # Assert + self.assertEqual(message_action_payload.link_to_message, link_to_message) + + def test_assign_from_property( + self, message_action_payload=message, from_property=from_property + ): + # Assert + self.assertEqual(message_action_payload.from_property, from_property) + + def test_assign_body(self, message_action_payload=message, body=body): + # Assert + self.assertEqual(message_action_payload.body, body) + + def test_assign_attachment_layout( + self, message_action_payload=message, attachment_layout=attachment_layout + ): + # Assert + self.assertEqual(message_action_payload.attachment_layout, attachment_layout) + + def test_assign_attachments( + self, message_action_payload=message, attachments=attachments + ): + # Assert + self.assertEqual(message_action_payload.attachments, attachments) + + def test_assign_mentions(self, message_action_payload=message, mentions=mentions): + # Assert + self.assertEqual(message_action_payload.mentions, mentions) + + def test_assign_reactions( + self, message_action_payload=message, reactions=reactions + ): + # Assert + self.assertEqual(message_action_payload.reactions, reactions) From e6a636abb579b2ef2b2dea7193a370145c231fd7 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 25 Jun 2020 10:18:43 -0700 Subject: [PATCH 474/616] Adding dependencies to setup.py --- libraries/botbuilder-adapters-slack/setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py index 666e321f2..42990d15b 100644 --- a/libraries/botbuilder-adapters-slack/setup.py +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -8,6 +8,8 @@ "botbuilder-schema==4.10.0", "botframework-connector==4.10.0", "botbuilder-core==4.10.0", + "pyslack", + "slackclient", ] TEST_REQUIRES = ["aiounittest==1.3.0"] @@ -32,7 +34,7 @@ long_description=long_description, long_description_content_type="text/x-rst", license=package_info["__license__"], - packages=["botbuilder.adapters", "botbuilder.adapters.slack",], + packages=["botbuilder.adapters.slack"], install_requires=REQUIRES + TEST_REQUIRES, tests_require=TEST_REQUIRES, include_package_data=True, From 488ba6cf478cb883afb0aa378471217deddc1dca Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 25 Jun 2020 11:19:11 -0700 Subject: [PATCH 475/616] pylint fixes --- .../botbuilder/adapters/slack/slack_client.py | 6 +++--- .../botbuilder/adapters/slack/slack_helper.py | 8 +++++--- .../botbuilder/adapters/slack/slack_payload.py | 4 +--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py index 42fb96e81..0911ce965 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py @@ -10,13 +10,13 @@ import aiohttp from aiohttp.web_request import Request +from slack.web.client import WebClient +from slack.web.slack_response import SlackResponse + from botbuilder.schema import Activity from botbuilder.adapters.slack import SlackAdapterOptions from botbuilder.adapters.slack.slack_message import SlackMessage -from slack.web.client import WebClient -from slack.web.slack_response import SlackResponse - POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage" POST_EPHEMERAL_MESSAGE_URL = "https://slack.com/api/chat.postEphemeral" diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py index e15604442..f45710561 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -7,6 +7,8 @@ from aiohttp.web_request import Request from aiohttp.web_response import Response +from slack.web.classes.attachments import Attachment + from botbuilder.schema import ( Activity, ConversationAccount, @@ -14,8 +16,6 @@ ActivityTypes, ) -from slack.web.classes.attachments import Attachment - from .slack_message import SlackMessage from .slack_client import SlackClient from .slack_event import SlackEvent @@ -53,7 +53,9 @@ def activity_to_slack(activity: Activity) -> SlackMessage: message.blocks = att.content else: new_attachment = Attachment( - author_name=att.name, thumb_url=att.thumbnail_url, + author_name=att.name, + thumb_url=att.thumbnail_url, + text="", ) attachments.append(new_attachment) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py index 0be8e3666..d5d87a225 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py @@ -2,10 +2,8 @@ # Licensed under the MIT License. from typing import Optional, List - -from botbuilder.adapters.slack.slack_message import SlackMessage - from slack.web.classes.actions import Action +from botbuilder.adapters.slack.slack_message import SlackMessage class SlackPayload: From d1edfae7434ad440666c313511f5b8c0bbf904e8 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 25 Jun 2020 11:36:00 -0700 Subject: [PATCH 476/616] Black compliant --- .../botbuilder/adapters/slack/slack_helper.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py index f45710561..de5b7e672 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -53,9 +53,7 @@ def activity_to_slack(activity: Activity) -> SlackMessage: message.blocks = att.content else: new_attachment = Attachment( - author_name=att.name, - thumb_url=att.thumbnail_url, - text="", + author_name=att.name, thumb_url=att.thumbnail_url, text="", ) attachments.append(new_attachment) From 529b345174edd552f4192e2e01ddc8e13bb8f109 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 25 Jun 2020 16:20:47 -0700 Subject: [PATCH 477/616] R10 reference doc fixes --- .../application_insights_telemetry_client.py | 2 +- .../botbuilder/core/bot_telemetry_client.py | 2 +- .../botbuilder/core/null_telemetry_client.py | 2 +- .../botbuilder/core/skills/skill_handler.py | 19 ++++++++++++------- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index ae660eb7b..0db7a98d8 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -171,7 +171,7 @@ def track_request( :param url: The actual URL for this request (to show in individual request instances). :param success: True if the request ended in success, False otherwise. :param start_time: the start time of the request. The value should look the same as the one returned by - :func:`datetime.isoformat()` (defaults to: None) + :func:`datetime.isoformat` (defaults to: None) :param duration: the number of milliseconds that this request lasted. (defaults to: None) :param response_code: the response code that this request returned. (defaults to: None) :param http_method: the HTTP method that triggered this request. (defaults to: None) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py b/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py index c797b000b..4fee9496e 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py @@ -144,7 +144,7 @@ def track_request( :param url: The actual URL for this request (to show in individual request instances). :param success: True if the request ended in success, False otherwise. :param start_time: the start time of the request. \ - The value should look the same as the one returned by :func:`datetime.isoformat()` (defaults to: None) + The value should look the same as the one returned by :func:`datetime.isoformat` (defaults to: None) :param duration: the number of milliseconds that this request lasted. (defaults to: None) :param response_code: the response code that this request returned. (defaults to: None) :param http_method: the HTTP method that triggered this request. (defaults to: None) diff --git a/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py b/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py index dc9954385..dca0e1fc5 100644 --- a/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py +++ b/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py @@ -118,7 +118,7 @@ def track_request( :param url: The actual URL for this request (to show in individual request instances). :param success: True if the request ended in success, False otherwise. :param start_time: the start time of the request. The value should look the same as the one returned \ - by :func:`datetime.isoformat()` (defaults to: None) + by :func:`datetime.isoformat` (defaults to: None) :param duration: the number of milliseconds that this request lasted. (defaults to: None) :param response_code: the response code that this request returned. (defaults to: None) :param http_method: the HTTP method that triggered this request. (defaults to: None) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index fcd9e9ca7..b0787d603 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -72,9 +72,12 @@ async def on_send_to_conversation( conversation. Use SendToConversation in all other cases. - :param claims_identity: - :param conversation_id: - :param activity: + :param claims_identity: Claims identity for the bot. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity + :param conversation_id:The conversation ID. + :type conversation_id: str + :param activity: Activity to send. + :type activity: Activity :return: """ return await self._process_activity( @@ -104,10 +107,12 @@ async def on_reply_to_activity( conversation. Use SendToConversation in all other cases. - :param claims_identity: - :param conversation_id: - :param activity_id: - :param activity: + :param claims_identity: Claims identity for the bot. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity + :param conversation_id:The conversation ID. + :type conversation_id: str + :param activity: Activity to send. + :type activity: Activity :return: """ return await self._process_activity( From a5153e611d23fbc7fe1ad262589c0ec1a6d4396b Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 25 Jun 2020 17:30:56 -0700 Subject: [PATCH 478/616] Update qnamaker.py --- .../botbuilder/ai/qna/qnamaker.py | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 4c4f7cfba..62fd16714 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -81,13 +81,8 @@ async def get_answers( """ Generates answers from the knowledge base. - return: - ------- - A list of answers for the user's query, sorted in decreasing order of ranking score. - - rtype: - ------ - List[QueryResult] + :return: A list of answers for the user's query, sorted in decreasing order of ranking score. + :rtype: :class:`typing.List[QueryResult]` """ result = await self.get_answers_raw( context, options, telemetry_properties, telemetry_metrics @@ -105,13 +100,8 @@ async def get_answers_raw( """ Generates raw answers from the knowledge base. - return: - ------- - A list of answers for the user's query, sorted in decreasing order of ranking score. - - rtype: - ------ - QueryResults + :return: A list of answers for the user's query, sorted in decreasing order of ranking score. + :rtype: class:`QueryResult` """ if not context: raise TypeError("QnAMaker.get_answers(): context cannot be None.") @@ -133,13 +123,9 @@ def get_low_score_variation(self, query_result: QueryResult) -> List[QueryResult """ Filters the ambiguous question for active learning. - Parameters: - ----------- - query_result: User query output. - - Return: - ------- - Filtered aray of ambigous questions. + :param query_result: User query output. + :type query_result: class:`QueryResult` + :return: Filtered array of ambiguous questions. """ return ActiveLearningUtils.get_low_score_variation(query_result) @@ -147,9 +133,8 @@ async def call_train(self, feedback_records: List[FeedbackRecord]): """ Sends feedback to the knowledge base. - Parameters: - ----------- - feedback_records + :param feedback_records: Feedback records. + :type feedback_records: :class:`typing.List[FeedbackRecord]` """ return await self._active_learning_train_helper.call_train(feedback_records) @@ -181,14 +166,12 @@ async def fill_qna_event( """ Fills the event properties and metrics for the QnaMessage event for telemetry. - return: - ------- - A tuple of event data properties and metrics that will be sent to the - BotTelemetryClient.track_event() method for the QnAMessage event. + :return: A tuple of event data properties and metrics that will be sent to the + :func:`botbuilder.core.BotTelemetryClient.track_event` method for the QnAMessage event. The properties and metrics returned the standard properties logged - with any properties passed from the get_answers() method. - - rtype: + with any properties passed from the :func:`get_answers` method. + :return: Event properties and metrics for the QnaMessage event for telemetry. + :rtype: :class:`EventData` ------ EventData """ From 0b27701c1640898537e774e1bc88619f8c41f940 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 25 Jun 2020 18:14:24 -0700 Subject: [PATCH 479/616] Update luis_recognizer.py --- .../botbuilder/ai/luis/luis_recognizer.py | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 5167358ab..1590f5c90 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -37,13 +37,14 @@ def __init__( ] = None, include_api_results: bool = False, ): - """Initializes a new instance of the class. + """Initializes a new instance of the :class:`LuisRecognizer` class. + :param application: The LUIS application to use to recognize text. - :type application: LuisApplication - :param prediction_options: The LUIS prediction options to use, defaults to None - :param prediction_options: LuisPredictionOptions, optional - :param include_api_results: True to include raw LUIS API response, defaults to False - :param include_api_results: bool, optional + :type application: :class:`LuisApplication` + :param prediction_options: The LUIS prediction options to use, defaults to None. + :type prediction_options: :class:LuisPredictionOptions`, optional + :param include_api_results: True to include raw LUIS API response, defaults to False. + :type include_api_results: bool, optional :raises TypeError: """ @@ -73,13 +74,14 @@ def top_intent( results: RecognizerResult, default_intent: str = "None", min_score: float = 0.0 ) -> str: """Returns the name of the top scoring intent from a set of LUIS results. + :param results: Result set to be searched. - :type results: RecognizerResult - :param default_intent: Intent name to return should a top intent be found, defaults to "None" - :param default_intent: str, optional - :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in the - set are below this threshold then the `defaultIntent` will be returned, defaults to 0.0 - :param min_score: float, optional + :type results: :class:`botbuilder.core.RecognizerResult` + :param default_intent: Intent name to return should a top intent be found, defaults to None. + :type default_intent: str, optional + :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in + the set are below this threshold then the `defaultIntent` will be returned, defaults to 0.0. + :type min_score: float, optional :raises TypeError: :return: The top scoring intent name. :rtype: str @@ -107,16 +109,17 @@ async def recognize( # pylint: disable=arguments-differ luis_prediction_options: LuisPredictionOptions = None, ) -> RecognizerResult: """Return results of the analysis (Suggested actions and intents). + :param turn_context: Context object containing information for a single turn of conversation with a user. - :type turn_context: TurnContext + :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults - to None - :param telemetry_properties: Dict[str, str], optional + to None. + :type telemetry_properties: :class:`typing.Dict[str, str]`, optional :param telemetry_metrics: Additional metrics to be logged to telemetry with the LuisResult event, defaults to - None - :param telemetry_metrics: Dict[str, float], optional + None. + :type telemetry_metrics: :class:`typing.Dict[str, float]`, optional :return: The LUIS results of the analysis of the current message text in the current turn's context activity. - :rtype: RecognizerResult + :rtype: :class:`botbuilder.core.RecognizerResult` """ return await self._recognize_internal( @@ -134,16 +137,17 @@ def on_recognizer_result( telemetry_metrics: Dict[str, float] = None, ): """Invoked prior to a LuisResult being logged. + :param recognizer_result: The Luis Results for the call. - :type recognizer_result: RecognizerResult + :type recognizer_result: :class:`botbuilder.core.RecognizerResult` :param turn_context: Context object containing information for a single turn of conversation with a user. - :type turn_context: TurnContext + :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults - to None - :param telemetry_properties: Dict[str, str], optional - :param telemetry_metrics: Additional metrics to be logged to telemetry with the LuisResult event, defaults to - None - :param telemetry_metrics: Dict[str, float], optional + to None. + :type telemetry_properties: :class:`typing.Dict[str, str], optional + :param telemetry_metrics: Additional metrics to be logged to telemetry with the LuisResult event, defaults + to None. + :type telemetry_metrics: :class:`typing.Dict[str, float]`, optional """ properties = self.fill_luis_event_properties( @@ -178,16 +182,17 @@ def fill_luis_event_properties( ) -> Dict[str, str]: """Fills the event properties for LuisResult event for telemetry. These properties are logged when the recognizer is called. + :param recognizer_result: Last activity sent from user. - :type recognizer_result: RecognizerResult + :type recognizer_result: :class:`botbuilder.core.RecognizerResult` :param turn_context: Context object containing information for a single turn of conversation with a user. - :type turn_context: TurnContext + :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None :param telemetry_properties: Dict[str, str], optional - :return: A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the - BotMessageSend event. - :rtype: Dict[str, str] + :return: A dictionary that is sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` + method for the BotMessageSend event. + :rtype: `typing.Dict[str, str]` """ intents = recognizer_result.intents From 2ec5ed2434ee531c3c936bf015346975920013c7 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 25 Jun 2020 18:40:37 -0700 Subject: [PATCH 480/616] Update luis_application.py --- .../botbuilder-ai/botbuilder/ai/luis/luis_application.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py index 8d8f8e09d..3351b5882 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py @@ -13,12 +13,13 @@ class LuisApplication: """ def __init__(self, application_id: str, endpoint_key: str, endpoint: str): - """Initializes a new instance of the class. + """Initializes a new instance of the :class:`LuisApplication` class. + :param application_id: LUIS application ID. :type application_id: str :param endpoint_key: LUIS subscription or endpoint key. :type endpoint_key: str - :param endpoint: LUIS endpoint to use like https://westus.api.cognitive.microsoft.com. + :param endpoint: LUIS endpoint to use, like https://westus.api.cognitive.microsoft.com. :type endpoint: str :raises ValueError: :raises ValueError: @@ -46,7 +47,8 @@ def __init__(self, application_id: str, endpoint_key: str, endpoint: str): @classmethod def from_application_endpoint(cls, application_endpoint: str): - """Initializes a new instance of the class. + """Initializes a new instance of the :class:`LuisApplication` class. + :param application_endpoint: LUIS application endpoint. :type application_endpoint: str :return: From 09d6ac019de6ee5a73e91690078fa017e9038b5f Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 25 Jun 2020 18:46:58 -0700 Subject: [PATCH 481/616] Update conversation_id_factory.py --- .../core/skills/conversation_id_factory.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py index 35b1d8b6a..bb00c1ac7 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py @@ -25,19 +25,19 @@ async def create_skill_conversation_id( ], ) -> str: """ - Using the options passed in, creates a conversation id and - SkillConversationReference, storing them for future use. + Using the options passed in, creates a conversation id and :class:`SkillConversationReference`, + storing them for future use. - :param options_or_conversation_reference: The options contain properties useful - for generating a SkillConversationReference and conversation id. - :type options_or_conversation_reference: :class: - `Union[SkillConversationIdFactoryOptions, ConversationReference]` + :param options_or_conversation_reference: The options contain properties useful for generating a + :class:`SkillConversationReference` and conversation id. + :type options_or_conversation_reference: + :class:`Union[SkillConversationIdFactoryOptions, ConversationReference]` :returns: A skill conversation id. .. note:: - SkillConversationIdFactoryOptions is the preferred parameter type, while ConversationReference - type is provided for backwards compatability. + :class:`SkillConversationIdFactoryOptions` is the preferred parameter type, while the + :class:`SkillConversationReference` type is provided for backwards compatability. """ raise NotImplementedError() @@ -46,14 +46,13 @@ async def get_conversation_reference( self, skill_conversation_id: str ) -> Union[SkillConversationReference, ConversationReference]: """ - Retrieves a SkillConversationReference using a conversation id passed in. + Retrieves a :class:`SkillConversationReference` using a conversation id passed in. - :param skill_conversation_id: The conversation id for which to retrieve - the SkillConversationReference. + :param skill_conversation_id: The conversation id for which to retrieve the :class:`SkillConversationReference`. :type skill_conversation_id: str .. note:: - SkillConversationReference is the preferred return type, while ConversationReference + SkillConversationReference is the preferred return type, while the :class:`SkillConversationReference` type is provided for backwards compatability. """ raise NotImplementedError() From ec46a5d783d6fb615f8b7a9f6ce74b0357e789a1 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 26 Jun 2020 12:59:18 -0300 Subject: [PATCH 482/616] add Activity methods in _models_py3 --- .../botbuilder/schema/_models_py3.py | 408 +++++++++++++----- 1 file changed, 310 insertions(+), 98 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index aa49a4905..aeea03204 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -8,12 +8,109 @@ # Changes may cause incorrect behavior and will be lost if the code is # regenerated. # -------------------------------------------------------------------------- -from datetime import datetime +from botbuilder.schema._connector_client_enums import ActivityTypes +from datetime import datetime from msrest.serialization import Model from msrest.exceptions import HttpOperationError +class ConversationReference(Model): + """An object relating to a particular point in a conversation. + + :param activity_id: (Optional) ID of the activity to refer to + :type activity_id: str + :param user: (Optional) User participating in this conversation + :type user: ~botframework.connector.models.ChannelAccount + :param bot: Bot participating in this conversation + :type bot: ~botframework.connector.models.ChannelAccount + :param conversation: Conversation reference + :type conversation: ~botframework.connector.models.ConversationAccount + :param channel_id: Channel ID + :type channel_id: str + :param locale: A locale name for the contents of the text field. + The locale name is a combination of an ISO 639 two- or three-letter + culture code associated with a language and an ISO 3166 two-letter + subculture code associated with a country or region. + The locale name can also correspond to a valid BCP-47 language tag. + :type locale: str + :param service_url: Service endpoint where operations concerning the + referenced conversation may be performed + :type service_url: str + """ + + _attribute_map = { + "activity_id": {"key": "activityId", "type": "str"}, + "user": {"key": "user", "type": "ChannelAccount"}, + "bot": {"key": "bot", "type": "ChannelAccount"}, + "conversation": {"key": "conversation", "type": "ConversationAccount"}, + "channel_id": {"key": "channelId", "type": "str"}, + "locale": {"key": "locale", "type": "str"}, + "service_url": {"key": "serviceUrl", "type": "str"}, + } + + def __init__( + self, + *, + activity_id: str = None, + user=None, + bot=None, + conversation=None, + channel_id: str = None, + locale: str = None, + service_url: str = None, + **kwargs + ) -> None: + super(ConversationReference, self).__init__(**kwargs) + self.activity_id = activity_id + self.user = user + self.bot = bot + self.conversation = conversation + self.channel_id = channel_id + self.locale = locale + self.service_url = service_url + + +class Mention(Model): + """Mention information (entity type: "mention"). + + :param mentioned: The mentioned user + :type mentioned: ~botframework.connector.models.ChannelAccount + :param text: Sub Text which represents the mention (can be null or empty) + :type text: str + :param type: Type of this entity (RFC 3987 IRI) + :type type: str + """ + + _attribute_map = { + "mentioned": {"key": "mentioned", "type": "ChannelAccount"}, + "text": {"key": "text", "type": "str"}, + "type": {"key": "type", "type": "str"}, + } + + def __init__( + self, *, mentioned=None, text: str = None, type: str = None, **kwargs + ) -> None: + super(Mention, self).__init__(**kwargs) + self.mentioned = mentioned + self.text = text + self.type = type + + +class ResourceResponse(Model): + """A response containing a resource ID. + + :param id: Id of the resource + :type id: str + """ + + _attribute_map = {"id": {"key": "id", "type": "str"}} + + def __init__(self, *, id: str = None, **kwargs) -> None: + super(ResourceResponse, self).__init__(**kwargs) + self.id = id + + class Activity(Model): """An Activity is the basic communication type for the Bot Framework 3.0 protocol. @@ -288,9 +385,106 @@ def __init__( self.semantic_action = semantic_action self.caller_id = caller_id + def apply_conversation_reference( + self, reference: ConversationReference, is_comming: bool = False + ): + self.channel_id = reference.channel_id + self.service_url = reference.service_url + self.conversation = reference.conversation + + if reference.locale is not None: + self.locale = reference.locale + + if is_comming: + self.from_property = reference.user + self.recipient = reference.bot + + if reference.activity_id is not None: + self.id = reference.activity_id + else: + self.from_property = reference.bot + self.recipient = reference.user + + if reference.activity_id is not None: + self.reply_to_id = reference.activity_id + + return self + + def as_contact_relation_update_activity(self): + return ( + self if self.__is_activity(ActivityTypes.contact_relation_update) else None + ) + + def as_conversation_update_activity(self): + return self if self.__is_activity(ActivityTypes.conversation_update) else None + + def as_end_of_conversation_activity(self): + return self if self.__is_activity(ActivityTypes.end_of_conversation) else None + + def as_event_activity(self): + return self if self.__is_activity(ActivityTypes.event) else None + + def as_handoff_activity(self): + return self if self.__is_activity(ActivityTypes.handoff) else None + + def as_installation_update_activity(self): + return self if self.__is_activity(ActivityTypes.installation_update) else None + + def as_invoke_activity(self): + return self if self.__is_activity(ActivityTypes.invoke) else None + + def as_message_activity(self): + return self if self.__is_activity(ActivityTypes.message) else None + + def as_message_delete_activity(self): + return self if self.__is_activity(ActivityTypes.message_delete) else None + + def as_message_reaction_activity(self): + return self if self.__is_activity(ActivityTypes.message_reaction) else None + + def as_message_update_activity(self): + return self if self.__is_activity(ActivityTypes.message_update) else None + + def as_suggestion_activity(self): + return self if self.__is_activity(ActivityTypes.suggestion) else None + + def as_trace_activity(self): + return self if self.__is_activity(ActivityTypes.trace) else None + + def as_typing_activity(self): + return self if self.__is_activity(ActivityTypes.typing) else None + + @staticmethod + def create_contact_relation_update_activity(): + return Activity(type=ActivityTypes.contact_relation_update) + + @staticmethod + def create_conversation_update_activity(): + return Activity(type=ActivityTypes.conversation_update) + + @staticmethod + def create_end_of_conversation_activity(): + return Activity(type=ActivityTypes.end_of_conversation) + + @staticmethod + def create_event_activity(): + return Activity(type=ActivityTypes.event) + + @staticmethod + def create_handoff_activity(): + return Activity(type=ActivityTypes.handoff) + + @staticmethod + def create_invoke_activity(): + return Activity(type=ActivityTypes.invoke) + + @staticmethod + def create_message_activity(): + return Activity(type=ActivityTypes.message) + def create_reply(self, text: str = None, locale: str = None): return Activity( - type="message", + type=ActivityTypes.message, timestamp=datetime.utcnow(), from_property=ChannelAccount( id=self.recipient.id if self.recipient else None, @@ -314,6 +508,120 @@ def create_reply(self, text: str = None, locale: str = None): entities=[], ) + def create_trace( + self, name: str, value: object = None, value_type: str = None, label: str = None + ): + if not value_type: + if value and hasattr(value, "type"): + value_type = value.type + + return Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + from_property=ChannelAccount( + id=self.recipient.id if self.recipient else None, + name=self.recipient.name if self.recipient else None, + ), + recipient=ChannelAccount( + id=self.from_property.id if self.from_property else None, + name=self.from_property.name if self.from_property else None, + ), + reply_to_id=self.id, + service_url=self.service_url, + channel_id=self.channel_id, + conversation=ConversationAccount( + is_group=self.conversation.is_group, + id=self.conversation.id, + name=self.conversation.name, + ), + name=name, + label=label, + value_type=value_type, + value=value, + ).as_trace_activity() + + @staticmethod + def create_trace_activity( + name: str, value: object = None, value_type: str = None, label: str = None + ): + if not value_type: + if value and hasattr(value, "type"): + value_type = value.type + + return Activity( + type=ActivityTypes.trace, + name=name, + label=label, + value_type=value_type, + value=value, + ) + + @staticmethod + def create_typing_activity(): + return Activity(type=ActivityTypes.typing) + + def get_conversation_reference(self): + return ConversationReference( + activity_id=self.id, + user=self.from_property, + bot=self.recipient, + conversation=self.conversation, + channel_id=self.channel_id, + locale=self.locale, + service_url=self.service_url, + ) + + def get_mentions(self) -> [Mention]: + _list = self.entities + return [x for x in _list if str(x.type).lower() == "mention"] + + def get_reply_conversation_reference( + self, reply: ResourceResponse + ) -> ConversationReference: + reference = self.get_conversation_reference() + reference.activity_id = reply.id + return reference + + def has_content(self) -> bool: + if self.text and self.text.strip(): + return True + + if self.summary and self.summary.strip(): + return True + + if self.attachments and len(self.attachments) > 0: + return True + + if self.channel_data: + return True + + return False + + def is_from_streaming_connection(self) -> bool: + if self.service_url: + return not self.service_url.lower().startswith("http") + return False + + def __is_activity(self, activity_type: str) -> bool: + if self.type is None: + return False + + type_attribute = str(self.type).lower() + activity_type = str(activity_type).lower() + + result = type_attribute.startswith(activity_type) + + if result: + result = len(type_attribute) == len(activity_type) + + if not result: + result = ( + len(type_attribute) > len(activity_type) + and type_attribute[len(activity_type)] == "/" + ) + + return result + class AnimationCard(Model): """An animation card (Ex: gif or short video clip). @@ -903,62 +1211,6 @@ def __init__( self.tenant_id = tenant_id -class ConversationReference(Model): - """An object relating to a particular point in a conversation. - - :param activity_id: (Optional) ID of the activity to refer to - :type activity_id: str - :param user: (Optional) User participating in this conversation - :type user: ~botframework.connector.models.ChannelAccount - :param bot: Bot participating in this conversation - :type bot: ~botframework.connector.models.ChannelAccount - :param conversation: Conversation reference - :type conversation: ~botframework.connector.models.ConversationAccount - :param channel_id: Channel ID - :type channel_id: str - :param locale: A locale name for the contents of the text field. - The locale name is a combination of an ISO 639 two- or three-letter - culture code associated with a language and an ISO 3166 two-letter - subculture code associated with a country or region. - The locale name can also correspond to a valid BCP-47 language tag. - :type locale: str - :param service_url: Service endpoint where operations concerning the - referenced conversation may be performed - :type service_url: str - """ - - _attribute_map = { - "activity_id": {"key": "activityId", "type": "str"}, - "user": {"key": "user", "type": "ChannelAccount"}, - "bot": {"key": "bot", "type": "ChannelAccount"}, - "conversation": {"key": "conversation", "type": "ConversationAccount"}, - "channel_id": {"key": "channelId", "type": "str"}, - "locale": {"key": "locale", "type": "str"}, - "service_url": {"key": "serviceUrl", "type": "str"}, - } - - def __init__( - self, - *, - activity_id: str = None, - user=None, - bot=None, - conversation=None, - channel_id: str = None, - locale: str = None, - service_url: str = None, - **kwargs - ) -> None: - super(ConversationReference, self).__init__(**kwargs) - self.activity_id = activity_id - self.user = user - self.bot = bot - self.conversation = conversation - self.channel_id = channel_id - self.locale = locale - self.service_url = service_url - - class ConversationResourceResponse(Model): """A response containing a resource. @@ -1349,32 +1601,6 @@ def __init__(self, *, url: str = None, profile: str = None, **kwargs) -> None: self.profile = profile -class Mention(Model): - """Mention information (entity type: "mention"). - - :param mentioned: The mentioned user - :type mentioned: ~botframework.connector.models.ChannelAccount - :param text: Sub Text which represents the mention (can be null or empty) - :type text: str - :param type: Type of this entity (RFC 3987 IRI) - :type type: str - """ - - _attribute_map = { - "mentioned": {"key": "mentioned", "type": "ChannelAccount"}, - "text": {"key": "text", "type": "str"}, - "type": {"key": "type", "type": "str"}, - } - - def __init__( - self, *, mentioned=None, text: str = None, type: str = None, **kwargs - ) -> None: - super(Mention, self).__init__(**kwargs) - self.mentioned = mentioned - self.text = text - self.type = type - - class MessageReaction(Model): """Message reaction object. @@ -1600,20 +1826,6 @@ def __init__( self.tap = tap -class ResourceResponse(Model): - """A response containing a resource ID. - - :param id: Id of the resource - :type id: str - """ - - _attribute_map = {"id": {"key": "id", "type": "str"}} - - def __init__(self, *, id: str = None, **kwargs) -> None: - super(ResourceResponse, self).__init__(**kwargs) - self.id = id - - class SemanticAction(Model): """Represents a reference to a programmatic action. From ed3da6c28aa8ed9047b8e3f1d3d3f246a5ac2971 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 26 Jun 2020 13:00:22 -0300 Subject: [PATCH 483/616] add tests for Activity class --- .../botbuilder-schema/tests/test_activity.py | 667 ++++++++++++++++++ 1 file changed, 667 insertions(+) create mode 100644 libraries/botbuilder-schema/tests/test_activity.py diff --git a/libraries/botbuilder-schema/tests/test_activity.py b/libraries/botbuilder-schema/tests/test_activity.py new file mode 100644 index 000000000..7d153a0d3 --- /dev/null +++ b/libraries/botbuilder-schema/tests/test_activity.py @@ -0,0 +1,667 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest +from botbuilder.schema import ( + Activity, + ConversationReference, + ConversationAccount, + ChannelAccount, + Entity, + ResourceResponse, + Attachment, +) +from botbuilder.schema._connector_client_enums import ActivityTypes + + +class TestActivity(aiounittest.AsyncTestCase): + def test_constructor(self): + # Arrange + activity = Activity() + + # Assert + self.assertIsNotNone(activity) + self.assertIsNone(activity.type) + self.assertIsNone(activity.id) + self.assertIsNone(activity.timestamp) + self.assertIsNone(activity.local_timestamp) + self.assertIsNone(activity.local_timezone) + self.assertIsNone(activity.service_url) + self.assertIsNone(activity.channel_id) + self.assertIsNone(activity.from_property) + self.assertIsNone(activity.conversation) + self.assertIsNone(activity.recipient) + self.assertIsNone(activity.text_format) + self.assertIsNone(activity.attachment_layout) + self.assertIsNone(activity.members_added) + self.assertIsNone(activity.members_removed) + self.assertIsNone(activity.reactions_added) + self.assertIsNone(activity.reactions_removed) + self.assertIsNone(activity.topic_name) + self.assertIsNone(activity.history_disclosed) + self.assertIsNone(activity.locale) + self.assertIsNone(activity.text) + self.assertIsNone(activity.speak) + self.assertIsNone(activity.input_hint) + self.assertIsNone(activity.summary) + self.assertIsNone(activity.suggested_actions) + self.assertIsNone(activity.attachments) + self.assertIsNone(activity.entities) + self.assertIsNone(activity.channel_data) + self.assertIsNone(activity.action) + self.assertIsNone(activity.reply_to_id) + self.assertIsNone(activity.label) + self.assertIsNone(activity.value_type) + self.assertIsNone(activity.value) + self.assertIsNone(activity.name) + self.assertIsNone(activity.relates_to) + self.assertIsNone(activity.code) + self.assertIsNone(activity.expiration) + self.assertIsNone(activity.importance) + self.assertIsNone(activity.delivery_mode) + self.assertIsNone(activity.listen_for) + self.assertIsNone(activity.text_highlights) + self.assertIsNone(activity.semantic_action) + self.assertIsNone(activity.caller_id) + + def test_apply_conversation_reference(self): + # Arrange + activity = self.__create_activity() + conversation_reference = ConversationReference( + channel_id="123", + service_url="serviceUrl", + conversation=ConversationAccount(id="456"), + user=ChannelAccount(id="abc"), + bot=ChannelAccount(id="def"), + activity_id="12345", + locale="en-uS", + ) + + # Act + activity.apply_conversation_reference(reference=conversation_reference) + + # Assert + self.assertEqual(conversation_reference.channel_id, activity.channel_id) + self.assertEqual(conversation_reference.locale, activity.locale) + self.assertEqual(conversation_reference.service_url, activity.service_url) + self.assertEqual( + conversation_reference.conversation.id, activity.conversation.id + ) + self.assertEqual(conversation_reference.bot.id, activity.from_property.id) + self.assertEqual(conversation_reference.user.id, activity.recipient.id) + self.assertEqual(conversation_reference.activity_id, activity.reply_to_id) + + def test_as_contact_relation_update_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.contact_relation_update + + # Act + result = activity.as_contact_relation_update_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.contact_relation_update) + + def test_as_contact_relation_update_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_contact_relation_update_activity() + + # Assert + self.assertIsNone(result) + + def test_as_conversation_update_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.conversation_update + + # Act + result = activity.as_conversation_update_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.conversation_update) + + def test_as_conversation_update_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_conversation_update_activity() + + # Assert + self.assertIsNone(result) + + def test_as_end_of_conversation_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.end_of_conversation + + # Act + result = activity.as_end_of_conversation_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.end_of_conversation) + + def test_as_end_of_conversation_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_end_of_conversation_activity() + + # Assert + self.assertIsNone(result) + + def test_as_event_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.event + + # Act + result = activity.as_event_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.event) + + def test_as_event_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_event_activity() + + # Assert + self.assertIsNone(result) + + def test_as_handoff_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.handoff + + # Act + result = activity.as_handoff_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.handoff) + + def test_as_handoff_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_handoff_activity() + + # Assert + self.assertIsNone(result) + + def test_as_installation_update_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.installation_update + + # Act + result = activity.as_installation_update_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.installation_update) + + def test_as_installation_update_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_installation_update_activity() + + # Assert + self.assertIsNone(result) + + def test_as_invoke_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.invoke + + # Act + result = activity.as_invoke_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.invoke) + + def test_as_invoke_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_invoke_activity() + + # Assert + self.assertIsNone(result) + + def test_as_message_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_message_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.message) + + def test_as_message_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.invoke + + # Act + result = activity.as_message_activity() + + # Assert + self.assertIsNone(result) + + def test_as_message_delete_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message_delete + + # Act + result = activity.as_message_delete_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.message_delete) + + def test_as_message_delete_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_message_delete_activity() + + # Assert + self.assertIsNone(result) + + def test_as_message_reaction_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message_reaction + + # Act + result = activity.as_message_reaction_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.message_reaction) + + def test_as_message_reaction_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_message_reaction_activity() + + # Assert + self.assertIsNone(result) + + def test_as_message_update_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message_update + + # Act + result = activity.as_message_update_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.message_update) + + def test_as_message_update_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_message_update_activity() + + # Assert + self.assertIsNone(result) + + def test_as_suggestion_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.suggestion + + # Act + result = activity.as_suggestion_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.suggestion) + + def test_as_suggestion_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_suggestion_activity() + + # Assert + self.assertIsNone(result) + + def test_as_trace_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.trace + + # Act + result = activity.as_trace_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.trace) + + def test_as_trace_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_trace_activity() + + # Assert + self.assertIsNone(result) + + def test_as_typing_activity_return_activity(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.typing + + # Act + result = activity.as_typing_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.typing) + + def test_as_typing_activity_return_none(self): + # Arrange + activity = self.__create_activity() + activity.type = ActivityTypes.message + + # Act + result = activity.as_typing_activity() + + # Assert + self.assertIsNone(result) + + def test_create_contact_relation_update_activity(self): + # Act + result = Activity.create_contact_relation_update_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.contact_relation_update) + + def test_create_conversation_update_activity(self): + # Act + result = Activity.create_conversation_update_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.conversation_update) + + def test_create_end_of_conversation_activity(self): + # Act + result = Activity.create_end_of_conversation_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.end_of_conversation) + + def test_create_event_activity(self): + # Act + result = Activity.create_event_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.event) + + def test_create_handoff_activity(self): + # Act + result = Activity.create_handoff_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.handoff) + + def test_create_invoke_activity(self): + # Act + result = Activity.create_invoke_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.invoke) + + def test_create_message_activity(self): + # Act + result = Activity.create_message_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.message) + + def test_create_reply(self): + # Arrange + activity = self.__create_activity() + text = "test reply" + locale = "en-us" + + # Act + result = activity.create_reply(text=text, locale=locale) + + # Assert + self.assertEqual(result.text, text) + self.assertEqual(result.locale, locale) + self.assertEqual(result.type, ActivityTypes.message) + + def test_create_trace(self): + # Arrange + activity = self.__create_activity() + name = "test-activity" + value_type = "string" + value = "test-value" + label = "test-label" + + # Act + result = activity.create_trace( + name=name, value_type=value_type, value=value, label=label + ) + + # Assert + self.assertEqual(result.type, ActivityTypes.trace) + self.assertEqual(result.name, name) + self.assertEqual(result.value_type, value_type) + self.assertEqual(result.value, value) + self.assertEqual(result.label, label) + + def test_create_trace_activity(self): + # Arrange + name = "test-activity" + value_type = "string" + value = "test-value" + label = "test-label" + + # Act + result = Activity.create_trace_activity( + name=name, value_type=value_type, value=value, label=label + ) + + # Assert + self.assertEqual(result.type, ActivityTypes.trace) + self.assertEqual(result.name, name) + self.assertEqual(result.value_type, value_type) + self.assertEqual(result.label, label) + + def test_create_typing_activity(self): + # Act + result = Activity.create_typing_activity() + + # Assert + self.assertEqual(result.type, ActivityTypes.typing) + + def test_get_conversation_reference(self): + # Arrange + activity = self.__create_activity() + + # Act + result = activity.get_conversation_reference() + + # Assert + self.assertEqual(activity.id, result.activity_id) + self.assertEqual(activity.from_property.id, result.user.id) + self.assertEqual(activity.recipient.id, result.bot.id) + self.assertEqual(activity.conversation.id, result.conversation.id) + self.assertEqual(activity.channel_id, result.channel_id) + self.assertEqual(activity.locale, result.locale) + self.assertEqual(activity.service_url, result.service_url) + + def test_get_mentions(self): + # Arrange + mentions = [Entity(type="mention"), Entity(type="reaction")] + activity = Activity(entities=mentions) + + # Act + result = Activity.get_mentions(activity) + + # Assert + self.assertEqual(len(result), 1) + self.assertEqual(result[0].type, "mention") + + def test_get_reply_conversation_reference(self): + # Arrange + activity = self.__create_activity() + reply = ResourceResponse(id="1234") + + # Act + result = activity.get_reply_conversation_reference(reply=reply) + + # Assert + self.assertEqual(reply.id, result.activity_id) + self.assertEqual(activity.from_property.id, result.user.id) + self.assertEqual(activity.recipient.id, result.bot.id) + self.assertEqual(activity.conversation.id, result.conversation.id) + self.assertEqual(activity.channel_id, result.channel_id) + self.assertEqual(activity.locale, result.locale) + self.assertEqual(activity.service_url, result.service_url) + + def test_has_content_empty(self): + # Arrange + activity_empty = Activity() + + # Act + result_empty = activity_empty.has_content() + + # Assert + self.assertEqual(result_empty, False) + + def test_has_content_with_text(self): + # Arrange + activity_with_text = Activity(text="test-text") + + # Act + result_with_text = activity_with_text.has_content() + + # Assert + self.assertEqual(result_with_text, True) + + def test_has_content_with_summary(self): + # Arrange + activity_with_summary = Activity(summary="test-summary") + + # Act + result_with_summary = activity_with_summary.has_content() + + # Assert + self.assertEqual(result_with_summary, True) + + def test_has_content_with_attachment(self): + # Arrange + activity_with_attachment = Activity(attachments=[Attachment()]) + + # Act + result_with_attachment = activity_with_attachment.has_content() + + # Assert + self.assertEqual(result_with_attachment, True) + + def test_has_content_with_channel_data(self): + # Arrange + activity_with_channel_data = Activity(channel_data="test-channel-data") + + # Act + result_with_channel_data = activity_with_channel_data.has_content() + + # Assert + self.assertEqual(result_with_channel_data, True) + + def test_is_from_streaming_connection(self): + # Arrange + non_streaming = [ + "http://yayay.com", + "https://yayay.com", + "HTTP://yayay.com", + "HTTPS://yayay.com", + ] + streaming = [ + "urn:botframework:WebSocket:wss://beep.com", + "urn:botframework:WebSocket:http://beep.com", + "URN:botframework:WebSocket:wss://beep.com", + "URN:botframework:WebSocket:http://beep.com", + ] + activity = self.__create_activity() + activity.service_url = None + + # Assert + self.assertEqual(activity.is_from_streaming_connection(), False) + + for s in non_streaming: + activity.service_url = s + self.assertEqual(activity.is_from_streaming_connection(), False) + + for s in streaming: + activity.service_url = s + self.assertEqual(activity.is_from_streaming_connection(), True) + + @staticmethod + def __create_activity() -> Activity: + account1 = ChannelAccount( + id="ChannelAccount_Id_1", + name="ChannelAccount_Name_1", + aad_object_id="ChannelAccount_aadObjectId_1", + role="ChannelAccount_Role_1", + ) + + account2 = ChannelAccount( + id="ChannelAccount_Id_2", + name="ChannelAccount_Name_2", + aad_object_id="ChannelAccount_aadObjectId_2", + role="ChannelAccount_Role_2", + ) + + conversation_account = ConversationAccount( + conversation_type="a", + id="123", + is_group=True, + name="Name", + role="ConversationAccount_Role", + ) + + activity = Activity( + id="123", + from_property=account1, + recipient=account2, + conversation=conversation_account, + channel_id="ChannelId123", + locale="en-uS", + service_url="ServiceUrl123", + ) + + return activity From b5eef3740651ee14ad6b42904c14264fb0fb0c72 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 09:53:20 -0700 Subject: [PATCH 484/616] Update skill_handler.py --- .../botbuilder-core/botbuilder/core/skills/skill_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index b0787d603..2f7ef72d6 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -73,7 +73,7 @@ async def on_send_to_conversation( Use SendToConversation in all other cases. :param claims_identity: Claims identity for the bot. - :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` :param conversation_id:The conversation ID. :type conversation_id: str :param activity: Activity to send. @@ -108,7 +108,7 @@ async def on_reply_to_activity( Use SendToConversation in all other cases. :param claims_identity: Claims identity for the bot. - :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` :param conversation_id:The conversation ID. :type conversation_id: str :param activity: Activity to send. From 9641d2b3f3ae985012041b38e8b8b2e1506e19f8 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 09:56:51 -0700 Subject: [PATCH 485/616] Update luis_recognizer.py --- .../botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 1590f5c90..2bb73948f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -42,7 +42,7 @@ def __init__( :param application: The LUIS application to use to recognize text. :type application: :class:`LuisApplication` :param prediction_options: The LUIS prediction options to use, defaults to None. - :type prediction_options: :class:LuisPredictionOptions`, optional + :type prediction_options: :class:`LuisPredictionOptions`, optional :param include_api_results: True to include raw LUIS API response, defaults to False. :type include_api_results: bool, optional :raises TypeError: @@ -144,7 +144,7 @@ def on_recognizer_result( :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None. - :type telemetry_properties: :class:`typing.Dict[str, str], optional + :type telemetry_properties: :class:`typing.Dict[str, str]`, optional :param telemetry_metrics: Additional metrics to be logged to telemetry with the LuisResult event, defaults to None. :type telemetry_metrics: :class:`typing.Dict[str, float]`, optional @@ -189,7 +189,7 @@ def fill_luis_event_properties( :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None - :param telemetry_properties: Dict[str, str], optional + :param telemetry_properties: :class:`typing.Dict[str, str]`, optional :return: A dictionary that is sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` method for the BotMessageSend event. :rtype: `typing.Dict[str, str]` From 39e492616f821d8cad51a5c4e51f74e626c5a49f Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 09:58:50 -0700 Subject: [PATCH 486/616] Update qnamaker.py --- libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 62fd16714..00d026339 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -101,7 +101,7 @@ async def get_answers_raw( Generates raw answers from the knowledge base. :return: A list of answers for the user's query, sorted in decreasing order of ranking score. - :rtype: class:`QueryResult` + :rtype: :class:`QueryResult` """ if not context: raise TypeError("QnAMaker.get_answers(): context cannot be None.") @@ -124,7 +124,7 @@ def get_low_score_variation(self, query_result: QueryResult) -> List[QueryResult Filters the ambiguous question for active learning. :param query_result: User query output. - :type query_result: class:`QueryResult` + :type query_result: :class:`QueryResult` :return: Filtered array of ambiguous questions. """ return ActiveLearningUtils.get_low_score_variation(query_result) From 8996b62f1801c87f1f1ff6929e9127c7f84f1ad0 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 09:59:41 -0700 Subject: [PATCH 487/616] Update null_telemetry_client.py --- .../botbuilder-core/botbuilder/core/null_telemetry_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py b/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py index dca0e1fc5..6cb3e5789 100644 --- a/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py +++ b/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py @@ -118,7 +118,7 @@ def track_request( :param url: The actual URL for this request (to show in individual request instances). :param success: True if the request ended in success, False otherwise. :param start_time: the start time of the request. The value should look the same as the one returned \ - by :func:`datetime.isoformat` (defaults to: None) + by :func:`datetime.isoformat`. (defaults to: None) :param duration: the number of milliseconds that this request lasted. (defaults to: None) :param response_code: the response code that this request returned. (defaults to: None) :param http_method: the HTTP method that triggered this request. (defaults to: None) From 9218e2963db70e3eefcc0bb5da430411aa5beaaf Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 09:59:59 -0700 Subject: [PATCH 488/616] Update bot_telemetry_client.py --- .../botbuilder-core/botbuilder/core/bot_telemetry_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py b/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py index 4fee9496e..0b935e943 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py @@ -144,7 +144,7 @@ def track_request( :param url: The actual URL for this request (to show in individual request instances). :param success: True if the request ended in success, False otherwise. :param start_time: the start time of the request. \ - The value should look the same as the one returned by :func:`datetime.isoformat` (defaults to: None) + The value should look the same as the one returned by :func:`datetime.isoformat`. (defaults to: None) :param duration: the number of milliseconds that this request lasted. (defaults to: None) :param response_code: the response code that this request returned. (defaults to: None) :param http_method: the HTTP method that triggered this request. (defaults to: None) From 5b978316f97a29b71e8d15b0c043f3e3163c0489 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 10:00:18 -0700 Subject: [PATCH 489/616] Update application_insights_telemetry_client.py --- .../application_insights_telemetry_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 0db7a98d8..e0bae05ca 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -171,7 +171,7 @@ def track_request( :param url: The actual URL for this request (to show in individual request instances). :param success: True if the request ended in success, False otherwise. :param start_time: the start time of the request. The value should look the same as the one returned by - :func:`datetime.isoformat` (defaults to: None) + :func:`datetime.isoformat`. (defaults to: None) :param duration: the number of milliseconds that this request lasted. (defaults to: None) :param response_code: the response code that this request returned. (defaults to: None) :param http_method: the HTTP method that triggered this request. (defaults to: None) From d761ba8c4bcf7eaa6f3f70150e24efedc52af13f Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 26 Jun 2020 14:44:43 -0300 Subject: [PATCH 490/616] add comments to Activity methods --- .../botbuilder/schema/_models_py3.py | 223 +++++++++++++++++- 1 file changed, 221 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index aeea03204..0333d801c 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -386,8 +386,23 @@ def __init__( self.caller_id = caller_id def apply_conversation_reference( - self, reference: ConversationReference, is_comming: bool = False + self, reference: ConversationReference, is_incoming: bool = False ): + """ + Updates this activity with the delivery information from an existing ConversationReference + + :param reference: The existing conversation reference. + :param is_incoming: Optional, True to treat the activity as an + incoming activity, where the bot is the recipient; otherwise, False. + Default is False, and the activity will show the bot as the sender. + + :returns: his activity, updated with the delivery information. + + .. remarks:: + Call GetConversationReference on an incoming + activity to get a conversation reference that you can then use to update an + outgoing activity with the correct delivery information. + """ self.channel_id = reference.channel_id self.service_url = reference.service_url self.conversation = reference.conversation @@ -395,7 +410,7 @@ def apply_conversation_reference( if reference.locale is not None: self.locale = reference.locale - if is_comming: + if is_incoming: self.from_property = reference.user self.recipient = reference.bot @@ -411,78 +426,208 @@ def apply_conversation_reference( return self def as_contact_relation_update_activity(self): + """ + Returns this activity as a ContactRelationUpdateActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a message activity; or None. + """ return ( self if self.__is_activity(ActivityTypes.contact_relation_update) else None ) def as_conversation_update_activity(self): + """ + Returns this activity as a ConversationUpdateActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a conversation update activity; or None. + """ return self if self.__is_activity(ActivityTypes.conversation_update) else None def as_end_of_conversation_activity(self): + """ + Returns this activity as an EndOfConversationActivity object; + or None, if this is not that type of activity. + + :returns: This activity as an end of conversation activity; or None. + """ return self if self.__is_activity(ActivityTypes.end_of_conversation) else None def as_event_activity(self): + """ + Returns this activity as an EventActivity object; + or None, if this is not that type of activity. + + :returns: This activity as an event activity; or None. + """ return self if self.__is_activity(ActivityTypes.event) else None def as_handoff_activity(self): + """ + Returns this activity as a HandoffActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a handoff activity; or None. + """ return self if self.__is_activity(ActivityTypes.handoff) else None def as_installation_update_activity(self): + """ + Returns this activity as an InstallationUpdateActivity object; + or None, if this is not that type of activity. + + :returns: This activity as an installation update activity; or None. + """ return self if self.__is_activity(ActivityTypes.installation_update) else None def as_invoke_activity(self): + """ + Returns this activity as an InvokeActivity object; + or None, if this is not that type of activity. + + :returns: This activity as an invoke activity; or None. + """ return self if self.__is_activity(ActivityTypes.invoke) else None def as_message_activity(self): + """ + Returns this activity as a MessageActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a message activity; or None. + """ return self if self.__is_activity(ActivityTypes.message) else None def as_message_delete_activity(self): + """ + Returns this activity as a MessageDeleteActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a message delete request; or None. + """ return self if self.__is_activity(ActivityTypes.message_delete) else None def as_message_reaction_activity(self): + """ + Returns this activity as a MessageReactionActivity object; + or None, if this is not that type of activity. + + :return: This activity as a message reaction activity; or None. + """ return self if self.__is_activity(ActivityTypes.message_reaction) else None def as_message_update_activity(self): + """ + Returns this activity as an MessageUpdateActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a message update request; or None. + """ return self if self.__is_activity(ActivityTypes.message_update) else None def as_suggestion_activity(self): + """ + Returns this activity as a SuggestionActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a suggestion activity; or None. + """ return self if self.__is_activity(ActivityTypes.suggestion) else None def as_trace_activity(self): + """ + Returns this activity as a TraceActivity object; + or None, if this is not that type of activity. + + :returns: This activity as a trace activity; or None. + """ return self if self.__is_activity(ActivityTypes.trace) else None def as_typing_activity(self): + """ + Returns this activity as a TypingActivity object; + or null, if this is not that type of activity. + + :returns: This activity as a typing activity; or null. + """ return self if self.__is_activity(ActivityTypes.typing) else None @staticmethod def create_contact_relation_update_activity(): + """ + Creates an instance of the :class:`Activity` class as aContactRelationUpdateActivity object. + + :returns: The new contact relation update activity. + """ return Activity(type=ActivityTypes.contact_relation_update) @staticmethod def create_conversation_update_activity(): + """ + Creates an instance of the :class:`Activity` class as a ConversationUpdateActivity object. + + :returns: The new conversation update activity. + """ return Activity(type=ActivityTypes.conversation_update) @staticmethod def create_end_of_conversation_activity(): + """ + Creates an instance of the :class:`Activity` class as an EndOfConversationActivity object. + + :returns: The new end of conversation activity. + """ return Activity(type=ActivityTypes.end_of_conversation) @staticmethod def create_event_activity(): + """ + Creates an instance of the :class:`Activity` class as an EventActivity object. + + :returns: The new event activity. + """ return Activity(type=ActivityTypes.event) @staticmethod def create_handoff_activity(): + """ + Creates an instance of the :class:`Activity` class as a HandoffActivity object. + + :returns: The new handoff activity. + """ return Activity(type=ActivityTypes.handoff) @staticmethod def create_invoke_activity(): + """ + Creates an instance of the :class:`Activity` class as an InvokeActivity object. + + :returns: The new invoke activity. + """ return Activity(type=ActivityTypes.invoke) @staticmethod def create_message_activity(): + """ + Creates an instance of the :class:`Activity` class as a MessageActivity object. + + :returns: The new message activity. + """ return Activity(type=ActivityTypes.message) def create_reply(self, text: str = None, locale: str = None): + """ + Creates a new message activity as a response to this activity. + + :param text: The text of the reply. + :param locale: The language code for the text. + + :returns: The new message activity. + + .. remarks:: + The new activity sets up routing information based on this activity. + """ return Activity( type=ActivityTypes.message, timestamp=datetime.utcnow(), @@ -511,6 +656,17 @@ def create_reply(self, text: str = None, locale: str = None): def create_trace( self, name: str, value: object = None, value_type: str = None, label: str = None ): + """ + Creates a new trace activity based on this activity. + + :param name: The name of the trace operation to create. + :param value: Optional, the content for this trace operation. + :param value_type: Optional, identifier for the format of the value + Default is the name of type of the value. + :param label: Optional, a descriptive label for this trace operation. + + :returns: The new trace activity. + """ if not value_type: if value and hasattr(value, "type"): value_type = value.type @@ -544,6 +700,17 @@ def create_trace( def create_trace_activity( name: str, value: object = None, value_type: str = None, label: str = None ): + """ + Creates an instance of the :class:`Activity` class as a TraceActivity object. + + :param name: The name of the trace operation to create. + :param value: Optional, the content for this trace operation. + :param value_type: Optional, identifier for the format of the value. + Default is the name of type of the value. + :param label: Optional, a descriptive label for this trace operation. + + :returns: The new trace activity. + """ if not value_type: if value and hasattr(value, "type"): value_type = value.type @@ -558,9 +725,19 @@ def create_trace_activity( @staticmethod def create_typing_activity(): + """ + Creates an instance of the :class:`Activity` class as a TypingActivity object. + + :returns: The new typing activity. + """ return Activity(type=ActivityTypes.typing) def get_conversation_reference(self): + """ + Creates a ConversationReference based on this activity. + + :returns: A conversation reference for the conversation that contains this activity. + """ return ConversationReference( activity_id=self.id, user=self.from_property, @@ -572,17 +749,45 @@ def get_conversation_reference(self): ) def get_mentions(self) -> [Mention]: + """ + Resolves the mentions from the entities of this activity. + + :returns: The array of mentions; or an empty array, if none are found. + + .. remarks:: + This method is defined on the :class:`Activity` class, but is only intended + for use with a message activity, where the activity Activity.Type is set to + ActivityTypes.Message. + """ _list = self.entities return [x for x in _list if str(x.type).lower() == "mention"] def get_reply_conversation_reference( self, reply: ResourceResponse ) -> ConversationReference: + """ + Create a ConversationReference based on this Activity's Conversation info + and the ResourceResponse from sending an activity. + + :param reply: ResourceResponse returned from send_activity. + + :return: A ConversationReference that can be stored and used later to delete or update the activity. + """ reference = self.get_conversation_reference() reference.activity_id = reply.id return reference def has_content(self) -> bool: + """ + Indicates whether this activity has content. + + :returns: True, if this activity has any content to send; otherwise, false. + + .. remarks:: + This method is defined on the :class:`Activity` class, but is only intended + for use with a message activity, where the activity Activity.Type is set to + ActivityTypes.Message. + """ if self.text and self.text.strip(): return True @@ -598,11 +803,25 @@ def has_content(self) -> bool: return False def is_from_streaming_connection(self) -> bool: + """ + Determine if the Activity was sent via an Http/Https connection or Streaming + This can be determined by looking at the service_url property: + (1) All channels that send messages via http/https are not streaming + (2) Channels that send messages via streaming have a ServiceUrl that does not begin with http/https. + + :returns: True if the Activity originated from a streaming connection. + """ if self.service_url: return not self.service_url.lower().startswith("http") return False def __is_activity(self, activity_type: str) -> bool: + """ + Indicates whether this activity is of a specified activity type. + + :param activity_type: The activity type to check for. + :return: True if this activity is of the specified activity type; otherwise, False. + """ if self.type is None: return False From 1e56107917d82b3a127acbd9871973b03c1602b0 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 10:47:43 -0700 Subject: [PATCH 491/616] Updates --- .../adapters/slack/slack_adapter.py | 2 +- .../botbuilder/core/bot_adapter.py | 49 +++++++++++++------ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 2f1af54e8..c7c56e5f9 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -171,7 +171,7 @@ async def process(self, req: Request, logic: Callable) -> Response: Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. :param req: The aoihttp Request object - :param logic: The method to call for the resulting bot turn. + :param logic: The method to call for the resulting bot turn. :return: The aoihttp Response """ if not req: diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 421e34ff2..a2b050c3c 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -13,6 +13,11 @@ class BotAdapter(ABC): + """ + Represents a bot adapter that can connect a bot to a service endpoint. + + :var on_turn_error: Gets or sets an error handler. + """ BOT_IDENTITY_KEY = "BotIdentity" BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" @@ -30,8 +35,11 @@ async def send_activities( ) -> List[ResourceResponse]: """ Sends a set of activities to the user. An array of responses from the server will be returned. - :param context: - :param activities: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` + :param activities: The activities to send. + :type activities: :class:`typing.List[Activity]` :return: """ raise NotImplementedError() @@ -40,8 +48,11 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): """ Replaces an existing activity. - :param context: - :param activity: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` + :param activity: New replacement activity. + :type activity: :class:`botbuilder.schema.Activity` :return: """ raise NotImplementedError() @@ -52,8 +63,11 @@ async def delete_activity( ): """ Deletes an existing activity. - :param context: - :param reference: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` + :param reference: Conversation reference for the activity to delete. + :type reference: :class:`botbuilder.schema.ConversationReference` :return: """ raise NotImplementedError() @@ -61,7 +75,8 @@ async def delete_activity( def use(self, middleware): """ Registers a middleware handler with the adapter. - :param middleware: + + :param middleware: The middleware to register. :return: """ self._middleware.use(middleware) @@ -79,11 +94,12 @@ async def continue_conversation( Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. Most _channels require a user to initiate a conversation with a bot before the bot can send activities to the user. + :param bot_id: The application ID of the bot. This parameter is ignored in - single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter - which is multi-tenant aware. - :param reference: A reference to the conversation to continue. - :param callback: The method to call for the resulting bot turn. + single tenant the Adapters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. :param claims_identity: :param audience: """ @@ -96,10 +112,13 @@ async def run_pipeline( self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None ): """ - Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at - the end of the chain. - :param context: - :param callback: + Called by the parent class to run the adapters middleware set and calls + the passed in `callback()` handler at the end of the chain. + + :param context: The context object for the turn. + :type context: :class:`TurnContext` + :param callback: A callback method to run at the end of the pipeline. + :type callbacK: :class:`typing.Callable[[TurnContext], Awaitable]` :return: """ BotAssert.context_not_none(context) From 03fb47f9b2387bd7bf4348d66750e18ea1886b81 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 26 Jun 2020 14:53:29 -0300 Subject: [PATCH 492/616] add test_apply_conversation_reference_with_is_incoming_true --- .../botbuilder-schema/tests/test_activity.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/libraries/botbuilder-schema/tests/test_activity.py b/libraries/botbuilder-schema/tests/test_activity.py index 7d153a0d3..32c7d5738 100644 --- a/libraries/botbuilder-schema/tests/test_activity.py +++ b/libraries/botbuilder-schema/tests/test_activity.py @@ -91,6 +91,33 @@ def test_apply_conversation_reference(self): self.assertEqual(conversation_reference.user.id, activity.recipient.id) self.assertEqual(conversation_reference.activity_id, activity.reply_to_id) + def test_apply_conversation_reference_with_is_incoming_true(self): + # Arrange + activity = self.__create_activity() + conversation_reference = ConversationReference( + channel_id="cr_123", + service_url="cr_serviceUrl", + conversation=ConversationAccount(id="cr_456"), + user=ChannelAccount(id="cr_abc"), + bot=ChannelAccount(id="cr_def"), + activity_id="cr_12345", + locale="en-uS", + ) + + # Act + activity.apply_conversation_reference(reference=conversation_reference, is_incoming=True) + + # Assert + self.assertEqual(conversation_reference.channel_id, activity.channel_id) + self.assertEqual(conversation_reference.locale, activity.locale) + self.assertEqual(conversation_reference.service_url, activity.service_url) + self.assertEqual( + conversation_reference.conversation.id, activity.conversation.id + ) + self.assertEqual(conversation_reference.user.id, activity.from_property.id) + self.assertEqual(conversation_reference.bot.id, activity.recipient.id) + self.assertEqual(conversation_reference.activity_id, activity.id) + def test_as_contact_relation_update_activity_return_activity(self): # Arrange activity = self.__create_activity() From cc026709eedc1a29e4509d0b2a4fb17c07018257 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 10:55:17 -0700 Subject: [PATCH 493/616] Revert "Updates" This reverts commit 1e56107917d82b3a127acbd9871973b03c1602b0. --- .../adapters/slack/slack_adapter.py | 2 +- .../botbuilder/core/bot_adapter.py | 49 ++++++------------- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index c7c56e5f9..2f1af54e8 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -171,7 +171,7 @@ async def process(self, req: Request, logic: Callable) -> Response: Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. :param req: The aoihttp Request object - :param logic: The method to call for the resulting bot turn. + :param logic: The method to call for the resulting bot turn. :return: The aoihttp Response """ if not req: diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index a2b050c3c..421e34ff2 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -13,11 +13,6 @@ class BotAdapter(ABC): - """ - Represents a bot adapter that can connect a bot to a service endpoint. - - :var on_turn_error: Gets or sets an error handler. - """ BOT_IDENTITY_KEY = "BotIdentity" BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" @@ -35,11 +30,8 @@ async def send_activities( ) -> List[ResourceResponse]: """ Sends a set of activities to the user. An array of responses from the server will be returned. - - :param context: The context object for the turn. - :type context: :class:`TurnContext` - :param activities: The activities to send. - :type activities: :class:`typing.List[Activity]` + :param context: + :param activities: :return: """ raise NotImplementedError() @@ -48,11 +40,8 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): """ Replaces an existing activity. - - :param context: The context object for the turn. - :type context: :class:`TurnContext` - :param activity: New replacement activity. - :type activity: :class:`botbuilder.schema.Activity` + :param context: + :param activity: :return: """ raise NotImplementedError() @@ -63,11 +52,8 @@ async def delete_activity( ): """ Deletes an existing activity. - - :param context: The context object for the turn. - :type context: :class:`TurnContext` - :param reference: Conversation reference for the activity to delete. - :type reference: :class:`botbuilder.schema.ConversationReference` + :param context: + :param reference: :return: """ raise NotImplementedError() @@ -75,8 +61,7 @@ async def delete_activity( def use(self, middleware): """ Registers a middleware handler with the adapter. - - :param middleware: The middleware to register. + :param middleware: :return: """ self._middleware.use(middleware) @@ -94,12 +79,11 @@ async def continue_conversation( Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. Most _channels require a user to initiate a conversation with a bot before the bot can send activities to the user. - :param bot_id: The application ID of the bot. This parameter is ignored in - single tenant the Adapters (Console, Test, etc) but is critical to the BotFrameworkAdapter - which is multi-tenant aware. - :param reference: A reference to the conversation to continue. - :param callback: The method to call for the resulting bot turn. + single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. :param claims_identity: :param audience: """ @@ -112,13 +96,10 @@ async def run_pipeline( self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None ): """ - Called by the parent class to run the adapters middleware set and calls - the passed in `callback()` handler at the end of the chain. - - :param context: The context object for the turn. - :type context: :class:`TurnContext` - :param callback: A callback method to run at the end of the pipeline. - :type callbacK: :class:`typing.Callable[[TurnContext], Awaitable]` + Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at + the end of the chain. + :param context: + :param callback: :return: """ BotAssert.context_not_none(context) From 63bdc06ecf914f3d40e104bbf13a2f11a5e877c2 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 26 Jun 2020 14:59:09 -0300 Subject: [PATCH 494/616] apply black styling --- libraries/botbuilder-schema/tests/test_activity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/tests/test_activity.py b/libraries/botbuilder-schema/tests/test_activity.py index 32c7d5738..1e1276451 100644 --- a/libraries/botbuilder-schema/tests/test_activity.py +++ b/libraries/botbuilder-schema/tests/test_activity.py @@ -105,7 +105,9 @@ def test_apply_conversation_reference_with_is_incoming_true(self): ) # Act - activity.apply_conversation_reference(reference=conversation_reference, is_incoming=True) + activity.apply_conversation_reference( + reference=conversation_reference, is_incoming=True + ) # Assert self.assertEqual(conversation_reference.channel_id, activity.channel_id) From 84039d8dc46f8c3511605164fb4fd9f81d876441 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 11:06:42 -0700 Subject: [PATCH 495/616] Removed , fixed param lists --- .../adapters/slack/slack_adapter.py | 2 +- .../botbuilder/core/bot_adapter.py | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 2f1af54e8..c7c56e5f9 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -171,7 +171,7 @@ async def process(self, req: Request, logic: Callable) -> Response: Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. :param req: The aoihttp Request object - :param logic: The method to call for the resulting bot turn. + :param logic: The method to call for the resulting bot turn. :return: The aoihttp Response """ if not req: diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 421e34ff2..2ceb9b3b5 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -30,7 +30,9 @@ async def send_activities( ) -> List[ResourceResponse]: """ Sends a set of activities to the user. An array of responses from the server will be returned. - :param context: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` :param activities: :return: """ @@ -40,7 +42,9 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): """ Replaces an existing activity. - :param context: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` :param activity: :return: """ @@ -52,7 +56,9 @@ async def delete_activity( ): """ Deletes an existing activity. - :param context: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` :param reference: :return: """ @@ -61,6 +67,7 @@ async def delete_activity( def use(self, middleware): """ Registers a middleware handler with the adapter. + :param middleware: :return: """ @@ -77,13 +84,14 @@ async def continue_conversation( ): """ Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. - Most _channels require a user to initiate a conversation with a bot before the bot can send activities + Most channels require a user to initiate a conversation with a bot before the bot can send activities to the user. + :param bot_id: The application ID of the bot. This parameter is ignored in - single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter - which is multi-tenant aware. - :param reference: A reference to the conversation to continue. - :param callback: The method to call for the resulting bot turn. + single tenant the Adapters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. :param claims_identity: :param audience: """ @@ -98,7 +106,9 @@ async def run_pipeline( """ Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at the end of the chain. - :param context: + + :param context: The context object for the turn. + :type context: :class:`TurnContext` :param callback: :return: """ From a5425f6aff0eae16a500f0ba4081ea90072107d0 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 11:30:43 -0700 Subject: [PATCH 496/616] Update slack_adapter.py --- .../botbuilder/adapters/slack/slack_adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index c7c56e5f9..918c01d70 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -138,6 +138,7 @@ async def continue_conversation( Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. Most _channels require a user to initiate a conversation with a bot before the bot can send activities to the user. + :param bot_id: Unused for this override. :param reference: A reference to the conversation to continue. :param callback: The method to call for the resulting bot turn. From cd509befdb4084c63332825c3e756c35ee7da385 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 11:34:49 -0700 Subject: [PATCH 497/616] Update bot_adapter.py --- .../botbuilder-core/botbuilder/core/bot_adapter.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 2ceb9b3b5..49ac384e8 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -33,7 +33,8 @@ async def send_activities( :param context: The context object for the turn. :type context: :class:`TurnContext` - :param activities: + :param activities: The activities to send. + :type activities: :class:`typing.List[Activity]` :return: """ raise NotImplementedError() @@ -45,7 +46,8 @@ async def update_activity(self, context: TurnContext, activity: Activity): :param context: The context object for the turn. :type context: :class:`TurnContext` - :param activity: + :param activity: New replacement activity. + :type activity: :class:`botbuilder.schema.Activity` :return: """ raise NotImplementedError() @@ -59,7 +61,8 @@ async def delete_activity( :param context: The context object for the turn. :type context: :class:`TurnContext` - :param reference: + :param reference: Conversation reference for the activity to delete. + :type reference: :class:`botbuilder.schema.ConversationReference` :return: """ raise NotImplementedError() @@ -68,7 +71,7 @@ def use(self, middleware): """ Registers a middleware handler with the adapter. - :param middleware: + :param middleware: The middleware to register. :return: """ self._middleware.use(middleware) @@ -109,7 +112,8 @@ async def run_pipeline( :param context: The context object for the turn. :type context: :class:`TurnContext` - :param callback: + :param callback: A callback method to run at the end of the pipeline. + :type callbacK: :class:`typing.Callable[[TurnContext], Awaitable]` :return: """ BotAssert.context_not_none(context) From 8d5bdccd9e2e5c0ec1ecc1deeeff819a9a73f86e Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 11:57:28 -0700 Subject: [PATCH 498/616] Update bot_adapter.py --- .../botbuilder-core/botbuilder/core/bot_adapter.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 49ac384e8..5c7d26396 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -94,9 +94,13 @@ async def continue_conversation( single tenant the Adapters (Console, Test, etc) but is critical to the BotFrameworkAdapter which is multi-tenant aware. :param reference: A reference to the conversation to continue. + :type reference: :class:`botbuilder.schema.ConversationReference` :param callback: The method to call for the resulting bot turn. - :param claims_identity: - :param audience: + :type callback: :class:`typing.Callable` + :param claims_identity: A :class:`botframework.connector.auth.ClaimsIdentity` for the conversation. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param audience:A value signifying the recipient of the proactive message. + :type audience: str """ context = TurnContext( self, conversation_reference_extension.get_continuation_activity(reference) @@ -113,7 +117,7 @@ async def run_pipeline( :param context: The context object for the turn. :type context: :class:`TurnContext` :param callback: A callback method to run at the end of the pipeline. - :type callbacK: :class:`typing.Callable[[TurnContext], Awaitable]` + :type callback: :class:`typing.Callable[[TurnContext], Awaitable]` :return: """ BotAssert.context_not_none(context) From 3308fb52632047065953c95011e0347d56b6bbb7 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 12:18:05 -0700 Subject: [PATCH 499/616] Update bot_adapter.py --- libraries/botbuilder-core/botbuilder/core/bot_adapter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 5c7d26396..25fa05351 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -13,6 +13,11 @@ class BotAdapter(ABC): + """ Represents a bot adapter that can connect a bot to a service endpoint. + + :var on_turn_error: Gets or sets an error handler that can catch exceptions. + :vartype on_turn_error: :class:`typing.Callable[[TurnContext, Exception], Awaitable]` + """ BOT_IDENTITY_KEY = "BotIdentity" BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" From 6578d79678a318aa4de581cd0868c3bd393a78ba Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 12:21:27 -0700 Subject: [PATCH 500/616] Update bot_adapter.py --- libraries/botbuilder-core/botbuilder/core/bot_adapter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 25fa05351..e99716d42 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -14,9 +14,6 @@ class BotAdapter(ABC): """ Represents a bot adapter that can connect a bot to a service endpoint. - - :var on_turn_error: Gets or sets an error handler that can catch exceptions. - :vartype on_turn_error: :class:`typing.Callable[[TurnContext, Exception], Awaitable]` """ BOT_IDENTITY_KEY = "BotIdentity" BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" From d5dba8883db309421c159181062537b5bbfcb869 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 26 Jun 2020 12:30:13 -0700 Subject: [PATCH 501/616] Update bot_adapter.py --- libraries/botbuilder-core/botbuilder/core/bot_adapter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index e99716d42..5c7d26396 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -13,8 +13,6 @@ class BotAdapter(ABC): - """ Represents a bot adapter that can connect a bot to a service endpoint. - """ BOT_IDENTITY_KEY = "BotIdentity" BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" From b81e03e4c3926a3ee7f9466992fc3be414553916 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 26 Jun 2020 18:19:41 -0300 Subject: [PATCH 502/616] add missing tests --- .../botbuilder/schema/_models_py3.py | 10 ++-- .../botbuilder-schema/tests/test_activity.py | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 0333d801c..b97c17b5d 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -667,9 +667,8 @@ def create_trace( :returns: The new trace activity. """ - if not value_type: - if value and hasattr(value, "type"): - value_type = value.type + if not value_type and value: + value_type = type(value) return Activity( type=ActivityTypes.trace, @@ -711,9 +710,8 @@ def create_trace_activity( :returns: The new trace activity. """ - if not value_type: - if value and hasattr(value, "type"): - value_type = value.type + if not value_type and value: + value_type = type(value) return Activity( type=ActivityTypes.trace, diff --git a/libraries/botbuilder-schema/tests/test_activity.py b/libraries/botbuilder-schema/tests/test_activity.py index 1e1276451..513ff06f9 100644 --- a/libraries/botbuilder-schema/tests/test_activity.py +++ b/libraries/botbuilder-schema/tests/test_activity.py @@ -296,6 +296,17 @@ def test_as_message_activity_return_none(self): # Assert self.assertIsNone(result) + def test_as_message_activity_type_none(self): + # Arrange + activity = self.__create_activity() + activity.type = None + + # Act + result = activity.as_message_activity() + + # Assert + self.assertIsNone(result) + def test_as_message_delete_activity_return_activity(self): # Arrange activity = self.__create_activity() @@ -491,6 +502,18 @@ def test_create_reply(self): self.assertEqual(result.locale, locale) self.assertEqual(result.type, ActivityTypes.message) + def test_create_reply_without_arguments(self): + # Arrange + activity = self.__create_activity() + + # Act + result = activity.create_reply() + + # Assert + self.assertEqual(result.type, ActivityTypes.message) + self.assertEqual(result.text, "") + self.assertEqual(result.locale, activity.locale) + def test_create_trace(self): # Arrange activity = self.__create_activity() @@ -511,6 +534,32 @@ def test_create_trace(self): self.assertEqual(result.value, value) self.assertEqual(result.label, label) + def test_create_trace_activity_no_recipient(self): + # Arrange + activity = self.__create_activity() + activity.recipient = None + + # Act + result = activity.create_trace("test") + + # Assert + self.assertIsNone(result.from_property.id) + self.assertIsNone(result.from_property.name) + + def test_crete_trace_activity_no_value_type(self): + # Arrange + name = "test-activity" + value = "test-value" + label = "test-label" + + # Act + result = Activity.create_trace_activity(name=name, value=value, label=label) + + # Assert + self.assertEqual(result.type, ActivityTypes.trace) + self.assertEqual(result.value_type, type(value)) + self.assertEqual(result.label, label) + def test_create_trace_activity(self): # Arrange name = "test-activity" From 91a7a14a43d2db78842463c5ecee3f9c5d93c40c Mon Sep 17 00:00:00 2001 From: Kyle Delaney Date: Fri, 26 Jun 2020 14:39:29 -0700 Subject: [PATCH 503/616] Fix doc comment for on_prompt --- .../botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index b385d2bc9..cf0a4123d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -213,7 +213,7 @@ async def on_prompt( :type state: :class:`Dict` :param options: A prompt options object constructed from:meth:`DialogContext.prompt()` :type options: :class:`PromptOptions` - :param is_retry: true if is the first time the user for input; otherwise, false + :param is_retry: Determines whether `prompt` or `retry_prompt` should be used :type is_retry: bool :return: A task representing the asynchronous operation. From 9d4b15140db469efc79bbca775c0abf2052b3ed9 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 29 Jun 2020 08:55:46 -0500 Subject: [PATCH 504/616] Added missing dialogs export in ai.qna --- libraries/botbuilder-ai/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index ce4aeff18..11cf15a35 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -39,6 +39,7 @@ "botbuilder.ai.luis", "botbuilder.ai.qna.models", "botbuilder.ai.qna.utils", + "botbuilder.ai.qna.dialogs", ], install_requires=REQUIRES + TESTS_REQUIRES, tests_require=TESTS_REQUIRES, From 036d8ffa755f98179f9f68e3039e51226a9fa668 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 30 Jun 2020 10:08:02 -0500 Subject: [PATCH 505/616] Added CardAction imageAltText --- libraries/botbuilder-schema/botbuilder/schema/_models_py3.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index b97c17b5d..c189454fa 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -1206,6 +1206,7 @@ class CardAction(Model): "display_text": {"key": "displayText", "type": "str"}, "value": {"key": "value", "type": "object"}, "channel_data": {"key": "channelData", "type": "object"}, + "image_alt_text": {"key": "imageAltText", "type": "str"}, } def __init__( @@ -1218,6 +1219,7 @@ def __init__( display_text: str = None, value=None, channel_data=None, + image_alt_text: str = None, **kwargs ) -> None: super(CardAction, self).__init__(**kwargs) @@ -1228,6 +1230,7 @@ def __init__( self.display_text = display_text self.value = value self.channel_data = channel_data + self.image_alt_text = image_alt_text class CardImage(Model): From 6c86ffbcda1706d738f08c6d53ffafa77dcd8e34 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 30 Jun 2020 10:10:24 -0500 Subject: [PATCH 506/616] Added imageAltText doc strings --- libraries/botbuilder-schema/botbuilder/schema/_models_py3.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index c189454fa..59ad34468 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -1196,6 +1196,8 @@ class CardAction(Model): :type value: object :param channel_data: Channel-specific data associated with this action :type channel_data: object + :param image_alt_text: Alternate image text to be used in place of the `image` field + :type image_alt_text: str """ _attribute_map = { From 46184ec27f3b0e941383e2e417b54fa2ed162c0b Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 30 Jun 2020 10:17:31 -0500 Subject: [PATCH 507/616] Removed Python V2 models, updated copyright on all schema code. --- .../botbuilder/schema/__init__.py | 159 +- .../schema/_connector_client_enums.py | 10 +- .../botbuilder/schema/_models.py | 1626 ---------------- .../botbuilder/schema/_models_py3.py | 10 +- .../botbuilder/schema/_sign_in_enums.py | 6 +- .../botbuilder/schema/callerid_constants.py | 7 +- .../botbuilder/schema/health_results.py | 6 +- .../botbuilder/schema/healthcheck_response.py | 6 +- .../botbuilder/schema/teams/__init__.py | 183 +- .../botbuilder/schema/teams/_models.py | 1643 ----------------- .../botbuilder/schema/teams/_models_py3.py | 10 +- .../schema/teams/additional_properties.py | 1 - 12 files changed, 117 insertions(+), 3550 deletions(-) delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/_models.py delete mode 100644 libraries/botbuilder-schema/botbuilder/schema/teams/_models.py diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index 97443cf28..d133a8db4 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -1,113 +1,56 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. -try: - from ._models_py3 import Activity - from ._models_py3 import AnimationCard - from ._models_py3 import Attachment - from ._models_py3 import AttachmentData - from ._models_py3 import AttachmentInfo - from ._models_py3 import AttachmentView - from ._models_py3 import AudioCard - from ._models_py3 import BasicCard - from ._models_py3 import CardAction - from ._models_py3 import CardImage - from ._models_py3 import ChannelAccount - from ._models_py3 import ConversationAccount - from ._models_py3 import ConversationMembers - from ._models_py3 import ConversationParameters - from ._models_py3 import ConversationReference - from ._models_py3 import ConversationResourceResponse - from ._models_py3 import ConversationsResult - from ._models_py3 import ExpectedReplies - from ._models_py3 import Entity - from ._models_py3 import Error - from ._models_py3 import ErrorResponse, ErrorResponseException - from ._models_py3 import Fact - from ._models_py3 import GeoCoordinates - from ._models_py3 import HeroCard - from ._models_py3 import InnerHttpError - from ._models_py3 import MediaCard - from ._models_py3 import MediaEventValue - from ._models_py3 import MediaUrl - from ._models_py3 import Mention - from ._models_py3 import MessageReaction - from ._models_py3 import OAuthCard - from ._models_py3 import PagedMembersResult - from ._models_py3 import Place - from ._models_py3 import ReceiptCard - from ._models_py3 import ReceiptItem - from ._models_py3 import ResourceResponse - from ._models_py3 import SemanticAction - from ._models_py3 import SigninCard - from ._models_py3 import SuggestedActions - from ._models_py3 import TextHighlight - from ._models_py3 import Thing - from ._models_py3 import ThumbnailCard - from ._models_py3 import ThumbnailUrl - from ._models_py3 import TokenExchangeInvokeRequest - from ._models_py3 import TokenExchangeInvokeResponse - from ._models_py3 import TokenExchangeState - from ._models_py3 import TokenRequest - from ._models_py3 import TokenResponse - from ._models_py3 import Transcript - from ._models_py3 import VideoCard -except (SyntaxError, ImportError): - from ._models import Activity - from ._models import AnimationCard - from ._models import Attachment - from ._models import AttachmentData - from ._models import AttachmentInfo - from ._models import AttachmentView - from ._models import AudioCard - from ._models import BasicCard - from ._models import CardAction - from ._models import CardImage - from ._models import ChannelAccount - from ._models import ConversationAccount - from ._models import ConversationMembers - from ._models import ConversationParameters - from ._models import ConversationReference - from ._models import ConversationResourceResponse - from ._models import ConversationsResult - from ._models import ExpectedReplies - from ._models import Entity - from ._models import Error - from ._models import ErrorResponse, ErrorResponseException - from ._models import Fact - from ._models import GeoCoordinates - from ._models import HeroCard - from ._models import InnerHttpError - from ._models import MediaCard - from ._models import MediaEventValue - from ._models import MediaUrl - from ._models import Mention - from ._models import MessageReaction - from ._models import OAuthCard - from ._models import PagedMembersResult - from ._models import Place - from ._models import ReceiptCard - from ._models import ReceiptItem - from ._models import ResourceResponse - from ._models import SemanticAction - from ._models import SigninCard - from ._models import SuggestedActions - from ._models import TextHighlight - from ._models import Thing - from ._models import ThumbnailCard - from ._models import ThumbnailUrl - from ._models import TokenRequest - from ._models import TokenResponse - from ._models import Transcript - from ._models import VideoCard +from ._models_py3 import Activity +from ._models_py3 import AnimationCard +from ._models_py3 import Attachment +from ._models_py3 import AttachmentData +from ._models_py3 import AttachmentInfo +from ._models_py3 import AttachmentView +from ._models_py3 import AudioCard +from ._models_py3 import BasicCard +from ._models_py3 import CardAction +from ._models_py3 import CardImage +from ._models_py3 import ChannelAccount +from ._models_py3 import ConversationAccount +from ._models_py3 import ConversationMembers +from ._models_py3 import ConversationParameters +from ._models_py3 import ConversationReference +from ._models_py3 import ConversationResourceResponse +from ._models_py3 import ConversationsResult +from ._models_py3 import ExpectedReplies +from ._models_py3 import Entity +from ._models_py3 import Error +from ._models_py3 import ErrorResponse, ErrorResponseException +from ._models_py3 import Fact +from ._models_py3 import GeoCoordinates +from ._models_py3 import HeroCard +from ._models_py3 import InnerHttpError +from ._models_py3 import MediaCard +from ._models_py3 import MediaEventValue +from ._models_py3 import MediaUrl +from ._models_py3 import Mention +from ._models_py3 import MessageReaction +from ._models_py3 import OAuthCard +from ._models_py3 import PagedMembersResult +from ._models_py3 import Place +from ._models_py3 import ReceiptCard +from ._models_py3 import ReceiptItem +from ._models_py3 import ResourceResponse +from ._models_py3 import SemanticAction +from ._models_py3 import SigninCard +from ._models_py3 import SuggestedActions +from ._models_py3 import TextHighlight +from ._models_py3 import Thing +from ._models_py3 import ThumbnailCard +from ._models_py3 import ThumbnailUrl +from ._models_py3 import TokenExchangeInvokeRequest +from ._models_py3 import TokenExchangeInvokeResponse +from ._models_py3 import TokenExchangeState +from ._models_py3 import TokenRequest +from ._models_py3 import TokenResponse +from ._models_py3 import Transcript +from ._models_py3 import VideoCard from ._connector_client_enums import ( ActionTypes, ActivityImportance, diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index d2027a277..46e7847e6 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -1,13 +1,5 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. from enum import Enum diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models.py b/libraries/botbuilder-schema/botbuilder/schema/_models.py deleted file mode 100644 index 2dabab91f..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/_models.py +++ /dev/null @@ -1,1626 +0,0 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- - -from msrest.serialization import Model -from msrest.exceptions import HttpOperationError - - -class Activity(Model): - """An Activity is the basic communication type for the Bot Framework 3.0 - protocol. - - :param type: Contains the activity type. Possible values include: - 'message', 'contactRelationUpdate', 'conversationUpdate', 'typing', - 'endOfConversation', 'event', 'invoke', 'deleteUserData', 'messageUpdate', - 'messageDelete', 'installationUpdate', 'messageReaction', 'suggestion', - 'trace', 'handoff' - :type type: str or ~botframework.connector.models.ActivityTypes - :param id: Contains an ID that uniquely identifies the activity on the - channel. - :type id: str - :param timestamp: Contains the date and time that the message was sent, in - UTC, expressed in ISO-8601 format. - :type timestamp: datetime - :param local_timestamp: Contains the local date and time of the message - expressed in ISO-8601 format. - For example, 2016-09-23T13:07:49.4714686-07:00. - :type local_timestamp: datetime - :param local_timezone: Contains the name of the local timezone of the message, - expressed in IANA Time Zone database format. - For example, America/Los_Angeles. - :type local_timezone: str - :param service_url: Contains the URL that specifies the channel's service - endpoint. Set by the channel. - :type service_url: str - :param channel_id: Contains an ID that uniquely identifies the channel. - Set by the channel. - :type channel_id: str - :param from_property: Identifies the sender of the message. - :type from_property: ~botframework.connector.models.ChannelAccount - :param conversation: Identifies the conversation to which the activity - belongs. - :type conversation: ~botframework.connector.models.ConversationAccount - :param recipient: Identifies the recipient of the message. - :type recipient: ~botframework.connector.models.ChannelAccount - :param text_format: Format of text fields Default:markdown. Possible - values include: 'markdown', 'plain', 'xml' - :type text_format: str or ~botframework.connector.models.TextFormatTypes - :param attachment_layout: The layout hint for multiple attachments. - Default: list. Possible values include: 'list', 'carousel' - :type attachment_layout: str or - ~botframework.connector.models.AttachmentLayoutTypes - :param members_added: The collection of members added to the conversation. - :type members_added: list[~botframework.connector.models.ChannelAccount] - :param members_removed: The collection of members removed from the - conversation. - :type members_removed: list[~botframework.connector.models.ChannelAccount] - :param reactions_added: The collection of reactions added to the - conversation. - :type reactions_added: - list[~botframework.connector.models.MessageReaction] - :param reactions_removed: The collection of reactions removed from the - conversation. - :type reactions_removed: - list[~botframework.connector.models.MessageReaction] - :param topic_name: The updated topic name of the conversation. - :type topic_name: str - :param history_disclosed: Indicates whether the prior history of the - channel is disclosed. - :type history_disclosed: bool - :param locale: A locale name for the contents of the text field. - The locale name is a combination of an ISO 639 two- or three-letter - culture code associated with a language - and an ISO 3166 two-letter subculture code associated with a country or - region. - The locale name can also correspond to a valid BCP-47 language tag. - :type locale: str - :param text: The text content of the message. - :type text: str - :param speak: The text to speak. - :type speak: str - :param input_hint: Indicates whether your bot is accepting, - expecting, or ignoring user input after the message is delivered to the - client. Possible values include: 'acceptingInput', 'ignoringInput', - 'expectingInput' - :type input_hint: str or ~botframework.connector.models.InputHints - :param summary: The text to display if the channel cannot render cards. - :type summary: str - :param suggested_actions: The suggested actions for the activity. - :type suggested_actions: ~botframework.connector.models.SuggestedActions - :param attachments: Attachments - :type attachments: list[~botframework.connector.models.Attachment] - :param entities: Represents the entities that were mentioned in the - message. - :type entities: list[~botframework.connector.models.Entity] - :param channel_data: Contains channel-specific content. - :type channel_data: object - :param action: Indicates whether the recipient of a contactRelationUpdate - was added or removed from the sender's contact list. - :type action: str - :param reply_to_id: Contains the ID of the message to which this message - is a reply. - :type reply_to_id: str - :param label: A descriptive label for the activity. - :type label: str - :param value_type: The type of the activity's value object. - :type value_type: str - :param value: A value that is associated with the activity. - :type value: object - :param name: The name of the operation associated with an invoke or event - activity. - :type name: str - :param relates_to: A reference to another conversation or activity. - :type relates_to: ~botframework.connector.models.ConversationReference - :param code: The a code for endOfConversation activities that indicates - why the conversation ended. Possible values include: 'unknown', - 'completedSuccessfully', 'userCancelled', 'botTimedOut', - 'botIssuedInvalidMessage', 'channelFailed' - :type code: str or ~botframework.connector.models.EndOfConversationCodes - :param expiration: The time at which the activity should be considered to - be "expired" and should not be presented to the recipient. - :type expiration: datetime - :param importance: The importance of the activity. Possible values - include: 'low', 'normal', 'high' - :type importance: str or ~botframework.connector.models.ActivityImportance - :param delivery_mode: A delivery hint to signal to the recipient alternate - delivery paths for the activity. - The default delivery mode is "default". Possible values include: 'normal', - 'notification', 'expectReplies', 'ephemeral' - :type delivery_mode: str or ~botframework.connector.models.DeliveryModes - :param listen_for: List of phrases and references that speech and language - priming systems should listen for - :type listen_for: list[str] - :param text_highlights: The collection of text fragments to highlight when - the activity contains a ReplyToId value. - :type text_highlights: list[~botframework.connector.models.TextHighlight] - :param semantic_action: An optional programmatic action accompanying this - request - :type semantic_action: ~botframework.connector.models.SemanticAction - :param caller_id: A string containing an IRI identifying the caller of a - bot. This field is not intended to be transmitted over the wire, but is - instead populated by bots and clients based on cryptographically - verifiable data that asserts the identity of the callers (e.g. tokens). - :type caller_id: str - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "timestamp": {"key": "timestamp", "type": "iso-8601"}, - "local_timestamp": {"key": "localTimestamp", "type": "iso-8601"}, - "local_timezone": {"key": "localTimezone", "type": "str"}, - "service_url": {"key": "serviceUrl", "type": "str"}, - "channel_id": {"key": "channelId", "type": "str"}, - "from_property": {"key": "from", "type": "ChannelAccount"}, - "conversation": {"key": "conversation", "type": "ConversationAccount"}, - "recipient": {"key": "recipient", "type": "ChannelAccount"}, - "text_format": {"key": "textFormat", "type": "str"}, - "attachment_layout": {"key": "attachmentLayout", "type": "str"}, - "members_added": {"key": "membersAdded", "type": "[ChannelAccount]"}, - "members_removed": {"key": "membersRemoved", "type": "[ChannelAccount]"}, - "reactions_added": {"key": "reactionsAdded", "type": "[MessageReaction]"}, - "reactions_removed": {"key": "reactionsRemoved", "type": "[MessageReaction]"}, - "topic_name": {"key": "topicName", "type": "str"}, - "history_disclosed": {"key": "historyDisclosed", "type": "bool"}, - "locale": {"key": "locale", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "speak": {"key": "speak", "type": "str"}, - "input_hint": {"key": "inputHint", "type": "str"}, - "summary": {"key": "summary", "type": "str"}, - "suggested_actions": {"key": "suggestedActions", "type": "SuggestedActions"}, - "attachments": {"key": "attachments", "type": "[Attachment]"}, - "entities": {"key": "entities", "type": "[Entity]"}, - "channel_data": {"key": "channelData", "type": "object"}, - "action": {"key": "action", "type": "str"}, - "reply_to_id": {"key": "replyToId", "type": "str"}, - "label": {"key": "label", "type": "str"}, - "value_type": {"key": "valueType", "type": "str"}, - "value": {"key": "value", "type": "object"}, - "name": {"key": "name", "type": "str"}, - "relates_to": {"key": "relatesTo", "type": "ConversationReference"}, - "code": {"key": "code", "type": "str"}, - "expiration": {"key": "expiration", "type": "iso-8601"}, - "importance": {"key": "importance", "type": "str"}, - "delivery_mode": {"key": "deliveryMode", "type": "str"}, - "listen_for": {"key": "listenFor", "type": "[str]"}, - "text_highlights": {"key": "textHighlights", "type": "[TextHighlight]"}, - "semantic_action": {"key": "semanticAction", "type": "SemanticAction"}, - "caller_id": {"key": "callerId", "type": "str"}, - } - - def __init__(self, **kwargs): - super(Activity, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - self.id = kwargs.get("id", None) - self.timestamp = kwargs.get("timestamp", None) - self.local_timestamp = kwargs.get("local_timestamp", None) - self.local_timezone = kwargs.get("local_timezone", None) - self.service_url = kwargs.get("service_url", None) - self.channel_id = kwargs.get("channel_id", None) - self.from_property = kwargs.get("from_property", None) - self.conversation = kwargs.get("conversation", None) - self.recipient = kwargs.get("recipient", None) - self.text_format = kwargs.get("text_format", None) - self.attachment_layout = kwargs.get("attachment_layout", None) - self.members_added = kwargs.get("members_added", None) - self.members_removed = kwargs.get("members_removed", None) - self.reactions_added = kwargs.get("reactions_added", None) - self.reactions_removed = kwargs.get("reactions_removed", None) - self.topic_name = kwargs.get("topic_name", None) - self.history_disclosed = kwargs.get("history_disclosed", None) - self.locale = kwargs.get("locale", None) - self.text = kwargs.get("text", None) - self.speak = kwargs.get("speak", None) - self.input_hint = kwargs.get("input_hint", None) - self.summary = kwargs.get("summary", None) - self.suggested_actions = kwargs.get("suggested_actions", None) - self.attachments = kwargs.get("attachments", None) - self.entities = kwargs.get("entities", None) - self.channel_data = kwargs.get("channel_data", None) - self.action = kwargs.get("action", None) - self.reply_to_id = kwargs.get("reply_to_id", None) - self.label = kwargs.get("label", None) - self.value_type = kwargs.get("value_type", None) - self.value = kwargs.get("value", None) - self.name = kwargs.get("name", None) - self.relates_to = kwargs.get("relates_to", None) - self.code = kwargs.get("code", None) - self.expiration = kwargs.get("expiration", None) - self.importance = kwargs.get("importance", None) - self.delivery_mode = kwargs.get("delivery_mode", None) - self.listen_for = kwargs.get("listen_for", None) - self.text_highlights = kwargs.get("text_highlights", None) - self.semantic_action = kwargs.get("semantic_action", None) - self.caller_id = kwargs.get("caller_id", None) - - -class AnimationCard(Model): - """An animation card (Ex: gif or short video clip). - - :param title: Title of this card - :type title: str - :param subtitle: Subtitle of this card - :type subtitle: str - :param text: Text of this card - :type text: str - :param image: Thumbnail placeholder - :type image: ~botframework.connector.models.ThumbnailUrl - :param media: Media URLs for this card. When this field contains more than - one URL, each URL is an alternative format of the same content. - :type media: list[~botframework.connector.models.MediaUrl] - :param buttons: Actions on this card - :type buttons: list[~botframework.connector.models.CardAction] - :param shareable: This content may be shared with others (default:true) - :type shareable: bool - :param autoloop: Should the client loop playback at end of content - (default:true) - :type autoloop: bool - :param autostart: Should the client automatically start playback of media - in this card (default:true) - :type autostart: bool - :param aspect: Aspect ratio of thumbnail/media placeholder. Allowed values - are "16:9" and "4:3" - :type aspect: str - :param duration: Describes the length of the media content without - requiring a receiver to open the content. Formatted as an ISO 8601 - Duration field. - :type duration: str - :param value: Supplementary parameter for this card - :type value: object - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "image": {"key": "image", "type": "ThumbnailUrl"}, - "media": {"key": "media", "type": "[MediaUrl]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "shareable": {"key": "shareable", "type": "bool"}, - "autoloop": {"key": "autoloop", "type": "bool"}, - "autostart": {"key": "autostart", "type": "bool"}, - "aspect": {"key": "aspect", "type": "str"}, - "duration": {"key": "duration", "type": "str"}, - "value": {"key": "value", "type": "object"}, - } - - def __init__(self, **kwargs): - super(AnimationCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.image = kwargs.get("image", None) - self.media = kwargs.get("media", None) - self.buttons = kwargs.get("buttons", None) - self.shareable = kwargs.get("shareable", None) - self.autoloop = kwargs.get("autoloop", None) - self.autostart = kwargs.get("autostart", None) - self.aspect = kwargs.get("aspect", None) - self.duration = kwargs.get("duration", None) - self.value = kwargs.get("value", None) - - -class Attachment(Model): - """An attachment within an activity. - - :param content_type: mimetype/Contenttype for the file - :type content_type: str - :param content_url: Content Url - :type content_url: str - :param content: Embedded content - :type content: object - :param name: (OPTIONAL) The name of the attachment - :type name: str - :param thumbnail_url: (OPTIONAL) Thumbnail associated with attachment - :type thumbnail_url: str - """ - - _attribute_map = { - "content_type": {"key": "contentType", "type": "str"}, - "content_url": {"key": "contentUrl", "type": "str"}, - "content": {"key": "content", "type": "object"}, - "name": {"key": "name", "type": "str"}, - "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, - } - - def __init__(self, **kwargs): - super(Attachment, self).__init__(**kwargs) - self.content_type = kwargs.get("content_type", None) - self.content_url = kwargs.get("content_url", None) - self.content = kwargs.get("content", None) - self.name = kwargs.get("name", None) - self.thumbnail_url = kwargs.get("thumbnail_url", None) - - -class AttachmentData(Model): - """Attachment data. - - :param type: Content-Type of the attachment - :type type: str - :param name: Name of the attachment - :type name: str - :param original_base64: Attachment content - :type original_base64: bytearray - :param thumbnail_base64: Attachment thumbnail - :type thumbnail_base64: bytearray - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "original_base64": {"key": "originalBase64", "type": "bytearray"}, - "thumbnail_base64": {"key": "thumbnailBase64", "type": "bytearray"}, - } - - def __init__(self, **kwargs): - super(AttachmentData, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - self.name = kwargs.get("name", None) - self.original_base64 = kwargs.get("original_base64", None) - self.thumbnail_base64 = kwargs.get("thumbnail_base64", None) - - -class AttachmentInfo(Model): - """Metadata for an attachment. - - :param name: Name of the attachment - :type name: str - :param type: ContentType of the attachment - :type type: str - :param views: attachment views - :type views: list[~botframework.connector.models.AttachmentView] - """ - - _attribute_map = { - "name": {"key": "name", "type": "str"}, - "type": {"key": "type", "type": "str"}, - "views": {"key": "views", "type": "[AttachmentView]"}, - } - - def __init__(self, **kwargs): - super(AttachmentInfo, self).__init__(**kwargs) - self.name = kwargs.get("name", None) - self.type = kwargs.get("type", None) - self.views = kwargs.get("views", None) - - -class AttachmentView(Model): - """Attachment View name and size. - - :param view_id: Id of the attachment - :type view_id: str - :param size: Size of the attachment - :type size: int - """ - - _attribute_map = { - "view_id": {"key": "viewId", "type": "str"}, - "size": {"key": "size", "type": "int"}, - } - - def __init__(self, **kwargs): - super(AttachmentView, self).__init__(**kwargs) - self.view_id = kwargs.get("view_id", None) - self.size = kwargs.get("size", None) - - -class AudioCard(Model): - """Audio card. - - :param title: Title of this card - :type title: str - :param subtitle: Subtitle of this card - :type subtitle: str - :param text: Text of this card - :type text: str - :param image: Thumbnail placeholder - :type image: ~botframework.connector.models.ThumbnailUrl - :param media: Media URLs for this card. When this field contains more than - one URL, each URL is an alternative format of the same content. - :type media: list[~botframework.connector.models.MediaUrl] - :param buttons: Actions on this card - :type buttons: list[~botframework.connector.models.CardAction] - :param shareable: This content may be shared with others (default:true) - :type shareable: bool - :param autoloop: Should the client loop playback at end of content - (default:true) - :type autoloop: bool - :param autostart: Should the client automatically start playback of media - in this card (default:true) - :type autostart: bool - :param aspect: Aspect ratio of thumbnail/media placeholder. Allowed values - are "16:9" and "4:3" - :type aspect: str - :param duration: Describes the length of the media content without - requiring a receiver to open the content. Formatted as an ISO 8601 - Duration field. - :type duration: str - :param value: Supplementary parameter for this card - :type value: object - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "image": {"key": "image", "type": "ThumbnailUrl"}, - "media": {"key": "media", "type": "[MediaUrl]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "shareable": {"key": "shareable", "type": "bool"}, - "autoloop": {"key": "autoloop", "type": "bool"}, - "autostart": {"key": "autostart", "type": "bool"}, - "aspect": {"key": "aspect", "type": "str"}, - "duration": {"key": "duration", "type": "str"}, - "value": {"key": "value", "type": "object"}, - } - - def __init__(self, **kwargs): - super(AudioCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.image = kwargs.get("image", None) - self.media = kwargs.get("media", None) - self.buttons = kwargs.get("buttons", None) - self.shareable = kwargs.get("shareable", None) - self.autoloop = kwargs.get("autoloop", None) - self.autostart = kwargs.get("autostart", None) - self.aspect = kwargs.get("aspect", None) - self.duration = kwargs.get("duration", None) - self.value = kwargs.get("value", None) - - -class BasicCard(Model): - """A basic card. - - :param title: Title of the card - :type title: str - :param subtitle: Subtitle of the card - :type subtitle: str - :param text: Text for the card - :type text: str - :param images: Array of images for the card - :type images: list[~botframework.connector.models.CardImage] - :param buttons: Set of actions applicable to the current card - :type buttons: list[~botframework.connector.models.CardAction] - :param tap: This action will be activated when user taps on the card - itself - :type tap: ~botframework.connector.models.CardAction - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "images": {"key": "images", "type": "[CardImage]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "tap": {"key": "tap", "type": "CardAction"}, - } - - def __init__(self, **kwargs): - super(BasicCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.images = kwargs.get("images", None) - self.buttons = kwargs.get("buttons", None) - self.tap = kwargs.get("tap", None) - - -class CardAction(Model): - """A clickable action. - - :param type: The type of action implemented by this button. Possible - values include: 'openUrl', 'imBack', 'postBack', 'playAudio', 'playVideo', - 'showImage', 'downloadFile', 'signin', 'call', 'messageBack' - :type type: str or ~botframework.connector.models.ActionTypes - :param title: Text description which appears on the button - :type title: str - :param image: Image URL which will appear on the button, next to text - label - :type image: str - :param text: Text for this action - :type text: str - :param display_text: (Optional) text to display in the chat feed if the - button is clicked - :type display_text: str - :param value: Supplementary parameter for action. Content of this property - depends on the ActionType - :type value: object - :param channel_data: Channel-specific data associated with this action - :type channel_data: object - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - "title": {"key": "title", "type": "str"}, - "image": {"key": "image", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "display_text": {"key": "displayText", "type": "str"}, - "value": {"key": "value", "type": "object"}, - "channel_data": {"key": "channelData", "type": "object"}, - } - - def __init__(self, **kwargs): - super(CardAction, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - self.title = kwargs.get("title", None) - self.image = kwargs.get("image", None) - self.text = kwargs.get("text", None) - self.display_text = kwargs.get("display_text", None) - self.value = kwargs.get("value", None) - self.channel_data = kwargs.get("channel_data", None) - - -class CardImage(Model): - """An image on a card. - - :param url: URL thumbnail image for major content property - :type url: str - :param alt: Image description intended for screen readers - :type alt: str - :param tap: Action assigned to specific Attachment - :type tap: ~botframework.connector.models.CardAction - """ - - _attribute_map = { - "url": {"key": "url", "type": "str"}, - "alt": {"key": "alt", "type": "str"}, - "tap": {"key": "tap", "type": "CardAction"}, - } - - def __init__(self, **kwargs): - super(CardImage, self).__init__(**kwargs) - self.url = kwargs.get("url", None) - self.alt = kwargs.get("alt", None) - self.tap = kwargs.get("tap", None) - - -class ChannelAccount(Model): - """Channel account information needed to route a message. - - :param id: Channel id for the user or bot on this channel (Example: - joe@smith.com, or @joesmith or 123456) - :type id: str - :param name: Display friendly name - :type name: str - :param aad_object_id: This account's object ID within Azure Active - Directory (AAD) - :type aad_object_id: str - :param role: Role of the entity behind the account (Example: User, Bot, - etc.). Possible values include: 'user', 'bot' - :type role: str or ~botframework.connector.models.RoleTypes - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "aad_object_id": {"key": "aadObjectId", "type": "str"}, - "role": {"key": "role", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ChannelAccount, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.name = kwargs.get("name", None) - self.aad_object_id = kwargs.get("aadObjectId", None) - self.role = kwargs.get("role", None) - - -class ConversationAccount(Model): - """Conversation account represents the identity of the conversation within a channel. - - :param is_group: Indicates whether the conversation contains more than two - participants at the time the activity was generated - :type is_group: bool - :param conversation_type: Indicates the type of the conversation in - channels that distinguish between conversation types - :type conversation_type: str - :param id: Channel id for the user or bot on this channel (Example: - joe@smith.com, or @joesmith or 123456) - :type id: str - :param name: Display friendly name - :type name: str - :param aad_object_id: This account's object ID within Azure Active - Directory (AAD) - :type aad_object_id: str - :param role: Role of the entity behind the account (Example: User, Bot, - etc.). Possible values include: 'user', 'bot' - :type role: str or ~botframework.connector.models.RoleTypes - :param tenant_id: This conversation's tenant ID - :type tenant_id: str - """ - - _attribute_map = { - "is_group": {"key": "isGroup", "type": "bool"}, - "conversation_type": {"key": "conversationType", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "aad_object_id": {"key": "aadObjectId", "type": "str"}, - "role": {"key": "role", "type": "str"}, - "tenant_id": {"key": "tenantID", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ConversationAccount, self).__init__(**kwargs) - self.is_group = kwargs.get("is_group", None) - self.conversation_type = kwargs.get("conversation_type", None) - self.id = kwargs.get("id", None) - self.name = kwargs.get("name", None) - self.aad_object_id = kwargs.get("aad_object_id", None) - self.role = kwargs.get("role", None) - self.tenant_id = kwargs.get("tenant_id", None) - - -class ConversationMembers(Model): - """Conversation and its members. - - :param id: Conversation ID - :type id: str - :param members: List of members in this conversation - :type members: list[~botframework.connector.models.ChannelAccount] - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "members": {"key": "members", "type": "[ChannelAccount]"}, - } - - def __init__(self, **kwargs): - super(ConversationMembers, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.members = kwargs.get("members", None) - - -class ConversationParameters(Model): - """Parameters for creating a new conversation. - - :param is_group: IsGroup - :type is_group: bool - :param bot: The bot address for this conversation - :type bot: ~botframework.connector.models.ChannelAccount - :param members: Members to add to the conversation - :type members: list[~botframework.connector.models.ChannelAccount] - :param topic_name: (Optional) Topic of the conversation (if supported by - the channel) - :type topic_name: str - :param activity: (Optional) When creating a new conversation, use this - activity as the initial message to the conversation - :type activity: ~botframework.connector.models.Activity - :param channel_data: Channel specific payload for creating the - conversation - :type channel_data: object - :param tenant_id: (Optional) The tenant ID in which the conversation should be created - :type tenant_id: str - """ - - _attribute_map = { - "is_group": {"key": "isGroup", "type": "bool"}, - "bot": {"key": "bot", "type": "ChannelAccount"}, - "members": {"key": "members", "type": "[ChannelAccount]"}, - "topic_name": {"key": "topicName", "type": "str"}, - "activity": {"key": "activity", "type": "Activity"}, - "channel_data": {"key": "channelData", "type": "object"}, - "tenant_id": {"key": "tenantID", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ConversationParameters, self).__init__(**kwargs) - self.is_group = kwargs.get("is_group", None) - self.bot = kwargs.get("bot", None) - self.members = kwargs.get("members", None) - self.topic_name = kwargs.get("topic_name", None) - self.activity = kwargs.get("activity", None) - self.channel_data = kwargs.get("channel_data", None) - self.tenant_id = kwargs.get("tenant_id", None) - - -class ConversationReference(Model): - """An object relating to a particular point in a conversation. - - :param activity_id: (Optional) ID of the activity to refer to - :type activity_id: str - :param user: (Optional) User participating in this conversation - :type user: ~botframework.connector.models.ChannelAccount - :param bot: Bot participating in this conversation - :type bot: ~botframework.connector.models.ChannelAccount - :param conversation: Conversation reference - :type conversation: ~botframework.connector.models.ConversationAccount - :param channel_id: Channel ID - :type channel_id: str - :param locale: A locale name for the contents of the text field. - The locale name is a combination of an ISO 639 two- or three-letter - culture code associated with a language and an ISO 3166 two-letter - subculture code associated with a country or region. - The locale name can also correspond to a valid BCP-47 language tag. - :type locale: str - :param service_url: Service endpoint where operations concerning the - referenced conversation may be performed - :type service_url: str - """ - - _attribute_map = { - "activity_id": {"key": "activityId", "type": "str"}, - "user": {"key": "user", "type": "ChannelAccount"}, - "bot": {"key": "bot", "type": "ChannelAccount"}, - "conversation": {"key": "conversation", "type": "ConversationAccount"}, - "channel_id": {"key": "channelId", "type": "str"}, - "locale": {"key": "locale", "type": "str"}, - "service_url": {"key": "serviceUrl", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ConversationReference, self).__init__(**kwargs) - self.activity_id = kwargs.get("activity_id", None) - self.user = kwargs.get("user", None) - self.bot = kwargs.get("bot", None) - self.conversation = kwargs.get("conversation", None) - self.channel_id = kwargs.get("channel_id", None) - self.locale = kwargs.get("locale", None) - self.service_url = kwargs.get("service_url", None) - - -class ConversationResourceResponse(Model): - """A response containing a resource. - - :param activity_id: ID of the Activity (if sent) - :type activity_id: str - :param service_url: Service endpoint where operations concerning the - conversation may be performed - :type service_url: str - :param id: Id of the resource - :type id: str - """ - - _attribute_map = { - "activity_id": {"key": "activityId", "type": "str"}, - "service_url": {"key": "serviceUrl", "type": "str"}, - "id": {"key": "id", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ConversationResourceResponse, self).__init__(**kwargs) - self.activity_id = kwargs.get("activity_id", None) - self.service_url = kwargs.get("service_url", None) - self.id = kwargs.get("id", None) - - -class ConversationsResult(Model): - """Conversations result. - - :param continuation_token: Paging token - :type continuation_token: str - :param conversations: List of conversations - :type conversations: - list[~botframework.connector.models.ConversationMembers] - """ - - _attribute_map = { - "continuation_token": {"key": "continuationToken", "type": "str"}, - "conversations": {"key": "conversations", "type": "[ConversationMembers]"}, - } - - def __init__(self, **kwargs): - super(ConversationsResult, self).__init__(**kwargs) - self.continuation_token = kwargs.get("continuation_token", None) - self.conversations = kwargs.get("conversations", None) - - -class ExpectedReplies(Model): - """ExpectedReplies. - - :param activities: A collection of Activities that conforms to the - ExpectedReplies schema. - :type activities: list[~botframework.connector.models.Activity] - """ - - _attribute_map = {"activities": {"key": "activities", "type": "[Activity]"}} - - def __init__(self, **kwargs): - super(ExpectedReplies, self).__init__(**kwargs) - self.activities = kwargs.get("activities", None) - - -class Entity(Model): - """Metadata object pertaining to an activity. - - :param type: Type of this entity (RFC 3987 IRI) - :type type: str - """ - - _attribute_map = {"type": {"key": "type", "type": "str"}} - - def __init__(self, **kwargs): - super(Entity, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - - -class Error(Model): - """Object representing error information. - - :param code: Error code - :type code: str - :param message: Error message - :type message: str - :param inner_http_error: Error from inner http call - :type inner_http_error: ~botframework.connector.models.InnerHttpError - """ - - _attribute_map = { - "code": {"key": "code", "type": "str"}, - "message": {"key": "message", "type": "str"}, - "inner_http_error": {"key": "innerHttpError", "type": "InnerHttpError"}, - } - - def __init__(self, **kwargs): - super(Error, self).__init__(**kwargs) - self.code = kwargs.get("code", None) - self.message = kwargs.get("message", None) - self.inner_http_error = kwargs.get("inner_http_error", None) - - -class ErrorResponse(Model): - """An HTTP API response. - - :param error: Error message - :type error: ~botframework.connector.models.Error - """ - - _attribute_map = {"error": {"key": "error", "type": "Error"}} - - def __init__(self, **kwargs): - super(ErrorResponse, self).__init__(**kwargs) - self.error = kwargs.get("error", None) - - -class ErrorResponseException(HttpOperationError): - """Server responsed with exception of type: 'ErrorResponse'. - - :param deserialize: A deserializer - :param response: Server response to be deserialized. - """ - - def __init__(self, deserialize, response, *args): - - super(ErrorResponseException, self).__init__( - deserialize, response, "ErrorResponse", *args - ) - - -class Fact(Model): - """Set of key-value pairs. Advantage of this section is that key and value - properties will be - rendered with default style information with some delimiter between them. - So there is no need for developer to specify style information. - - :param key: The key for this Fact - :type key: str - :param value: The value for this Fact - :type value: str - """ - - _attribute_map = { - "key": {"key": "key", "type": "str"}, - "value": {"key": "value", "type": "str"}, - } - - def __init__(self, **kwargs): - super(Fact, self).__init__(**kwargs) - self.key = kwargs.get("key", None) - self.value = kwargs.get("value", None) - - -class GeoCoordinates(Model): - """GeoCoordinates (entity type: "https://schema.org/GeoCoordinates"). - - :param elevation: Elevation of the location [WGS - 84](https://en.wikipedia.org/wiki/World_Geodetic_System) - :type elevation: float - :param latitude: Latitude of the location [WGS - 84](https://en.wikipedia.org/wiki/World_Geodetic_System) - :type latitude: float - :param longitude: Longitude of the location [WGS - 84](https://en.wikipedia.org/wiki/World_Geodetic_System) - :type longitude: float - :param type: The type of the thing - :type type: str - :param name: The name of the thing - :type name: str - """ - - _attribute_map = { - "elevation": {"key": "elevation", "type": "float"}, - "latitude": {"key": "latitude", "type": "float"}, - "longitude": {"key": "longitude", "type": "float"}, - "type": {"key": "type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - } - - def __init__(self, **kwargs): - super(GeoCoordinates, self).__init__(**kwargs) - self.elevation = kwargs.get("elevation", None) - self.latitude = kwargs.get("latitude", None) - self.longitude = kwargs.get("longitude", None) - self.type = kwargs.get("type", None) - self.name = kwargs.get("name", None) - - -class HeroCard(Model): - """A Hero card (card with a single, large image). - - :param title: Title of the card - :type title: str - :param subtitle: Subtitle of the card - :type subtitle: str - :param text: Text for the card - :type text: str - :param images: Array of images for the card - :type images: list[~botframework.connector.models.CardImage] - :param buttons: Set of actions applicable to the current card - :type buttons: list[~botframework.connector.models.CardAction] - :param tap: This action will be activated when user taps on the card - itself - :type tap: ~botframework.connector.models.CardAction - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "images": {"key": "images", "type": "[CardImage]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "tap": {"key": "tap", "type": "CardAction"}, - } - - def __init__(self, **kwargs): - super(HeroCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.images = kwargs.get("images", None) - self.buttons = kwargs.get("buttons", None) - self.tap = kwargs.get("tap", None) - - -class InnerHttpError(Model): - """Object representing inner http error. - - :param status_code: HttpStatusCode from failed request - :type status_code: int - :param body: Body from failed request - :type body: object - """ - - _attribute_map = { - "status_code": {"key": "statusCode", "type": "int"}, - "body": {"key": "body", "type": "object"}, - } - - def __init__(self, **kwargs): - super(InnerHttpError, self).__init__(**kwargs) - self.status_code = kwargs.get("status_code", None) - self.body = kwargs.get("body", None) - - -class MediaCard(Model): - """Media card. - - :param title: Title of this card - :type title: str - :param subtitle: Subtitle of this card - :type subtitle: str - :param text: Text of this card - :type text: str - :param image: Thumbnail placeholder - :type image: ~botframework.connector.models.ThumbnailUrl - :param media: Media URLs for this card. When this field contains more than - one URL, each URL is an alternative format of the same content. - :type media: list[~botframework.connector.models.MediaUrl] - :param buttons: Actions on this card - :type buttons: list[~botframework.connector.models.CardAction] - :param shareable: This content may be shared with others (default:true) - :type shareable: bool - :param autoloop: Should the client loop playback at end of content - (default:true) - :type autoloop: bool - :param autostart: Should the client automatically start playback of media - in this card (default:true) - :type autostart: bool - :param aspect: Aspect ratio of thumbnail/media placeholder. Allowed values - are "16:9" and "4:3" - :type aspect: str - :param duration: Describes the length of the media content without - requiring a receiver to open the content. Formatted as an ISO 8601 - Duration field. - :type duration: str - :param value: Supplementary parameter for this card - :type value: object - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "image": {"key": "image", "type": "ThumbnailUrl"}, - "media": {"key": "media", "type": "[MediaUrl]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "shareable": {"key": "shareable", "type": "bool"}, - "autoloop": {"key": "autoloop", "type": "bool"}, - "autostart": {"key": "autostart", "type": "bool"}, - "aspect": {"key": "aspect", "type": "str"}, - "duration": {"key": "duration", "type": "str"}, - "value": {"key": "value", "type": "object"}, - } - - def __init__(self, **kwargs): - super(MediaCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.image = kwargs.get("image", None) - self.media = kwargs.get("media", None) - self.buttons = kwargs.get("buttons", None) - self.shareable = kwargs.get("shareable", None) - self.autoloop = kwargs.get("autoloop", None) - self.autostart = kwargs.get("autostart", None) - self.aspect = kwargs.get("aspect", None) - self.duration = kwargs.get("duration", None) - self.value = kwargs.get("value", None) - - -class MediaEventValue(Model): - """Supplementary parameter for media events. - - :param card_value: Callback parameter specified in the Value field of the - MediaCard that originated this event - :type card_value: object - """ - - _attribute_map = {"card_value": {"key": "cardValue", "type": "object"}} - - def __init__(self, **kwargs): - super(MediaEventValue, self).__init__(**kwargs) - self.card_value = kwargs.get("card_value", None) - - -class MediaUrl(Model): - """Media URL. - - :param url: Url for the media - :type url: str - :param profile: Optional profile hint to the client to differentiate - multiple MediaUrl objects from each other - :type profile: str - """ - - _attribute_map = { - "url": {"key": "url", "type": "str"}, - "profile": {"key": "profile", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MediaUrl, self).__init__(**kwargs) - self.url = kwargs.get("url", None) - self.profile = kwargs.get("profile", None) - - -class Mention(Model): - """Mention information (entity type: "mention"). - - :param mentioned: The mentioned user - :type mentioned: ~botframework.connector.models.ChannelAccount - :param text: Sub Text which represents the mention (can be null or empty) - :type text: str - :param type: Type of this entity (RFC 3987 IRI) - :type type: str - """ - - _attribute_map = { - "mentioned": {"key": "mentioned", "type": "ChannelAccount"}, - "text": {"key": "text", "type": "str"}, - "type": {"key": "type", "type": "str"}, - } - - def __init__(self, **kwargs): - super(Mention, self).__init__(**kwargs) - self.mentioned = kwargs.get("mentioned", None) - self.text = kwargs.get("text", None) - self.type = kwargs.get("type", None) - - -class MessageReaction(Model): - """Message reaction object. - - :param type: Message reaction type. Possible values include: 'like', - 'plusOne' - :type type: str or ~botframework.connector.models.MessageReactionTypes - """ - - _attribute_map = {"type": {"key": "type", "type": "str"}} - - def __init__(self, **kwargs): - super(MessageReaction, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - - -class OAuthCard(Model): - """A card representing a request to perform a sign in via OAuth. - - :param text: Text for signin request - :type text: str - :param connection_name: The name of the registered connection - :type connection_name: str - :param buttons: Action to use to perform signin - :type buttons: list[~botframework.connector.models.CardAction] - """ - - _attribute_map = { - "text": {"key": "text", "type": "str"}, - "connection_name": {"key": "connectionName", "type": "str"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - } - - def __init__(self, **kwargs): - super(OAuthCard, self).__init__(**kwargs) - self.text = kwargs.get("text", None) - self.connection_name = kwargs.get("connection_name", None) - self.buttons = kwargs.get("buttons", None) - - -class PagedMembersResult(Model): - """Page of members. - - :param continuation_token: Paging token - :type continuation_token: str - :param members: The Channel Accounts. - :type members: list[~botframework.connector.models.ChannelAccount] - """ - - _attribute_map = { - "continuation_token": {"key": "continuationToken", "type": "str"}, - "members": {"key": "members", "type": "[ChannelAccount]"}, - } - - def __init__(self, **kwargs): - super(PagedMembersResult, self).__init__(**kwargs) - self.continuation_token = kwargs.get("continuation_token", None) - self.members = kwargs.get("members", None) - - -class Place(Model): - """Place (entity type: "https://schema.org/Place"). - - :param address: Address of the place (may be `string` or complex object of - type `PostalAddress`) - :type address: object - :param geo: Geo coordinates of the place (may be complex object of type - `GeoCoordinates` or `GeoShape`) - :type geo: object - :param has_map: Map to the place (may be `string` (URL) or complex object - of type `Map`) - :type has_map: object - :param type: The type of the thing - :type type: str - :param name: The name of the thing - :type name: str - """ - - _attribute_map = { - "address": {"key": "address", "type": "object"}, - "geo": {"key": "geo", "type": "object"}, - "has_map": {"key": "hasMap", "type": "object"}, - "type": {"key": "type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - } - - def __init__(self, **kwargs): - super(Place, self).__init__(**kwargs) - self.address = kwargs.get("address", None) - self.geo = kwargs.get("geo", None) - self.has_map = kwargs.get("has_map", None) - self.type = kwargs.get("type", None) - self.name = kwargs.get("name", None) - - -class ReceiptCard(Model): - """A receipt card. - - :param title: Title of the card - :type title: str - :param facts: Array of Fact objects - :type facts: list[~botframework.connector.models.Fact] - :param items: Array of Receipt Items - :type items: list[~botframework.connector.models.ReceiptItem] - :param tap: This action will be activated when user taps on the card - :type tap: ~botframework.connector.models.CardAction - :param total: Total amount of money paid (or to be paid) - :type total: str - :param tax: Total amount of tax paid (or to be paid) - :type tax: str - :param vat: Total amount of VAT paid (or to be paid) - :type vat: str - :param buttons: Set of actions applicable to the current card - :type buttons: list[~botframework.connector.models.CardAction] - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "facts": {"key": "facts", "type": "[Fact]"}, - "items": {"key": "items", "type": "[ReceiptItem]"}, - "tap": {"key": "tap", "type": "CardAction"}, - "total": {"key": "total", "type": "str"}, - "tax": {"key": "tax", "type": "str"}, - "vat": {"key": "vat", "type": "str"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - } - - def __init__(self, **kwargs): - super(ReceiptCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.facts = kwargs.get("facts", None) - self.items = kwargs.get("items", None) - self.tap = kwargs.get("tap", None) - self.total = kwargs.get("total", None) - self.tax = kwargs.get("tax", None) - self.vat = kwargs.get("vat", None) - self.buttons = kwargs.get("buttons", None) - - -class ReceiptItem(Model): - """An item on a receipt card. - - :param title: Title of the Card - :type title: str - :param subtitle: Subtitle appears just below Title field, differs from - Title in font styling only - :type subtitle: str - :param text: Text field appears just below subtitle, differs from Subtitle - in font styling only - :type text: str - :param image: Image - :type image: ~botframework.connector.models.CardImage - :param price: Amount with currency - :type price: str - :param quantity: Number of items of given kind - :type quantity: str - :param tap: This action will be activated when user taps on the Item - bubble. - :type tap: ~botframework.connector.models.CardAction - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "image": {"key": "image", "type": "CardImage"}, - "price": {"key": "price", "type": "str"}, - "quantity": {"key": "quantity", "type": "str"}, - "tap": {"key": "tap", "type": "CardAction"}, - } - - def __init__(self, **kwargs): - super(ReceiptItem, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.image = kwargs.get("image", None) - self.price = kwargs.get("price", None) - self.quantity = kwargs.get("quantity", None) - self.tap = kwargs.get("tap", None) - - -class ResourceResponse(Model): - """A response containing a resource ID. - - :param id: Id of the resource - :type id: str - """ - - _attribute_map = {"id": {"key": "id", "type": "str"}} - - def __init__(self, **kwargs): - super(ResourceResponse, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - - -class SemanticAction(Model): - """Represents a reference to a programmatic action. - - :param id: ID of this action - :type id: str - :param entities: Entities associated with this action - :type entities: dict[str, ~botframework.connector.models.Entity] - :param state: State of this action. Allowed values: `start`, `continue`, `done` - :type state: str or ~botframework.connector.models.SemanticActionStates - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "entities": {"key": "entities", "type": "{Entity}"}, - "state": {"key": "state", "type": "str"}, - } - - def __init__(self, **kwargs): - super(SemanticAction, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.entities = kwargs.get("entities", None) - self.state = kwargs.get("state", None) - - -class SigninCard(Model): - """A card representing a request to sign in. - - :param text: Text for signin request - :type text: str - :param buttons: Action to use to perform signin - :type buttons: list[~botframework.connector.models.CardAction] - """ - - _attribute_map = { - "text": {"key": "text", "type": "str"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - } - - def __init__(self, **kwargs): - super(SigninCard, self).__init__(**kwargs) - self.text = kwargs.get("text", None) - self.buttons = kwargs.get("buttons", None) - - -class SuggestedActions(Model): - """SuggestedActions that can be performed. - - :param to: Ids of the recipients that the actions should be shown to. - These Ids are relative to the channelId and a subset of all recipients of - the activity - :type to: list[str] - :param actions: Actions that can be shown to the user - :type actions: list[~botframework.connector.models.CardAction] - """ - - _attribute_map = { - "to": {"key": "to", "type": "[str]"}, - "actions": {"key": "actions", "type": "[CardAction]"}, - } - - def __init__(self, **kwargs): - super(SuggestedActions, self).__init__(**kwargs) - self.to = kwargs.get("to", None) - self.actions = kwargs.get("actions", None) - - -class TextHighlight(Model): - """Refers to a substring of content within another field. - - :param text: Defines the snippet of text to highlight - :type text: str - :param occurrence: Occurrence of the text field within the referenced - text, if multiple exist. - :type occurrence: int - """ - - _attribute_map = { - "text": {"key": "text", "type": "str"}, - "occurrence": {"key": "occurrence", "type": "int"}, - } - - def __init__(self, **kwargs): - super(TextHighlight, self).__init__(**kwargs) - self.text = kwargs.get("text", None) - self.occurrence = kwargs.get("occurrence", None) - - -class Thing(Model): - """Thing (entity type: "https://schema.org/Thing"). - - :param type: The type of the thing - :type type: str - :param name: The name of the thing - :type name: str - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - } - - def __init__(self, **kwargs): - super(Thing, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - self.name = kwargs.get("name", None) - - -class ThumbnailCard(Model): - """A thumbnail card (card with a single, small thumbnail image). - - :param title: Title of the card - :type title: str - :param subtitle: Subtitle of the card - :type subtitle: str - :param text: Text for the card - :type text: str - :param images: Array of images for the card - :type images: list[~botframework.connector.models.CardImage] - :param buttons: Set of actions applicable to the current card - :type buttons: list[~botframework.connector.models.CardAction] - :param tap: This action will be activated when user taps on the card - itself - :type tap: ~botframework.connector.models.CardAction - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "images": {"key": "images", "type": "[CardImage]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "tap": {"key": "tap", "type": "CardAction"}, - } - - def __init__(self, **kwargs): - super(ThumbnailCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.images = kwargs.get("images", None) - self.buttons = kwargs.get("buttons", None) - self.tap = kwargs.get("tap", None) - - -class ThumbnailUrl(Model): - """Thumbnail URL. - - :param url: URL pointing to the thumbnail to use for media content - :type url: str - :param alt: HTML alt text to include on this thumbnail image - :type alt: str - """ - - _attribute_map = { - "url": {"key": "url", "type": "str"}, - "alt": {"key": "alt", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ThumbnailUrl, self).__init__(**kwargs) - self.url = kwargs.get("url", None) - self.alt = kwargs.get("alt", None) - - -class TokenRequest(Model): - """A request to receive a user token. - - :param provider: The provider to request a user token from - :type provider: str - :param settings: A collection of settings for the specific provider for - this request - :type settings: dict[str, object] - """ - - _attribute_map = { - "provider": {"key": "provider", "type": "str"}, - "settings": {"key": "settings", "type": "{object}"}, - } - - def __init__(self, **kwargs): - super(TokenRequest, self).__init__(**kwargs) - self.provider = kwargs.get("provider", None) - self.settings = kwargs.get("settings", None) - - -class TokenResponse(Model): - """A response that includes a user token. - - :param connection_name: The connection name - :type connection_name: str - :param token: The user token - :type token: str - :param expiration: Expiration for the token, in ISO 8601 format (e.g. - "2007-04-05T14:30Z") - :type expiration: str - :param channel_id: The channelId of the TokenResponse - :type channel_id: str - """ - - _attribute_map = { - "connection_name": {"key": "connectionName", "type": "str"}, - "token": {"key": "token", "type": "str"}, - "expiration": {"key": "expiration", "type": "str"}, - "channel_id": {"key": "channelId", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TokenResponse, self).__init__(**kwargs) - self.connection_name = kwargs.get("connection_name", None) - self.token = kwargs.get("token", None) - self.expiration = kwargs.get("expiration", None) - self.channel_id = kwargs.get("channel_id", None) - - -class Transcript(Model): - """Transcript. - - :param activities: A collection of Activities that conforms to the - Transcript schema. - :type activities: list[~botframework.connector.models.Activity] - """ - - _attribute_map = {"activities": {"key": "activities", "type": "[Activity]"}} - - def __init__(self, **kwargs): - super(Transcript, self).__init__(**kwargs) - self.activities = kwargs.get("activities", None) - - -class VideoCard(Model): - """Video card. - - :param title: Title of this card - :type title: str - :param subtitle: Subtitle of this card - :type subtitle: str - :param text: Text of this card - :type text: str - :param image: Thumbnail placeholder - :type image: ~botframework.connector.models.ThumbnailUrl - :param media: Media URLs for this card. When this field contains more than - one URL, each URL is an alternative format of the same content. - :type media: list[~botframework.connector.models.MediaUrl] - :param buttons: Actions on this card - :type buttons: list[~botframework.connector.models.CardAction] - :param shareable: This content may be shared with others (default:true) - :type shareable: bool - :param autoloop: Should the client loop playback at end of content - (default:true) - :type autoloop: bool - :param autostart: Should the client automatically start playback of media - in this card (default:true) - :type autostart: bool - :param aspect: Aspect ratio of thumbnail/media placeholder. Allowed values - are "16:9" and "4:3" - :type aspect: str - :param duration: Describes the length of the media content without - requiring a receiver to open the content. Formatted as an ISO 8601 - Duration field. - :type duration: str - :param value: Supplementary parameter for this card - :type value: object - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "subtitle": {"key": "subtitle", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "image": {"key": "image", "type": "ThumbnailUrl"}, - "media": {"key": "media", "type": "[MediaUrl]"}, - "buttons": {"key": "buttons", "type": "[CardAction]"}, - "shareable": {"key": "shareable", "type": "bool"}, - "autoloop": {"key": "autoloop", "type": "bool"}, - "autostart": {"key": "autostart", "type": "bool"}, - "aspect": {"key": "aspect", "type": "str"}, - "duration": {"key": "duration", "type": "str"}, - "value": {"key": "value", "type": "object"}, - } - - def __init__(self, **kwargs): - super(VideoCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.subtitle = kwargs.get("subtitle", None) - self.text = kwargs.get("text", None) - self.image = kwargs.get("image", None) - self.media = kwargs.get("media", None) - self.buttons = kwargs.get("buttons", None) - self.shareable = kwargs.get("shareable", None) - self.autoloop = kwargs.get("autoloop", None) - self.autostart = kwargs.get("autostart", None) - self.aspect = kwargs.get("aspect", None) - self.duration = kwargs.get("duration", None) - self.value = kwargs.get("value", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index b97c17b5d..ef8b0b4d4 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -1,13 +1,5 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. from botbuilder.schema._connector_client_enums import ActivityTypes from datetime import datetime diff --git a/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py index 4e411687a..015e5a733 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_sign_in_enums.py @@ -1,9 +1,5 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. from enum import Enum diff --git a/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py b/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py index 7954a5213..3b2131306 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py +++ b/libraries/botbuilder-schema/botbuilder/schema/callerid_constants.py @@ -1,9 +1,6 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. + from enum import Enum diff --git a/libraries/botbuilder-schema/botbuilder/schema/health_results.py b/libraries/botbuilder-schema/botbuilder/schema/health_results.py index 1d28e23aa..28f7dca9c 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/health_results.py +++ b/libraries/botbuilder-schema/botbuilder/schema/health_results.py @@ -1,9 +1,5 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. from msrest.serialization import Model diff --git a/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py b/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py index 70a6dcdfc..e5ebea7e3 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py +++ b/libraries/botbuilder-schema/botbuilder/schema/healthcheck_response.py @@ -1,9 +1,5 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. from msrest.serialization import Model diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index 0f1f0edfe..a6d384feb 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -1,130 +1,63 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. -try: - from ._models_py3 import AppBasedLinkQuery - from ._models_py3 import ChannelInfo - from ._models_py3 import ConversationList - from ._models_py3 import FileConsentCard - from ._models_py3 import FileConsentCardResponse - from ._models_py3 import FileDownloadInfo - from ._models_py3 import FileInfoCard - from ._models_py3 import FileUploadInfo - from ._models_py3 import MessageActionsPayload - from ._models_py3 import MessageActionsPayloadApp - from ._models_py3 import MessageActionsPayloadAttachment - from ._models_py3 import MessageActionsPayloadBody - from ._models_py3 import MessageActionsPayloadConversation - from ._models_py3 import MessageActionsPayloadFrom - from ._models_py3 import MessageActionsPayloadMention - from ._models_py3 import MessageActionsPayloadReaction - from ._models_py3 import MessageActionsPayloadUser - from ._models_py3 import MessagingExtensionAction - from ._models_py3 import MessagingExtensionActionResponse - from ._models_py3 import MessagingExtensionAttachment - from ._models_py3 import MessagingExtensionParameter - from ._models_py3 import MessagingExtensionQuery - from ._models_py3 import MessagingExtensionQueryOptions - from ._models_py3 import MessagingExtensionResponse - from ._models_py3 import MessagingExtensionResult - from ._models_py3 import MessagingExtensionSuggestedAction - from ._models_py3 import NotificationInfo - from ._models_py3 import O365ConnectorCard - from ._models_py3 import O365ConnectorCardActionBase - from ._models_py3 import O365ConnectorCardActionCard - from ._models_py3 import O365ConnectorCardActionQuery - from ._models_py3 import O365ConnectorCardDateInput - from ._models_py3 import O365ConnectorCardFact - from ._models_py3 import O365ConnectorCardHttpPOST - from ._models_py3 import O365ConnectorCardImage - from ._models_py3 import O365ConnectorCardInputBase - from ._models_py3 import O365ConnectorCardMultichoiceInput - from ._models_py3 import O365ConnectorCardMultichoiceInputChoice - from ._models_py3 import O365ConnectorCardOpenUri - from ._models_py3 import O365ConnectorCardOpenUriTarget - from ._models_py3 import O365ConnectorCardSection - from ._models_py3 import O365ConnectorCardTextInput - from ._models_py3 import O365ConnectorCardViewAction - from ._models_py3 import SigninStateVerificationQuery - from ._models_py3 import TaskModuleContinueResponse - from ._models_py3 import TaskModuleMessageResponse - from ._models_py3 import TaskModuleRequest - from ._models_py3 import TaskModuleRequestContext - from ._models_py3 import TaskModuleResponse - from ._models_py3 import TaskModuleResponseBase - from ._models_py3 import TaskModuleTaskInfo - from ._models_py3 import TeamDetails - from ._models_py3 import TeamInfo - from ._models_py3 import TeamsChannelAccount - from ._models_py3 import TeamsChannelData - from ._models_py3 import TeamsPagedMembersResult - from ._models_py3 import TenantInfo -except (SyntaxError, ImportError): - from ._models import AppBasedLinkQuery - from ._models import ChannelInfo - from ._models import ConversationList - from ._models import FileConsentCard - from ._models import FileConsentCardResponse - from ._models import FileDownloadInfo - from ._models import FileInfoCard - from ._models import FileUploadInfo - from ._models import MessageActionsPayload - from ._models import MessageActionsPayloadApp - from ._models import MessageActionsPayloadAttachment - from ._models import MessageActionsPayloadBody - from ._models import MessageActionsPayloadConversation - from ._models import MessageActionsPayloadFrom - from ._models import MessageActionsPayloadMention - from ._models import MessageActionsPayloadReaction - from ._models import MessageActionsPayloadUser - from ._models import MessagingExtensionAction - from ._models import MessagingExtensionActionResponse - from ._models import MessagingExtensionAttachment - from ._models import MessagingExtensionParameter - from ._models import MessagingExtensionQuery - from ._models import MessagingExtensionQueryOptions - from ._models import MessagingExtensionResponse - from ._models import MessagingExtensionResult - from ._models import MessagingExtensionSuggestedAction - from ._models import NotificationInfo - from ._models import O365ConnectorCard - from ._models import O365ConnectorCardActionBase - from ._models import O365ConnectorCardActionCard - from ._models import O365ConnectorCardActionQuery - from ._models import O365ConnectorCardDateInput - from ._models import O365ConnectorCardFact - from ._models import O365ConnectorCardHttpPOST - from ._models import O365ConnectorCardImage - from ._models import O365ConnectorCardInputBase - from ._models import O365ConnectorCardMultichoiceInput - from ._models import O365ConnectorCardMultichoiceInputChoice - from ._models import O365ConnectorCardOpenUri - from ._models import O365ConnectorCardOpenUriTarget - from ._models import O365ConnectorCardSection - from ._models import O365ConnectorCardTextInput - from ._models import O365ConnectorCardViewAction - from ._models import SigninStateVerificationQuery - from ._models import TaskModuleContinueResponse - from ._models import TaskModuleMessageResponse - from ._models import TaskModuleRequest - from ._models import TaskModuleRequestContext - from ._models import TaskModuleResponse - from ._models import TaskModuleResponseBase - from ._models import TaskModuleTaskInfo - from ._models import TeamDetails - from ._models import TeamInfo - from ._models import TeamsChannelAccount - from ._models import TeamsChannelData - from ._models import TeamsPagedMembersResult - from ._models import TenantInfo +from ._models_py3 import AppBasedLinkQuery +from ._models_py3 import ChannelInfo +from ._models_py3 import ConversationList +from ._models_py3 import FileConsentCard +from ._models_py3 import FileConsentCardResponse +from ._models_py3 import FileDownloadInfo +from ._models_py3 import FileInfoCard +from ._models_py3 import FileUploadInfo +from ._models_py3 import MessageActionsPayload +from ._models_py3 import MessageActionsPayloadApp +from ._models_py3 import MessageActionsPayloadAttachment +from ._models_py3 import MessageActionsPayloadBody +from ._models_py3 import MessageActionsPayloadConversation +from ._models_py3 import MessageActionsPayloadFrom +from ._models_py3 import MessageActionsPayloadMention +from ._models_py3 import MessageActionsPayloadReaction +from ._models_py3 import MessageActionsPayloadUser +from ._models_py3 import MessagingExtensionAction +from ._models_py3 import MessagingExtensionActionResponse +from ._models_py3 import MessagingExtensionAttachment +from ._models_py3 import MessagingExtensionParameter +from ._models_py3 import MessagingExtensionQuery +from ._models_py3 import MessagingExtensionQueryOptions +from ._models_py3 import MessagingExtensionResponse +from ._models_py3 import MessagingExtensionResult +from ._models_py3 import MessagingExtensionSuggestedAction +from ._models_py3 import NotificationInfo +from ._models_py3 import O365ConnectorCard +from ._models_py3 import O365ConnectorCardActionBase +from ._models_py3 import O365ConnectorCardActionCard +from ._models_py3 import O365ConnectorCardActionQuery +from ._models_py3 import O365ConnectorCardDateInput +from ._models_py3 import O365ConnectorCardFact +from ._models_py3 import O365ConnectorCardHttpPOST +from ._models_py3 import O365ConnectorCardImage +from ._models_py3 import O365ConnectorCardInputBase +from ._models_py3 import O365ConnectorCardMultichoiceInput +from ._models_py3 import O365ConnectorCardMultichoiceInputChoice +from ._models_py3 import O365ConnectorCardOpenUri +from ._models_py3 import O365ConnectorCardOpenUriTarget +from ._models_py3 import O365ConnectorCardSection +from ._models_py3 import O365ConnectorCardTextInput +from ._models_py3 import O365ConnectorCardViewAction +from ._models_py3 import SigninStateVerificationQuery +from ._models_py3 import TaskModuleContinueResponse +from ._models_py3 import TaskModuleMessageResponse +from ._models_py3 import TaskModuleRequest +from ._models_py3 import TaskModuleRequestContext +from ._models_py3 import TaskModuleResponse +from ._models_py3 import TaskModuleResponseBase +from ._models_py3 import TaskModuleTaskInfo +from ._models_py3 import TeamDetails +from ._models_py3 import TeamInfo +from ._models_py3 import TeamsChannelAccount +from ._models_py3 import TeamsChannelData +from ._models_py3 import TeamsPagedMembersResult +from ._models_py3 import TenantInfo __all__ = [ "AppBasedLinkQuery", diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py deleted file mode 100644 index a086439ed..000000000 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models.py +++ /dev/null @@ -1,1643 +0,0 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- - -from msrest.serialization import Model -from botbuilder.schema import Activity, PagedMembersResult, TeamsChannelAccount - - -class AppBasedLinkQuery(Model): - """Invoke request body type for app-based link query. - - :param url: Url queried by user - :type url: str - """ - - _attribute_map = { - "url": {"key": "url", "type": "str"}, - } - - def __init__(self, *, url: str = None, **kwargs) -> None: - super(AppBasedLinkQuery, self).__init__(**kwargs) - self.url = url - - -class ChannelInfo(Model): - """A channel info object which describes the channel. - - :param id: Unique identifier representing a channel - :type id: str - :param name: Name of the channel - :type name: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - } - - def __init__(self, **kwargs): - super(ChannelInfo, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.name = kwargs.get("name", None) - - -class ConversationList(Model): - """List of channels under a team. - - :param conversations: - :type conversations: - list[~botframework.connector.teams.models.ChannelInfo] - """ - - _attribute_map = { - "conversations": {"key": "conversations", "type": "[ChannelInfo]"}, - } - - def __init__(self, **kwargs): - super(ConversationList, self).__init__(**kwargs) - self.conversations = kwargs.get("conversations", None) - - -class FileConsentCard(Model): - """File consent card attachment. - - :param description: File description. - :type description: str - :param size_in_bytes: Size of the file to be uploaded in Bytes. - :type size_in_bytes: long - :param accept_context: Context sent back to the Bot if user consented to - upload. This is free flow schema and is sent back in Value field of - Activity. - :type accept_context: object - :param decline_context: Context sent back to the Bot if user declined. - This is free flow schema and is sent back in Value field of Activity. - :type decline_context: object - """ - - _attribute_map = { - "description": {"key": "description", "type": "str"}, - "size_in_bytes": {"key": "sizeInBytes", "type": "long"}, - "accept_context": {"key": "acceptContext", "type": "object"}, - "decline_context": {"key": "declineContext", "type": "object"}, - } - - def __init__(self, **kwargs): - super(FileConsentCard, self).__init__(**kwargs) - self.description = kwargs.get("description", None) - self.size_in_bytes = kwargs.get("size_in_bytes", None) - self.accept_context = kwargs.get("accept_context", None) - self.decline_context = kwargs.get("decline_context", None) - - -class FileConsentCardResponse(Model): - """Represents the value of the invoke activity sent when the user acts on a - file consent card. - - :param action: The action the user took. Possible values include: - 'accept', 'decline' - :type action: str or ~botframework.connector.teams.models.enum - :param context: The context associated with the action. - :type context: object - :param upload_info: If the user accepted the file, contains information - about the file to be uploaded. - :type upload_info: ~botframework.connector.teams.models.FileUploadInfo - """ - - _attribute_map = { - "action": {"key": "action", "type": "str"}, - "context": {"key": "context", "type": "object"}, - "upload_info": {"key": "uploadInfo", "type": "FileUploadInfo"}, - } - - def __init__(self, **kwargs): - super(FileConsentCardResponse, self).__init__(**kwargs) - self.action = kwargs.get("action", None) - self.context = kwargs.get("context", None) - self.upload_info = kwargs.get("upload_info", None) - - -class FileDownloadInfo(Model): - """File download info attachment. - - :param download_url: File download url. - :type download_url: str - :param unique_id: Unique Id for the file. - :type unique_id: str - :param file_type: Type of file. - :type file_type: str - :param etag: ETag for the file. - :type etag: object - """ - - _attribute_map = { - "download_url": {"key": "downloadUrl", "type": "str"}, - "unique_id": {"key": "uniqueId", "type": "str"}, - "file_type": {"key": "fileType", "type": "str"}, - "etag": {"key": "etag", "type": "object"}, - } - - def __init__(self, **kwargs): - super(FileDownloadInfo, self).__init__(**kwargs) - self.download_url = kwargs.get("download_url", None) - self.unique_id = kwargs.get("unique_id", None) - self.file_type = kwargs.get("file_type", None) - self.etag = kwargs.get("etag", None) - - -class FileInfoCard(Model): - """File info card. - - :param unique_id: Unique Id for the file. - :type unique_id: str - :param file_type: Type of file. - :type file_type: str - :param etag: ETag for the file. - :type etag: object - """ - - _attribute_map = { - "unique_id": {"key": "uniqueId", "type": "str"}, - "file_type": {"key": "fileType", "type": "str"}, - "etag": {"key": "etag", "type": "object"}, - } - - def __init__(self, **kwargs): - super(FileInfoCard, self).__init__(**kwargs) - self.unique_id = kwargs.get("unique_id", None) - self.file_type = kwargs.get("file_type", None) - self.etag = kwargs.get("etag", None) - - -class FileUploadInfo(Model): - """Information about the file to be uploaded. - - :param name: Name of the file. - :type name: str - :param upload_url: URL to an upload session that the bot can use to set - the file contents. - :type upload_url: str - :param content_url: URL to file. - :type content_url: str - :param unique_id: ID that uniquely identifies the file. - :type unique_id: str - :param file_type: Type of the file. - :type file_type: str - """ - - _attribute_map = { - "name": {"key": "name", "type": "str"}, - "upload_url": {"key": "uploadUrl", "type": "str"}, - "content_url": {"key": "contentUrl", "type": "str"}, - "unique_id": {"key": "uniqueId", "type": "str"}, - "file_type": {"key": "fileType", "type": "str"}, - } - - def __init__(self, **kwargs): - super(FileUploadInfo, self).__init__(**kwargs) - self.name = kwargs.get("name", None) - self.upload_url = kwargs.get("upload_url", None) - self.content_url = kwargs.get("content_url", None) - self.unique_id = kwargs.get("unique_id", None) - self.file_type = kwargs.get("file_type", None) - - -class MessageActionsPayloadApp(Model): - """Represents an application entity. - - :param application_identity_type: The type of application. Possible values - include: 'aadApplication', 'bot', 'tenantBot', 'office365Connector', - 'webhook' - :type application_identity_type: str or - ~botframework.connector.teams.models.enum - :param id: The id of the application. - :type id: str - :param display_name: The plaintext display name of the application. - :type display_name: str - """ - - _attribute_map = { - "application_identity_type": {"key": "applicationIdentityType", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "display_name": {"key": "displayName", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadApp, self).__init__(**kwargs) - self.application_identity_type = kwargs.get("application_identity_type", None) - self.id = kwargs.get("id", None) - self.display_name = kwargs.get("display_name", None) - - -class MessageActionsPayloadAttachment(Model): - """Represents the attachment in a message. - - :param id: The id of the attachment. - :type id: str - :param content_type: The type of the attachment. - :type content_type: str - :param content_url: The url of the attachment, in case of a external link. - :type content_url: str - :param content: The content of the attachment, in case of a code snippet, - email, or file. - :type content: object - :param name: The plaintext display name of the attachment. - :type name: str - :param thumbnail_url: The url of a thumbnail image that might be embedded - in the attachment, in case of a card. - :type thumbnail_url: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "content_type": {"key": "contentType", "type": "str"}, - "content_url": {"key": "contentUrl", "type": "str"}, - "content": {"key": "content", "type": "object"}, - "name": {"key": "name", "type": "str"}, - "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadAttachment, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.content_type = kwargs.get("content_type", None) - self.content_url = kwargs.get("content_url", None) - self.content = kwargs.get("content", None) - self.name = kwargs.get("name", None) - self.thumbnail_url = kwargs.get("thumbnail_url", None) - - -class MessageActionsPayloadBody(Model): - """Plaintext/HTML representation of the content of the message. - - :param content_type: Type of the content. Possible values include: 'html', - 'text' - :type content_type: str or ~botframework.connector.teams.models.enum - :param content: The content of the body. - :type content: str - """ - - _attribute_map = { - "content_type": {"key": "contentType", "type": "str"}, - "content": {"key": "content", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadBody, self).__init__(**kwargs) - self.content_type = kwargs.get("content_type", None) - self.content = kwargs.get("content", None) - - -class MessageActionsPayloadConversation(Model): - """Represents a team or channel entity. - - :param conversation_identity_type: The type of conversation, whether a - team or channel. Possible values include: 'team', 'channel' - :type conversation_identity_type: str or - ~botframework.connector.teams.models.enum - :param id: The id of the team or channel. - :type id: str - :param display_name: The plaintext display name of the team or channel - entity. - :type display_name: str - """ - - _attribute_map = { - "conversation_identity_type": { - "key": "conversationIdentityType", - "type": "str", - }, - "id": {"key": "id", "type": "str"}, - "display_name": {"key": "displayName", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadConversation, self).__init__(**kwargs) - self.conversation_identity_type = kwargs.get("conversation_identity_type", None) - self.id = kwargs.get("id", None) - self.display_name = kwargs.get("display_name", None) - - -class MessageActionsPayloadFrom(Model): - """Represents a user, application, or conversation type that either sent or - was referenced in a message. - - :param user: Represents details of the user. - :type user: ~botframework.connector.teams.models.MessageActionsPayloadUser - :param application: Represents details of the app. - :type application: - ~botframework.connector.teams.models.MessageActionsPayloadApp - :param conversation: Represents details of the converesation. - :type conversation: - ~botframework.connector.teams.models.MessageActionsPayloadConversation - """ - - _attribute_map = { - "user": {"key": "user", "type": "MessageActionsPayloadUser"}, - "application": {"key": "application", "type": "MessageActionsPayloadApp"}, - "conversation": { - "key": "conversation", - "type": "MessageActionsPayloadConversation", - }, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadFrom, self).__init__(**kwargs) - self.user = kwargs.get("user", None) - self.application = kwargs.get("application", None) - self.conversation = kwargs.get("conversation", None) - - -class MessageActionsPayloadMention(Model): - """Represents the entity that was mentioned in the message. - - :param id: The id of the mentioned entity. - :type id: int - :param mention_text: The plaintext display name of the mentioned entity. - :type mention_text: str - :param mentioned: Provides more details on the mentioned entity. - :type mentioned: - ~botframework.connector.teams.models.MessageActionsPayloadFrom - """ - - _attribute_map = { - "id": {"key": "id", "type": "int"}, - "mention_text": {"key": "mentionText", "type": "str"}, - "mentioned": {"key": "mentioned", "type": "MessageActionsPayloadFrom"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadMention, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.mention_text = kwargs.get("mention_text", None) - self.mentioned = kwargs.get("mentioned", None) - - -class MessageActionsPayloadReaction(Model): - """Represents the reaction of a user to a message. - - :param reaction_type: The type of reaction given to the message. Possible - values include: 'like', 'heart', 'laugh', 'surprised', 'sad', 'angry' - :type reaction_type: str or ~botframework.connector.teams.models.enum - :param created_date_time: Timestamp of when the user reacted to the - message. - :type created_date_time: str - :param user: The user with which the reaction is associated. - :type user: ~botframework.connector.teams.models.MessageActionsPayloadFrom - """ - - _attribute_map = { - "reaction_type": {"key": "reactionType", "type": "str"}, - "created_date_time": {"key": "createdDateTime", "type": "str"}, - "user": {"key": "user", "type": "MessageActionsPayloadFrom"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadReaction, self).__init__(**kwargs) - self.reaction_type = kwargs.get("reaction_type", None) - self.created_date_time = kwargs.get("created_date_time", None) - self.user = kwargs.get("user", None) - - -class MessageActionsPayloadUser(Model): - """Represents a user entity. - - :param user_identity_type: The identity type of the user. Possible values - include: 'aadUser', 'onPremiseAadUser', 'anonymousGuest', 'federatedUser' - :type user_identity_type: str or ~botframework.connector.teams.models.enum - :param id: The id of the user. - :type id: str - :param display_name: The plaintext display name of the user. - :type display_name: str - """ - - _attribute_map = { - "user_identity_type": {"key": "userIdentityType", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "display_name": {"key": "displayName", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayloadUser, self).__init__(**kwargs) - self.user_identity_type = kwargs.get("user_identity_type", None) - self.id = kwargs.get("id", None) - self.display_name = kwargs.get("display_name", None) - - -class MessageActionsPayload(Model): - """Represents the individual message within a chat or channel where a message - actions is taken. - - :param id: Unique id of the message. - :type id: str - :param reply_to_id: Id of the parent/root message of the thread. - :type reply_to_id: str - :param message_type: Type of message - automatically set to message. - Possible values include: 'message' - :type message_type: str or ~botframework.connector.teams.models.enum - :param created_date_time: Timestamp of when the message was created. - :type created_date_time: str - :param last_modified_date_time: Timestamp of when the message was edited - or updated. - :type last_modified_date_time: str - :param deleted: Indicates whether a message has been soft deleted. - :type deleted: bool - :param subject: Subject line of the message. - :type subject: str - :param summary: Summary text of the message that could be used for - notifications. - :type summary: str - :param importance: The importance of the message. Possible values include: - 'normal', 'high', 'urgent' - :type importance: str or ~botframework.connector.teams.models.enum - :param locale: Locale of the message set by the client. - :type locale: str - :param link_to_message: Link back to the message. - :type link_to_message: str - :param from_property: Sender of the message. - :type from_property: - ~botframework.connector.teams.models.MessageActionsPayloadFrom - :param body: Plaintext/HTML representation of the content of the message. - :type body: ~botframework.connector.teams.models.MessageActionsPayloadBody - :param attachment_layout: How the attachment(s) are displayed in the - message. - :type attachment_layout: str - :param attachments: Attachments in the message - card, image, file, etc. - :type attachments: - list[~botframework.connector.teams.models.MessageActionsPayloadAttachment] - :param mentions: List of entities mentioned in the message. - :type mentions: - list[~botframework.connector.teams.models.MessageActionsPayloadMention] - :param reactions: Reactions for the message. - :type reactions: - list[~botframework.connector.teams.models.MessageActionsPayloadReaction] - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "reply_to_id": {"key": "replyToId", "type": "str"}, - "message_type": {"key": "messageType", "type": "str"}, - "created_date_time": {"key": "createdDateTime", "type": "str"}, - "last_modified_date_time": {"key": "lastModifiedDateTime", "type": "str"}, - "deleted": {"key": "deleted", "type": "bool"}, - "subject": {"key": "subject", "type": "str"}, - "summary": {"key": "summary", "type": "str"}, - "importance": {"key": "importance", "type": "str"}, - "locale": {"key": "locale", "type": "str"}, - "link_to_message": {"key": "linkToMessage", "type": "str"}, - "from_property": {"key": "from", "type": "MessageActionsPayloadFrom"}, - "body": {"key": "body", "type": "MessageActionsPayloadBody"}, - "attachment_layout": {"key": "attachmentLayout", "type": "str"}, - "attachments": { - "key": "attachments", - "type": "[MessageActionsPayloadAttachment]", - }, - "mentions": {"key": "mentions", "type": "[MessageActionsPayloadMention]"}, - "reactions": {"key": "reactions", "type": "[MessageActionsPayloadReaction]"}, - } - - def __init__(self, **kwargs): - super(MessageActionsPayload, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.reply_to_id = kwargs.get("reply_to_id", None) - self.message_type = kwargs.get("message_type", None) - self.created_date_time = kwargs.get("created_date_time", None) - self.last_modified_date_time = kwargs.get("last_modified_date_time", None) - self.deleted = kwargs.get("deleted", None) - self.subject = kwargs.get("subject", None) - self.summary = kwargs.get("summary", None) - self.importance = kwargs.get("importance", None) - self.locale = kwargs.get("locale", None) - self.link_to_message = kwargs.get("link_to_message", None) - self.from_property = kwargs.get("from_property", None) - self.body = kwargs.get("body", None) - self.attachment_layout = kwargs.get("attachment_layout", None) - self.attachments = kwargs.get("attachments", None) - self.mentions = kwargs.get("mentions", None) - self.reactions = kwargs.get("reactions", None) - - -class MessagingExtensionAction(TaskModuleRequest): - """Messaging extension action. - - :param data: User input data. Free payload with key-value pairs. - :type data: object - :param context: Current user context, i.e., the current theme - :type context: - ~botframework.connector.teams.models.TaskModuleRequestContext - :param command_id: Id of the command assigned by Bot - :type command_id: str - :param command_context: The context from which the command originates. - Possible values include: 'message', 'compose', 'commandbox' - :type command_context: str or ~botframework.connector.teams.models.enum - :param bot_message_preview_action: Bot message preview action taken by - user. Possible values include: 'edit', 'send' - :type bot_message_preview_action: str or - ~botframework.connector.teams.models.enum - :param bot_activity_preview: - :type bot_activity_preview: - list[~botframework.connector.teams.models.Activity] - :param message_payload: Message content sent as part of the command - request. - :type message_payload: - ~botframework.connector.teams.models.MessageActionsPayload - """ - - _attribute_map = { - "data": {"key": "data", "type": "object"}, - "context": {"key": "context", "type": "TaskModuleRequestContext"}, - "command_id": {"key": "commandId", "type": "str"}, - "command_context": {"key": "commandContext", "type": "str"}, - "bot_message_preview_action": {"key": "botMessagePreviewAction", "type": "str"}, - "bot_activity_preview": {"key": "botActivityPreview", "type": "[Activity]"}, - "message_payload": {"key": "messagePayload", "type": "MessageActionsPayload"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionAction, self).__init__(**kwargs) - self.command_id = kwargs.get("command_id", None) - self.command_context = kwargs.get("command_context", None) - self.bot_message_preview_action = kwargs.get("bot_message_preview_action", None) - self.bot_activity_preview = kwargs.get("bot_activity_preview", None) - self.message_payload = kwargs.get("message_payload", None) - - -class MessagingExtensionActionResponse(Model): - """Response of messaging extension action. - - :param task: The JSON for the Adaptive card to appear in the task module. - :type task: ~botframework.connector.teams.models.TaskModuleResponseBase - :param compose_extension: - :type compose_extension: - ~botframework.connector.teams.models.MessagingExtensionResult - """ - - _attribute_map = { - "task": {"key": "task", "type": "TaskModuleResponseBase"}, - "compose_extension": { - "key": "composeExtension", - "type": "MessagingExtensionResult", - }, - } - - def __init__(self, **kwargs): - super(MessagingExtensionActionResponse, self).__init__(**kwargs) - self.task = kwargs.get("task", None) - self.compose_extension = kwargs.get("compose_extension", None) - - -class MessagingExtensionAttachment(Attachment): - """Messaging extension attachment. - - :param content_type: mimetype/Contenttype for the file - :type content_type: str - :param content_url: Content Url - :type content_url: str - :param content: Embedded content - :type content: object - :param name: (OPTIONAL) The name of the attachment - :type name: str - :param thumbnail_url: (OPTIONAL) Thumbnail associated with attachment - :type thumbnail_url: str - :param preview: - :type preview: ~botframework.connector.teams.models.Attachment - """ - - _attribute_map = { - "content_type": {"key": "contentType", "type": "str"}, - "content_url": {"key": "contentUrl", "type": "str"}, - "content": {"key": "content", "type": "object"}, - "name": {"key": "name", "type": "str"}, - "thumbnail_url": {"key": "thumbnailUrl", "type": "str"}, - "preview": {"key": "preview", "type": "Attachment"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionAttachment, self).__init__(**kwargs) - self.preview = kwargs.get("preview", None) - - -class MessagingExtensionParameter(Model): - """Messaging extension query parameters. - - :param name: Name of the parameter - :type name: str - :param value: Value of the parameter - :type value: object - """ - - _attribute_map = { - "name": {"key": "name", "type": "str"}, - "value": {"key": "value", "type": "object"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionParameter, self).__init__(**kwargs) - self.name = kwargs.get("name", None) - self.value = kwargs.get("value", None) - - -class MessagingExtensionQuery(Model): - """Messaging extension query. - - :param command_id: Id of the command assigned by Bot - :type command_id: str - :param parameters: Parameters for the query - :type parameters: - list[~botframework.connector.teams.models.MessagingExtensionParameter] - :param query_options: - :type query_options: - ~botframework.connector.teams.models.MessagingExtensionQueryOptions - :param state: State parameter passed back to the bot after - authentication/configuration flow - :type state: str - """ - - _attribute_map = { - "command_id": {"key": "commandId", "type": "str"}, - "parameters": {"key": "parameters", "type": "[MessagingExtensionParameter]"}, - "query_options": { - "key": "queryOptions", - "type": "MessagingExtensionQueryOptions", - }, - "state": {"key": "state", "type": "str"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionQuery, self).__init__(**kwargs) - self.command_id = kwargs.get("command_id", None) - self.parameters = kwargs.get("parameters", None) - self.query_options = kwargs.get("query_options", None) - self.state = kwargs.get("state", None) - - -class MessagingExtensionQueryOptions(Model): - """Messaging extension query options. - - :param skip: Number of entities to skip - :type skip: int - :param count: Number of entities to fetch - :type count: int - """ - - _attribute_map = { - "skip": {"key": "skip", "type": "int"}, - "count": {"key": "count", "type": "int"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionQueryOptions, self).__init__(**kwargs) - self.skip = kwargs.get("skip", None) - self.count = kwargs.get("count", None) - - -class MessagingExtensionResponse(Model): - """Messaging extension response. - - :param compose_extension: - :type compose_extension: - ~botframework.connector.teams.models.MessagingExtensionResult - """ - - _attribute_map = { - "compose_extension": { - "key": "composeExtension", - "type": "MessagingExtensionResult", - }, - } - - def __init__(self, **kwargs): - super(MessagingExtensionResponse, self).__init__(**kwargs) - self.compose_extension = kwargs.get("compose_extension", None) - - -class MessagingExtensionResult(Model): - """Messaging extension result. - - :param attachment_layout: Hint for how to deal with multiple attachments. - Possible values include: 'list', 'grid' - :type attachment_layout: str or ~botframework.connector.teams.models.enum - :param type: The type of the result. Possible values include: 'result', - 'auth', 'config', 'message', 'botMessagePreview' - :type type: str or ~botframework.connector.teams.models.enum - :param attachments: (Only when type is result) Attachments - :type attachments: - list[~botframework.connector.teams.models.MessagingExtensionAttachment] - :param suggested_actions: - :type suggested_actions: - ~botframework.connector.teams.models.MessagingExtensionSuggestedAction - :param text: (Only when type is message) Text - :type text: str - :param activity_preview: (Only when type is botMessagePreview) Message - activity to preview - :type activity_preview: ~botframework.connector.teams.models.Activity - """ - - _attribute_map = { - "attachment_layout": {"key": "attachmentLayout", "type": "str"}, - "type": {"key": "type", "type": "str"}, - "attachments": {"key": "attachments", "type": "[MessagingExtensionAttachment]"}, - "suggested_actions": { - "key": "suggestedActions", - "type": "MessagingExtensionSuggestedAction", - }, - "text": {"key": "text", "type": "str"}, - "activity_preview": {"key": "activityPreview", "type": "Activity"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionResult, self).__init__(**kwargs) - self.attachment_layout = kwargs.get("attachment_layout", None) - self.type = kwargs.get("type", None) - self.attachments = kwargs.get("attachments", None) - self.suggested_actions = kwargs.get("suggested_actions", None) - self.text = kwargs.get("text", None) - self.activity_preview = kwargs.get("activity_preview", None) - - -class MessagingExtensionSuggestedAction(Model): - """Messaging extension Actions (Only when type is auth or config). - - :param actions: Actions - :type actions: list[~botframework.connector.teams.models.CardAction] - """ - - _attribute_map = { - "actions": {"key": "actions", "type": "[CardAction]"}, - } - - def __init__(self, **kwargs): - super(MessagingExtensionSuggestedAction, self).__init__(**kwargs) - self.actions = kwargs.get("actions", None) - - -class NotificationInfo(Model): - """Specifies if a notification is to be sent for the mentions. - - :param alert: true if notification is to be sent to the user, false - otherwise. - :type alert: bool - """ - - _attribute_map = { - "alert": {"key": "alert", "type": "bool"}, - } - - def __init__(self, **kwargs): - super(NotificationInfo, self).__init__(**kwargs) - self.alert = kwargs.get("alert", None) - - -class O365ConnectorCard(Model): - """O365 connector card. - - :param title: Title of the item - :type title: str - :param text: Text for the card - :type text: str - :param summary: Summary for the card - :type summary: str - :param theme_color: Theme color for the card - :type theme_color: str - :param sections: Set of sections for the current card - :type sections: - list[~botframework.connector.teams.models.O365ConnectorCardSection] - :param potential_action: Set of actions for the current card - :type potential_action: - list[~botframework.connector.teams.models.O365ConnectorCardActionBase] - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "summary": {"key": "summary", "type": "str"}, - "theme_color": {"key": "themeColor", "type": "str"}, - "sections": {"key": "sections", "type": "[O365ConnectorCardSection]"}, - "potential_action": { - "key": "potentialAction", - "type": "[O365ConnectorCardActionBase]", - }, - } - - def __init__(self, **kwargs): - super(O365ConnectorCard, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.text = kwargs.get("text", None) - self.summary = kwargs.get("summary", None) - self.theme_color = kwargs.get("theme_color", None) - self.sections = kwargs.get("sections", None) - self.potential_action = kwargs.get("potential_action", None) - - -class O365ConnectorCardActionBase(Model): - """O365 connector card action base. - - :param type: Type of the action. Possible values include: 'ViewAction', - 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum - :param name: Name of the action that will be used as button title - :type name: str - :param id: Action Id - :type id: str - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "id": {"key": "@id", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardActionBase, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - self.name = kwargs.get("name", None) - self.id = kwargs.get("id", None) - - -class O365ConnectorCardActionCard(O365ConnectorCardActionBase): - """O365 connector card ActionCard action. - - :param type: Type of the action. Possible values include: 'ViewAction', - 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum - :param name: Name of the action that will be used as button title - :type name: str - :param id: Action Id - :type id: str - :param inputs: Set of inputs contained in this ActionCard whose each item - can be in any subtype of O365ConnectorCardInputBase - :type inputs: - list[~botframework.connector.teams.models.O365ConnectorCardInputBase] - :param actions: Set of actions contained in this ActionCard whose each - item can be in any subtype of O365ConnectorCardActionBase except - O365ConnectorCardActionCard, as nested ActionCard is forbidden. - :type actions: - list[~botframework.connector.teams.models.O365ConnectorCardActionBase] - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "id": {"key": "@id", "type": "str"}, - "inputs": {"key": "inputs", "type": "[O365ConnectorCardInputBase]"}, - "actions": {"key": "actions", "type": "[O365ConnectorCardActionBase]"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardActionCard, self).__init__(**kwargs) - self.inputs = kwargs.get("inputs", None) - self.actions = kwargs.get("actions", None) - - -class O365ConnectorCardActionQuery(Model): - """O365 connector card HttpPOST invoke query. - - :param body: The results of body string defined in - IO365ConnectorCardHttpPOST with substituted input values - :type body: str - :param action_id: Action Id associated with the HttpPOST action button - triggered, defined in O365ConnectorCardActionBase. - :type action_id: str - """ - - _attribute_map = { - "body": {"key": "body", "type": "str"}, - "action_id": {"key": "actionId", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardActionQuery, self).__init__(**kwargs) - self.body = kwargs.get("body", None) - # This is how it comes in from Teams - self.action_id = kwargs.get("actionId", None) - - -class O365ConnectorCardDateInput(O365ConnectorCardInputBase): - """O365 connector card date input. - - :param type: Input type name. Possible values include: 'textInput', - 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum - :param id: Input Id. It must be unique per entire O365 connector card. - :type id: str - :param is_required: Define if this input is a required field. Default - value is false. - :type is_required: bool - :param title: Input title that will be shown as the placeholder - :type title: str - :param value: Default value for this input field - :type value: str - :param include_time: Include time input field. Default value is false - (date only). - :type include_time: bool - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "is_required": {"key": "isRequired", "type": "bool"}, - "title": {"key": "title", "type": "str"}, - "value": {"key": "value", "type": "str"}, - "include_time": {"key": "includeTime", "type": "bool"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardDateInput, self).__init__(**kwargs) - self.include_time = kwargs.get("include_time", None) - - -class O365ConnectorCardFact(Model): - """O365 connector card fact. - - :param name: Display name of the fact - :type name: str - :param value: Display value for the fact - :type value: str - """ - - _attribute_map = { - "name": {"key": "name", "type": "str"}, - "value": {"key": "value", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardFact, self).__init__(**kwargs) - self.name = kwargs.get("name", None) - self.value = kwargs.get("value", None) - - -class O365ConnectorCardHttpPOST(O365ConnectorCardActionBase): - """O365 connector card HttpPOST action. - - :param type: Type of the action. Possible values include: 'ViewAction', - 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum - :param name: Name of the action that will be used as button title - :type name: str - :param id: Action Id - :type id: str - :param body: Content to be posted back to bots via invoke - :type body: str - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "id": {"key": "@id", "type": "str"}, - "body": {"key": "body", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardHttpPOST, self).__init__(**kwargs) - self.body = kwargs.get("body", None) - - -class O365ConnectorCardImage(Model): - """O365 connector card image. - - :param image: URL for the image - :type image: str - :param title: Alternative text for the image - :type title: str - """ - - _attribute_map = { - "image": {"key": "image", "type": "str"}, - "title": {"key": "title", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardImage, self).__init__(**kwargs) - self.image = kwargs.get("image", None) - self.title = kwargs.get("title", None) - - -class O365ConnectorCardInputBase(Model): - """O365 connector card input for ActionCard action. - - :param type: Input type name. Possible values include: 'textInput', - 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum - :param id: Input Id. It must be unique per entire O365 connector card. - :type id: str - :param is_required: Define if this input is a required field. Default - value is false. - :type is_required: bool - :param title: Input title that will be shown as the placeholder - :type title: str - :param value: Default value for this input field - :type value: str - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "is_required": {"key": "isRequired", "type": "bool"}, - "title": {"key": "title", "type": "str"}, - "value": {"key": "value", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardInputBase, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - self.id = kwargs.get("id", None) - self.is_required = kwargs.get("is_required", None) - self.title = kwargs.get("title", None) - self.value = kwargs.get("value", None) - - -class O365ConnectorCardMultichoiceInput(O365ConnectorCardInputBase): - """O365 connector card multiple choice input. - - :param type: Input type name. Possible values include: 'textInput', - 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum - :param id: Input Id. It must be unique per entire O365 connector card. - :type id: str - :param is_required: Define if this input is a required field. Default - value is false. - :type is_required: bool - :param title: Input title that will be shown as the placeholder - :type title: str - :param value: Default value for this input field - :type value: str - :param choices: Set of choices whose each item can be in any subtype of - O365ConnectorCardMultichoiceInputChoice. - :type choices: - list[~botframework.connector.teams.models.O365ConnectorCardMultichoiceInputChoice] - :param style: Choice item rendering style. Default value is 'compact'. - Possible values include: 'compact', 'expanded' - :type style: str or ~botframework.connector.teams.models.enum - :param is_multi_select: Define if this input field allows multiple - selections. Default value is false. - :type is_multi_select: bool - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "is_required": {"key": "isRequired", "type": "bool"}, - "title": {"key": "title", "type": "str"}, - "value": {"key": "value", "type": "str"}, - "choices": { - "key": "choices", - "type": "[O365ConnectorCardMultichoiceInputChoice]", - }, - "style": {"key": "style", "type": "str"}, - "is_multi_select": {"key": "isMultiSelect", "type": "bool"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardMultichoiceInput, self).__init__(**kwargs) - self.choices = kwargs.get("choices", None) - self.style = kwargs.get("style", None) - self.is_multi_select = kwargs.get("is_multi_select", None) - - -class O365ConnectorCardMultichoiceInputChoice(Model): - """O365O365 connector card multiple choice input item. - - :param display: The text rendered on ActionCard. - :type display: str - :param value: The value received as results. - :type value: str - """ - - _attribute_map = { - "display": {"key": "display", "type": "str"}, - "value": {"key": "value", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardMultichoiceInputChoice, self).__init__(**kwargs) - self.display = kwargs.get("display", None) - self.value = kwargs.get("value", None) - - -class O365ConnectorCardOpenUriTarget(Model): - """O365 connector card OpenUri target. - - :param os: Target operating system. Possible values include: 'default', - 'iOS', 'android', 'windows' - :type os: str or ~botframework.connector.teams.models.enum - :param uri: Target url - :type uri: str - """ - - _attribute_map = { - "os": {"key": "os", "type": "str"}, - "uri": {"key": "uri", "type": "str"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardOpenUriTarget, self).__init__(**kwargs) - self.os = kwargs.get("os", None) - self.uri = kwargs.get("uri", None) - - -class O365ConnectorCardOpenUri(O365ConnectorCardActionBase): - """O365 connector card OpenUri action. - - :param type: Type of the action. Possible values include: 'ViewAction', - 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum - :param name: Name of the action that will be used as button title - :type name: str - :param id: Action Id - :type id: str - :param targets: Target os / urls - :type targets: - list[~botframework.connector.teams.models.O365ConnectorCardOpenUriTarget] - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "id": {"key": "@id", "type": "str"}, - "targets": {"key": "targets", "type": "[O365ConnectorCardOpenUriTarget]"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardOpenUri, self).__init__(**kwargs) - self.targets = kwargs.get("targets", None) - - -class O365ConnectorCardSection(Model): - """O365 connector card section. - - :param title: Title of the section - :type title: str - :param text: Text for the section - :type text: str - :param activity_title: Activity title - :type activity_title: str - :param activity_subtitle: Activity subtitle - :type activity_subtitle: str - :param activity_text: Activity text - :type activity_text: str - :param activity_image: Activity image - :type activity_image: str - :param activity_image_type: Describes how Activity image is rendered. - Possible values include: 'avatar', 'article' - :type activity_image_type: str or - ~botframework.connector.teams.models.enum - :param markdown: Use markdown for all text contents. Default value is - true. - :type markdown: bool - :param facts: Set of facts for the current section - :type facts: - list[~botframework.connector.teams.models.O365ConnectorCardFact] - :param images: Set of images for the current section - :type images: - list[~botframework.connector.teams.models.O365ConnectorCardImage] - :param potential_action: Set of actions for the current section - :type potential_action: - list[~botframework.connector.teams.models.O365ConnectorCardActionBase] - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "text": {"key": "text", "type": "str"}, - "activity_title": {"key": "activityTitle", "type": "str"}, - "activity_subtitle": {"key": "activitySubtitle", "type": "str"}, - "activity_text": {"key": "activityText", "type": "str"}, - "activity_image": {"key": "activityImage", "type": "str"}, - "activity_image_type": {"key": "activityImageType", "type": "str"}, - "markdown": {"key": "markdown", "type": "bool"}, - "facts": {"key": "facts", "type": "[O365ConnectorCardFact]"}, - "images": {"key": "images", "type": "[O365ConnectorCardImage]"}, - "potential_action": { - "key": "potentialAction", - "type": "[O365ConnectorCardActionBase]", - }, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardSection, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.text = kwargs.get("text", None) - self.activity_title = kwargs.get("activity_title", None) - self.activity_subtitle = kwargs.get("activity_subtitle", None) - self.activity_text = kwargs.get("activity_text", None) - self.activity_image = kwargs.get("activity_image", None) - self.activity_image_type = kwargs.get("activity_image_type", None) - self.markdown = kwargs.get("markdown", None) - self.facts = kwargs.get("facts", None) - self.images = kwargs.get("images", None) - self.potential_action = kwargs.get("potential_action", None) - - -class O365ConnectorCardTextInput(O365ConnectorCardInputBase): - """O365 connector card text input. - - :param type: Input type name. Possible values include: 'textInput', - 'dateInput', 'multichoiceInput' - :type type: str or ~botframework.connector.teams.models.enum - :param id: Input Id. It must be unique per entire O365 connector card. - :type id: str - :param is_required: Define if this input is a required field. Default - value is false. - :type is_required: bool - :param title: Input title that will be shown as the placeholder - :type title: str - :param value: Default value for this input field - :type value: str - :param is_multiline: Define if text input is allowed for multiple lines. - Default value is false. - :type is_multiline: bool - :param max_length: Maximum length of text input. Default value is - unlimited. - :type max_length: float - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "id": {"key": "id", "type": "str"}, - "is_required": {"key": "isRequired", "type": "bool"}, - "title": {"key": "title", "type": "str"}, - "value": {"key": "value", "type": "str"}, - "is_multiline": {"key": "isMultiline", "type": "bool"}, - "max_length": {"key": "maxLength", "type": "float"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardTextInput, self).__init__(**kwargs) - self.is_multiline = kwargs.get("is_multiline", None) - self.max_length = kwargs.get("max_length", None) - - -class O365ConnectorCardViewAction(O365ConnectorCardActionBase): - """O365 connector card ViewAction action. - - :param type: Type of the action. Possible values include: 'ViewAction', - 'OpenUri', 'HttpPOST', 'ActionCard' - :type type: str or ~botframework.connector.teams.models.enum - :param name: Name of the action that will be used as button title - :type name: str - :param id: Action Id - :type id: str - :param target: Target urls, only the first url effective for card button - :type target: list[str] - """ - - _attribute_map = { - "type": {"key": "@type", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "id": {"key": "@id", "type": "str"}, - "target": {"key": "target", "type": "[str]"}, - } - - def __init__(self, **kwargs): - super(O365ConnectorCardViewAction, self).__init__(**kwargs) - self.target = kwargs.get("target", None) - - -class SigninStateVerificationQuery(Model): - """Signin state (part of signin action auth flow) verification invoke query. - - :param state: The state string originally received when the signin web - flow is finished with a state posted back to client via tab SDK - microsoftTeams.authentication.notifySuccess(state) - :type state: str - """ - - _attribute_map = { - "state": {"key": "state", "type": "str"}, - } - - def __init__(self, **kwargs): - super(SigninStateVerificationQuery, self).__init__(**kwargs) - self.state = kwargs.get("state", None) - - -class TaskModuleContinueResponse(TaskModuleResponseBase): - """Task Module Response with continue action. - - :param type: Choice of action options when responding to the task/submit - message. Possible values include: 'message', 'continue' - :type type: str or ~botframework.connector.teams.models.enum - :param value: The JSON for the Adaptive card to appear in the task module. - :type value: ~botframework.connector.teams.models.TaskModuleTaskInfo - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - "value": {"key": "value", "type": "TaskModuleTaskInfo"}, - } - - def __init__(self, **kwargs): - super(TaskModuleContinueResponse, self).__init__(**kwargs) - self.value = kwargs.get("value", None) - - -class TaskModuleMessageResponse(TaskModuleResponseBase): - """Task Module response with message action. - - :param type: Choice of action options when responding to the task/submit - message. Possible values include: 'message', 'continue' - :type type: str or ~botframework.connector.teams.models.enum - :param value: Teams will display the value of value in a popup message - box. - :type value: str - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - "value": {"key": "value", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TaskModuleMessageResponse, self).__init__(**kwargs) - self.value = kwargs.get("value", None) - - -class TaskModuleRequest(Model): - """Task module invoke request value payload. - - :param data: User input data. Free payload with key-value pairs. - :type data: object - :param context: Current user context, i.e., the current theme - :type context: - ~botframework.connector.teams.models.TaskModuleRequestContext - """ - - _attribute_map = { - "data": {"key": "data", "type": "object"}, - "context": {"key": "context", "type": "TaskModuleRequestContext"}, - } - - def __init__(self, **kwargs): - super(TaskModuleRequest, self).__init__(**kwargs) - self.data = kwargs.get("data", None) - self.context = kwargs.get("context", None) - - -class TaskModuleRequestContext(Model): - """Current user context, i.e., the current theme. - - :param theme: - :type theme: str - """ - - _attribute_map = { - "theme": {"key": "theme", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TaskModuleRequestContext, self).__init__(**kwargs) - self.theme = kwargs.get("theme", None) - - -class TaskModuleResponse(Model): - """Envelope for Task Module Response. - - :param task: The JSON for the Adaptive card to appear in the task module. - :type task: ~botframework.connector.teams.models.TaskModuleResponseBase - """ - - _attribute_map = { - "task": {"key": "task", "type": "TaskModuleResponseBase"}, - } - - def __init__(self, **kwargs): - super(TaskModuleResponse, self).__init__(**kwargs) - self.task = kwargs.get("task", None) - - -class TaskModuleResponseBase(Model): - """Base class for Task Module responses. - - :param type: Choice of action options when responding to the task/submit - message. Possible values include: 'message', 'continue' - :type type: str or ~botframework.connector.teams.models.enum - """ - - _attribute_map = { - "type": {"key": "type", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TaskModuleResponseBase, self).__init__(**kwargs) - self.type = kwargs.get("type", None) - - -class TaskModuleTaskInfo(Model): - """Metadata for a Task Module. - - :param title: Appears below the app name and to the right of the app icon. - :type title: str - :param height: This can be a number, representing the task module's height - in pixels, or a string, one of: small, medium, large. - :type height: object - :param width: This can be a number, representing the task module's width - in pixels, or a string, one of: small, medium, large. - :type width: object - :param url: The URL of what is loaded as an iframe inside the task module. - One of url or card is required. - :type url: str - :param card: The JSON for the Adaptive card to appear in the task module. - :type card: ~botframework.connector.teams.models.Attachment - :param fallback_url: If a client does not support the task module feature, - this URL is opened in a browser tab. - :type fallback_url: str - :param completion_bot_id: If a client does not support the task module - feature, this URL is opened in a browser tab. - :type completion_bot_id: str - """ - - _attribute_map = { - "title": {"key": "title", "type": "str"}, - "height": {"key": "height", "type": "object"}, - "width": {"key": "width", "type": "object"}, - "url": {"key": "url", "type": "str"}, - "card": {"key": "card", "type": "Attachment"}, - "fallback_url": {"key": "fallbackUrl", "type": "str"}, - "completion_bot_id": {"key": "completionBotId", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TaskModuleTaskInfo, self).__init__(**kwargs) - self.title = kwargs.get("title", None) - self.height = kwargs.get("height", None) - self.width = kwargs.get("width", None) - self.url = kwargs.get("url", None) - self.card = kwargs.get("card", None) - self.fallback_url = kwargs.get("fallback_url", None) - self.completion_bot_id = kwargs.get("completion_bot_id", None) - - -class TeamDetails(Model): - """Details related to a team. - - :param id: Unique identifier representing a team - :type id: str - :param name: Name of team. - :type name: str - :param aad_group_id: Azure Active Directory (AAD) Group Id for the team. - :type aad_group_id: str - :param channel_count: The count of channels in the team. - :type chanel_count: int - :param member_count: The count of members in the team. - :type member_count: int - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "aad_group_id": {"key": "aadGroupId", "type": "str"}, - "channel_count": {"key": "channelCount", "type": "int"}, - "member_count": {"key": "memberCount", "type": "int"}, - } - - def __init__(self, **kwargs): - super(TeamDetails, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.name = kwargs.get("name", None) - self.aad_group_id = kwargs.get("aad_group_id", None) - self.channel_count = kwargs.get("channel_count", None) - self.member_count = kwargs.get("member_count", None) - - -class TeamInfo(Model): - """Describes a team. - - :param id: Unique identifier representing a team - :type id: str - :param name: Name of team. - :type name: str - :param name: Azure AD Teams group ID. - :type name: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "aad_group_id": {"key": "aadGroupId", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TeamInfo, self).__init__(**kwargs) - self.id = kwargs.get("id", None) - self.name = kwargs.get("name", None) - self.aad_group_id = kwargs.get("aad_group_id", None) - - -class TeamsChannelAccount(ChannelAccount): - """Teams channel account detailing user Azure Active Directory details. - - :param id: Channel id for the user or bot on this channel (Example: - joe@smith.com, or @joesmith or 123456) - :type id: str - :param name: Display friendly name - :type name: str - :param given_name: Given name part of the user name. - :type given_name: str - :param surname: Surname part of the user name. - :type surname: str - :param email: Email Id of the user. - :type email: str - :param user_principal_name: Unique user principal name - :type user_principal_name: str - :param tenant_id: Tenant Id of the user. - :type tenant_id: str - :param user_role: User Role of the user. - :type user_role: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "given_name": {"key": "givenName", "type": "str"}, - "surname": {"key": "surname", "type": "str"}, - "email": {"key": "email", "type": "str"}, - "userPrincipalName": {"key": "userPrincipalName", "type": "str"}, - "aad_object_id": {"key": "objectId", "type": "str"}, - "tenant_id": {"key": "tenantId", "type": "str"}, - "user_role": {"key": "userRole", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TeamsChannelAccount, self).__init__(**kwargs) - self.given_name = kwargs.get("given_name", None) - self.surname = kwargs.get("surname", None) - self.email = kwargs.get("email", None) - self.user_principal_name = kwargs.get("userPrincipalName", None) - self.tenant_id = kwargs.get("tenantId", None) - self.user_role = kwargs.get("userRole", None) - - -class TeamsPagedMembersResult(PagedMembersResult): - """Page of members for Teams. - - :param continuation_token: Paging token - :type continuation_token: str - :param members: The Teams Channel Accounts. - :type members: list[~botframework.connector.models.TeamsChannelAccount] - """ - - _attribute_map = { - "continuation_token": {"key": "continuationToken", "type": "str"}, - "members": {"key": "members", "type": "[TeamsChannelAccount]"}, - } - - def __init__(self, **kwargs): - super(TeamsPagedMembersResult, self).__init__(**kwargs) - self.continuation_token = kwargs.get("continuation_token", None) - self.members = kwargs.get("members", None) - - -class TeamsChannelData(Model): - """Channel data specific to messages received in Microsoft Teams. - - :param channel: Information about the channel in which the message was - sent - :type channel: ~botframework.connector.teams.models.ChannelInfo - :param event_type: Type of event. - :type event_type: str - :param team: Information about the team in which the message was sent - :type team: ~botframework.connector.teams.models.TeamInfo - :param notification: Notification settings for the message - :type notification: ~botframework.connector.teams.models.NotificationInfo - :param tenant: Information about the tenant in which the message was sent - :type tenant: ~botframework.connector.teams.models.TenantInfo - """ - - _attribute_map = { - "channel": {"key": "channel", "type": "ChannelInfo"}, - "event_type": {"key": "eventType", "type": "str"}, - "team": {"key": "team", "type": "TeamInfo"}, - "notification": {"key": "notification", "type": "NotificationInfo"}, - "tenant": {"key": "tenant", "type": "TenantInfo"}, - } - - def __init__(self, **kwargs): - super(TeamsChannelData, self).__init__(**kwargs) - self.channel = kwargs.get("channel", None) - # doing camel case here since that's how the data comes in - self.event_type = kwargs.get("event_type", None) - self.team = kwargs.get("team", None) - self.notification = kwargs.get("notification", None) - self.tenant = kwargs.get("tenant", None) - - -class TenantInfo(Model): - """Describes a tenant. - - :param id: Unique identifier representing a tenant - :type id: str - """ - - _attribute_map = { - "id": {"key": "id", "type": "str"}, - } - - def __init__(self, **kwargs): - super(TenantInfo, self).__init__(**kwargs) - self.id = kwargs.get("id", None) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index efa619fe3..3a27e5c51 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1,13 +1,5 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. -# -------------------------------------------------------------------------- +# Licensed under the MIT License. from msrest.serialization import Model from botbuilder.schema import Activity, Attachment, ChannelAccount, PagedMembersResult diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py index e9c7544d7..a21b3971b 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py @@ -1,7 +1,6 @@ # 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" From 1bcd60bbd6b6c3825969fda5d673f037ab560781 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 30 Jun 2020 10:29:47 -0500 Subject: [PATCH 508/616] black --- .../botbuilder/schema/teams/additional_properties.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py index a21b3971b..e9c7544d7 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/additional_properties.py @@ -1,6 +1,7 @@ # 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" From f7dfba0725a684acbed11c80df97b2c4f84a4acc Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 12:02:44 -0300 Subject: [PATCH 509/616] add channelRestored event --- .../core/teams/teams_activity_handler.py | 9 ++++++ .../teams/test_teams_activity_handler.py | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 7b1a88814..e2e289c31 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -324,6 +324,10 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): return await self.on_teams_channel_renamed( channel_data.channel, channel_data.team, turn_context ) + if channel_data.event_type == "channelRestored": + return await self.on_teams_channel_restored( + channel_data.channel, channel_data.team, turn_context + ) if channel_data.event_type == "teamRenamed": return await self.on_teams_team_renamed_activity( channel_data.team, turn_context @@ -437,3 +441,8 @@ async def on_teams_channel_renamed( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): return + + async def on_teams_channel_restored( # pylint: disable=unused-argument + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + return diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 7c70ef36c..ed2596916 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -100,6 +100,14 @@ async def on_teams_channel_renamed( channel_info, team_info, turn_context ) + async def on_teams_channel_restored( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_channel_restored") + return await super().on_teams_channel_restored( + channel_info, team_info, turn_context + ) + async def on_teams_channel_deleted( self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): @@ -313,6 +321,28 @@ async def test_on_teams_channel_renamed_activity(self): assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_channel_renamed" + async def test_on_teams_channel_restored_activity(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelRestored", + "channel": {"id": "asdfqwerty", "name": "channel_restored"} + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_channel_restored" + async def test_on_teams_channel_deleted_activity(self): # arrange activity = Activity( From e9e0199ca5239284421e2612b571936c336c036d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Jul 2020 10:20:34 -0500 Subject: [PATCH 510/616] Slack adapter updates for dialog interactions --- .../botbuilder/adapters/slack/slack_payload.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py index d5d87a225..c05456f69 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py @@ -15,6 +15,12 @@ def __init__(self, **kwargs): self.team: str = kwargs.get("team") self.user: str = kwargs.get("user") self.actions: Optional[List[Action]] = None + self.trigger_id: str = kwargs.get("trigger_id") + self.action_ts: str = kwargs.get("action_ts") + self.submission: str = kwargs.get("submission") + self.callback_id: str = kwargs.get("callback_id") + self.state: str = kwargs.get("state") + self.response_url: str = kwargs.get("response_url") if "message" in kwargs: message = kwargs.get("message") From 7d00209e78e192cc32fa6ec4933b634b7b4e219f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 1 Jul 2020 10:25:51 -0500 Subject: [PATCH 511/616] Remove Slack from the list of channels that support Suggested Actions --- .../botbuilder-dialogs/botbuilder/dialogs/choices/channel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py index 4c1c59d0f..41c313047 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py @@ -32,7 +32,6 @@ def supports_suggested_actions(channel_id: str, button_cnt: int = 100) -> bool: # https://dev.kik.com/#/docs/messaging#text-response-object Channels.kik: 20, Channels.telegram: 100, - Channels.slack: 100, Channels.emulator: 100, Channels.direct_line: 100, Channels.webchat: 100, From 5885ad2c8f5003e2783d7ff06be758697b36c37a Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 12:57:36 -0300 Subject: [PATCH 512/616] Add comments to teams_activity_handler methods --- .../core/teams/teams_activity_handler.py | 282 +++++++++++++++++- 1 file changed, 281 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index e2e289c31..91a3f1128 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -28,6 +28,19 @@ class TeamsActivityHandler(ActivityHandler): async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: + """ + Invoked when an invoke activity is received from the connector. + Invoke activities can be used to communicate many different things. + + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + + .. remarks:: + Invoke activities communicate programmatic commands from a client or channel to a bot. + The meaning of an invoke activity is defined by the "invoke_activity.name" property, + which is meaningful within the scope of a channel. + """ try: if ( not turn_context.activity.name @@ -154,14 +167,35 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: return invoke_exception.create_invoke_response() async def on_sign_in_invoke(self, turn_context: TurnContext): + """ + Invoked when a signIn invoke activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ return await self.on_teams_signin_verify_state(turn_context) async def on_teams_card_action_invoke( self, turn_context: TurnContext ) -> InvokeResponse: + """ + Invoked when an card action invoke activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_signin_verify_state(self, turn_context: TurnContext): + """ + Invoked when a signIn verify state activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_signin_token_exchange(self, turn_context: TurnContext): @@ -172,6 +206,15 @@ async def on_teams_file_consent( turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, ) -> InvokeResponse: + """ + Invoked when a file consent card activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param file_consent_card_response: The response representing the value of the invoke + activity sent when the user acts on a file consent card. + + :returns: An InvokeResponse depending on the action of the file consent card. + """ if file_consent_card_response.action == "accept": await self.on_teams_file_consent_accept( turn_context, file_consent_card_response @@ -194,6 +237,15 @@ async def on_teams_file_consent_accept( # pylint: disable=unused-argument turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, ): + """ + Invoked when a file consent card is accepted by the user. + + :param turn_context: A strongly-typed context object for this turn. + :param file_consent_card_response: The response representing the value of the invoke + activity sent when the user accepts a file consent card. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_file_consent_decline( # pylint: disable=unused-argument @@ -201,31 +253,80 @@ async def on_teams_file_consent_decline( # pylint: disable=unused-argument turn_context: TurnContext, file_consent_card_response: FileConsentCardResponse, ): + """ + Invoked when a file consent card is declined by the user. + + :param turn_context: A strongly-typed context object for this turn. + :param file_consent_card_response: The response representing the value of the invoke + activity sent when the user declines a file consent card. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_o365_connector_card_action( # pylint: disable=unused-argument self, turn_context: TurnContext, query: O365ConnectorCardActionQuery ): + """ + Invoked when a O365 Connector Card Action activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param query: The O365 connector card HttpPOST invoke query. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_app_based_link_query( # pylint: disable=unused-argument self, turn_context: TurnContext, query: AppBasedLinkQuery ) -> MessagingExtensionResponse: + """ + Invoked when an app based link query activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param query: The invoke request body type for app-based link query. + + :returns: The Messaging Extension Response for the query. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_query( # pylint: disable=unused-argument self, turn_context: TurnContext, query: MessagingExtensionQuery ) -> MessagingExtensionResponse: + """ + Invoked when a Messaging Extension Query activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param query: The query for the search command. + + :returns: The Messaging Extension Response for the query. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_select_item( # pylint: disable=unused-argument self, turn_context: TurnContext, query ) -> MessagingExtensionResponse: + """ + Invoked when a messaging extension select item activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param query: The object representing the query. + + :returns: The Messaging Extension Response for the query. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_submit_action_dispatch( self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: + """ + Invoked when a messaging extension submit action dispatch activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param action: The messaging extension action. + + :returns: The Messaging Extension Action Response for the action. + """ if not action.bot_message_preview_action: return await self.on_teams_messaging_extension_submit_action( turn_context, action @@ -249,50 +350,135 @@ async def on_teams_messaging_extension_submit_action_dispatch( async def on_teams_messaging_extension_bot_message_preview_edit( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: + """ + Invoked when a messaging extension bot message preview edit activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param action: The messaging extension action. + + :returns: The Messaging Extension Action Response for the action. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_bot_message_preview_send( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: + """ + Invoked when a messaging extension bot message preview send activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param action: The messaging extension action. + + :returns: The Messaging Extension Action Response for the action. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_submit_action( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: + """ + Invoked when a messaging extension submit action activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param action: The messaging extension action. + + :returns: The Messaging Extension Action Response for the action. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_fetch_task( # pylint: disable=unused-argument self, turn_context: TurnContext, action: MessagingExtensionAction ) -> MessagingExtensionActionResponse: + """ + Invoked when a Messaging Extension Fetch activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param action: The messaging extension action. + + :returns: The Messaging Extension Action Response for the action. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_configuration_query_settings_url( # pylint: disable=unused-argument self, turn_context: TurnContext, query: MessagingExtensionQuery ) -> MessagingExtensionResponse: + """ + Invoked when a messaging extension configuration query setting url activity is received from the connector. + + :param turn_context: A strongly-typed context object for this turn. + :param query: The Messaging extension query. + + :returns: The Messaging Extension Response for the query. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_configuration_setting( # pylint: disable=unused-argument self, turn_context: TurnContext, settings ): + """ + Override this in a derived class to provide logic for when a configuration is set for a messaging extension. + + :param turn_context: A strongly-typed context object for this turn. + :param settings: Object representing the configuration settings. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_messaging_extension_card_button_clicked( # pylint: disable=unused-argument self, turn_context: TurnContext, card_data ): + """ + Override this in a derived class to provide logic for when a card button is clicked in a messaging extension. + + :param turn_context: A strongly-typed context object for this turn. + :param card_data: Object representing the card data. + + :returns: A task that represents the work queued to execute. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_task_module_fetch( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request: TaskModuleRequest ) -> TaskModuleResponse: + """ + Override this in a derived class to provide logic for when a task module is fetched. + + :param turn_context: A strongly-typed context object for this turn. + :param task_module_request: The task module invoke request value payload. + + :returns: A Task Module Response for the request. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_teams_task_module_submit( # pylint: disable=unused-argument self, turn_context: TurnContext, task_module_request: TaskModuleRequest ) -> TaskModuleResponse: + """ + Override this in a derived class to provide logic for when a task module is submitted. + + :param turn_context: A strongly-typed context object for this turn. + :param task_module_request: The task module invoke request value payload. + + :returns: A Task Module Response for the request. + """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) async def on_conversation_update_activity(self, turn_context: TurnContext): + """ + Invoked when a conversation update activity is received from the channel. + Conversation update activities are useful when it comes to responding to users + being added to or removed from the channel. + For example, a bot could respond to a user being added by greeting the user. + + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + .. remarks:: + In a derived class, override this method to add logic that applies + to all conversation update activities. + """ if turn_context.activity.channel_id == Channels.ms_teams: channel_data = TeamsChannelData().deserialize( turn_context.activity.channel_data @@ -338,11 +524,30 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): async def on_teams_channel_created( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): + """ + Invoked when a Channel Created event activity is received from the connector. + Channel Created correspond to the user creating a new channel. + + :param channel_info: The channel info object which describes the channel. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ return async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext ): + """ + Invoked when a Team Renamed event activity is received from the connector. + Team Renamed correspond to the user renaming an existing team. + + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ return async def on_teams_members_added_dispatch( # pylint: disable=unused-argument @@ -351,7 +556,18 @@ async def on_teams_members_added_dispatch( # pylint: disable=unused-argument team_info: TeamInfo, turn_context: TurnContext, ): - + """ + Override this in a derived class to provide logic for when members other than the bot + join the channel, such as your bot's welcome logic. + UseIt will get the associated members with the provided accounts. + + :param members_added: A list of all the accounts added to the channel, as + described by the conversation update activity. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ team_members_added = [] for member in members_added: is_bot = ( @@ -393,6 +609,17 @@ async def on_teams_members_added( # pylint: disable=unused-argument team_info: TeamInfo, turn_context: TurnContext, ): + """ + Override this in a derived class to provide logic for when members other than the bot + join the channel, such as your bot's welcome logic. + + :param teams_members_added: A list of all the members added to the channel, as + described by the conversation update activity. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ teams_members_added = [ ChannelAccount().deserialize(member.serialize()) for member in teams_members_added @@ -407,6 +634,18 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument team_info: TeamInfo, turn_context: TurnContext, ): + """ + Override this in a derived class to provide logic for when members other than the bot + leave the channel, such as your bot's good-bye logic. + It will get the associated members with the provided accounts. + + :param members_removed: A list of all the accounts removed from the channel, as + described by the conversation update activity. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ teams_members_removed = [] for member in members_removed: new_account_json = member.serialize() @@ -426,6 +665,17 @@ async def on_teams_members_removed( # pylint: disable=unused-argument team_info: TeamInfo, turn_context: TurnContext, ): + """ + Override this in a derived class to provide logic for when members other than the bot + leave the channel, such as your bot's good-bye logic. + + :param teams_members_removed: A list of all the members removed from the channel, as + described by the conversation update activity. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ members_removed = [ ChannelAccount().deserialize(member.serialize()) for member in teams_members_removed @@ -435,14 +685,44 @@ async def on_teams_members_removed( # pylint: disable=unused-argument async def on_teams_channel_deleted( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): + """ + Invoked when a Channel Deleted event activity is received from the connector. + Channel Deleted correspond to the user deleting an existing channel. + + :param channel_info: The channel info object which describes the channel. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ return async def on_teams_channel_renamed( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): + """ + Invoked when a Channel Renamed event activity is received from the connector. + Channel Renamed correspond to the user renaming an existing channel. + + :param channel_info: The channel info object which describes the channel. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ return async def on_teams_channel_restored( # pylint: disable=unused-argument self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext ): + """ + Invoked when a Channel Restored event activity is received from the connector. + Channel Restored correspond to the user restoring a previously deleted channel. + + :param channel_info: The channel info object which describes the channel. + :param team_info: The team info object representing the team. + :param turn_context: A strongly-typed context object for this turn. + + :returns: A task that represents the work queued to execute. + """ return From 47d8526e545e97ab76a7e9761aa808b55dceea8a Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 12:58:19 -0300 Subject: [PATCH 513/616] apply black styling --- .../botbuilder-core/tests/teams/test_teams_activity_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index ed2596916..776fccb2b 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -327,7 +327,7 @@ async def test_on_teams_channel_restored_activity(self): type=ActivityTypes.conversation_update, channel_data={ "eventType": "channelRestored", - "channel": {"id": "asdfqwerty", "name": "channel_restored"} + "channel": {"id": "asdfqwerty", "name": "channel_restored"}, }, channel_id=Channels.ms_teams, ) From 66b4188e5a60f0da4d87c888c6d4faeffa4c1922 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 13:45:26 -0300 Subject: [PATCH 514/616] fixed turn_context comments --- .../core/teams/teams_activity_handler.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 91a3f1128..54b707e34 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -32,7 +32,7 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: Invoked when an invoke activity is received from the connector. Invoke activities can be used to communicate many different things. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. @@ -170,7 +170,7 @@ async def on_sign_in_invoke(self, turn_context: TurnContext): """ Invoked when a signIn invoke activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -182,7 +182,7 @@ async def on_teams_card_action_invoke( """ Invoked when an card action invoke activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -192,7 +192,7 @@ async def on_teams_signin_verify_state(self, turn_context: TurnContext): """ Invoked when a signIn verify state activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -209,7 +209,7 @@ async def on_teams_file_consent( """ Invoked when a file consent card activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param file_consent_card_response: The response representing the value of the invoke activity sent when the user acts on a file consent card. @@ -240,7 +240,7 @@ async def on_teams_file_consent_accept( # pylint: disable=unused-argument """ Invoked when a file consent card is accepted by the user. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param file_consent_card_response: The response representing the value of the invoke activity sent when the user accepts a file consent card. @@ -256,7 +256,7 @@ async def on_teams_file_consent_decline( # pylint: disable=unused-argument """ Invoked when a file consent card is declined by the user. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param file_consent_card_response: The response representing the value of the invoke activity sent when the user declines a file consent card. @@ -270,7 +270,7 @@ async def on_teams_o365_connector_card_action( # pylint: disable=unused-argumen """ Invoked when a O365 Connector Card Action activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param query: The O365 connector card HttpPOST invoke query. :returns: A task that represents the work queued to execute. @@ -283,7 +283,7 @@ async def on_teams_app_based_link_query( # pylint: disable=unused-argument """ Invoked when an app based link query activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param query: The invoke request body type for app-based link query. :returns: The Messaging Extension Response for the query. @@ -296,7 +296,7 @@ async def on_teams_messaging_extension_query( # pylint: disable=unused-argument """ Invoked when a Messaging Extension Query activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param query: The query for the search command. :returns: The Messaging Extension Response for the query. @@ -309,7 +309,7 @@ async def on_teams_messaging_extension_select_item( # pylint: disable=unused-ar """ Invoked when a messaging extension select item activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param query: The object representing the query. :returns: The Messaging Extension Response for the query. @@ -322,7 +322,7 @@ async def on_teams_messaging_extension_submit_action_dispatch( """ Invoked when a messaging extension submit action dispatch activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param action: The messaging extension action. :returns: The Messaging Extension Action Response for the action. @@ -353,7 +353,7 @@ async def on_teams_messaging_extension_bot_message_preview_edit( # pylint: disa """ Invoked when a messaging extension bot message preview edit activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param action: The messaging extension action. :returns: The Messaging Extension Action Response for the action. @@ -366,7 +366,7 @@ async def on_teams_messaging_extension_bot_message_preview_send( # pylint: disa """ Invoked when a messaging extension bot message preview send activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param action: The messaging extension action. :returns: The Messaging Extension Action Response for the action. @@ -379,7 +379,7 @@ async def on_teams_messaging_extension_submit_action( # pylint: disable=unused- """ Invoked when a messaging extension submit action activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param action: The messaging extension action. :returns: The Messaging Extension Action Response for the action. @@ -392,7 +392,7 @@ async def on_teams_messaging_extension_fetch_task( # pylint: disable=unused-arg """ Invoked when a Messaging Extension Fetch activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param action: The messaging extension action. :returns: The Messaging Extension Action Response for the action. @@ -405,7 +405,7 @@ async def on_teams_messaging_extension_configuration_query_settings_url( # pyli """ Invoked when a messaging extension configuration query setting url activity is received from the connector. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param query: The Messaging extension query. :returns: The Messaging Extension Response for the query. @@ -418,7 +418,7 @@ async def on_teams_messaging_extension_configuration_setting( # pylint: disable """ Override this in a derived class to provide logic for when a configuration is set for a messaging extension. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param settings: Object representing the configuration settings. :returns: A task that represents the work queued to execute. @@ -431,7 +431,7 @@ async def on_teams_messaging_extension_card_button_clicked( # pylint: disable=u """ Override this in a derived class to provide logic for when a card button is clicked in a messaging extension. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param card_data: Object representing the card data. :returns: A task that represents the work queued to execute. @@ -444,7 +444,7 @@ async def on_teams_task_module_fetch( # pylint: disable=unused-argument """ Override this in a derived class to provide logic for when a task module is fetched. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param task_module_request: The task module invoke request value payload. :returns: A Task Module Response for the request. @@ -457,7 +457,7 @@ async def on_teams_task_module_submit( # pylint: disable=unused-argument """ Override this in a derived class to provide logic for when a task module is submitted. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :param task_module_request: The task module invoke request value payload. :returns: A Task Module Response for the request. @@ -471,7 +471,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): being added to or removed from the channel. For example, a bot could respond to a user being added by greeting the user. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. @@ -530,7 +530,7 @@ async def on_teams_channel_created( # pylint: disable=unused-argument :param channel_info: The channel info object which describes the channel. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -544,7 +544,7 @@ async def on_teams_team_renamed_activity( # pylint: disable=unused-argument Team Renamed correspond to the user renaming an existing team. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -564,7 +564,7 @@ async def on_teams_members_added_dispatch( # pylint: disable=unused-argument :param members_added: A list of all the accounts added to the channel, as described by the conversation update activity. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -616,7 +616,7 @@ async def on_teams_members_added( # pylint: disable=unused-argument :param teams_members_added: A list of all the members added to the channel, as described by the conversation update activity. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -642,7 +642,7 @@ async def on_teams_members_removed_dispatch( # pylint: disable=unused-argument :param members_removed: A list of all the accounts removed from the channel, as described by the conversation update activity. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -672,7 +672,7 @@ async def on_teams_members_removed( # pylint: disable=unused-argument :param teams_members_removed: A list of all the members removed from the channel, as described by the conversation update activity. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -691,7 +691,7 @@ async def on_teams_channel_deleted( # pylint: disable=unused-argument :param channel_info: The channel info object which describes the channel. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -706,7 +706,7 @@ async def on_teams_channel_renamed( # pylint: disable=unused-argument :param channel_info: The channel info object which describes the channel. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ @@ -721,7 +721,7 @@ async def on_teams_channel_restored( # pylint: disable=unused-argument :param channel_info: The channel info object which describes the channel. :param team_info: The team info object representing the team. - :param turn_context: A strongly-typed context object for this turn. + :param turn_context: A context object for this turn. :returns: A task that represents the work queued to execute. """ From 3eb07a5d6260baab4e4cf84505bde83388781212 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 14:56:24 -0300 Subject: [PATCH 515/616] add teams events events added: - teamArchived - teamDeleted - teamHardDeleted - teamRestored - teamUnarchived --- .../core/teams/teams_activity_handler.py | 90 +++++++++++ .../teams/test_teams_activity_handler.py | 140 ++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 7b1a88814..3b728da85 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -324,10 +324,30 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): return await self.on_teams_channel_renamed( channel_data.channel, channel_data.team, turn_context ) + if channel_data.event_type == "teamArchived": + return await self.on_teams_team_archived( + channel_data.team, turn_context + ) + if channel_data.event_type == "teamDeleted": + return await self.on_teams_team_deleted( + channel_data.team, turn_context + ) + if channel_data.event_type == "teamHardDeleted": + return await self.on_teams_team_hard_deleted( + channel_data.team, turn_context + ) if channel_data.event_type == "teamRenamed": return await self.on_teams_team_renamed_activity( channel_data.team, turn_context ) + if channel_data.event_type == "teamRestored": + return await self.on_teams_team_restored( + channel_data.team, turn_context + ) + if channel_data.event_type == "teamUnarchived": + return await self.on_teams_team_unarchived( + channel_data.team, turn_context + ) return await super().on_conversation_update_activity(turn_context) @@ -336,11 +356,81 @@ async def on_teams_channel_created( # pylint: disable=unused-argument ): return + async def on_teams_team_archived( # pylint: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + """ + Invoked when a Team Archived event activity is received from the connector. + Team Archived correspond to the user archiving a team. + + :param team_info: The team info object representing the team. + :param turn_context: A context object for this turn. + + :returns: A task that represents the work queued to execute. + """ + return + + async def on_teams_team_deleted( # pylint: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + """ + Invoked when a Team Deleted event activity is received from the connector. + Team Deleted corresponds to the user deleting a team. + + :param team_info: The team info object representing the team. + :param turn_context: A context object for this turn. + + :returns: A task that represents the work queued to execute. + """ + return + + async def on_teams_team_hard_deleted( # pylint: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + """ + Invoked when a Team Hard Deleted event activity is received from the connector. + Team Hard Deleted corresponds to the user hard deleting a team. + + :param team_info: The team info object representing the team. + :param turn_context: A context object for this turn. + + :returns: A task that represents the work queued to execute. + """ + return + async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext ): return + async def on_teams_team_restored( # pyling: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + """ + Invoked when a Team Restored event activity is received from the connector. + Team Restored corresponds to the user restoring a team. + + :param team_info: The team info object representing the team. + :param turn_context: A context object for this turn. + + :returns: A task that represents the work queued to execute. + """ + return + + async def on_teams_team_unarchived( # pylint: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + """ + Invoked when a Team Unarchived event activity is received from the connector. + Team Unarchived correspond to the user unarchiving a team. + + :param team_info: The team info object representing the team. + :param turn_context: A context object for this turn. + + :returns: A task that represents the work queued to execute. + """ + return + async def on_teams_members_added_dispatch( # pylint: disable=unused-argument self, members_added: [ChannelAccount], diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 7c70ef36c..30fdac818 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -108,12 +108,42 @@ async def on_teams_channel_deleted( channel_info, team_info, turn_context ) + async def on_teams_team_archived( + self, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_team_archived") + return await super().on_teams_team_archived(team_info, turn_context) + + async def on_teams_team_deleted( + self, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_team_deleted") + return await super().on_teams_team_deleted(team_info, turn_context) + + async def on_teams_team_hard_deleted( + self, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_team_hard_deleted") + return await super().on_teams_team_hard_deleted(team_info, turn_context) + async def on_teams_team_renamed_activity( self, team_info: TeamInfo, turn_context: TurnContext ): self.record.append("on_teams_team_renamed_activity") return await super().on_teams_team_renamed_activity(team_info, turn_context) + async def on_teams_team_restored( + self, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_team_restored") + return await super().on_teams_team_restored(team_info, turn_context) + + async def on_teams_team_unarchived( + self, team_info: TeamInfo, turn_context: TurnContext + ): + self.record.append("on_teams_team_unarchived") + return await super().on_teams_team_unarchived(team_info, turn_context) + async def on_invoke_activity(self, turn_context: TurnContext): self.record.append("on_invoke_activity") return await super().on_invoke_activity(turn_context) @@ -335,6 +365,72 @@ async def test_on_teams_channel_deleted_activity(self): assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_channel_deleted" + async def test_on_teams_team_archived(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamArchived", + "team": {"id": "team_id_1", "name": "archived_team_name"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_archived" + + async def test_on_teams_team_deleted(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamDeleted", + "team": {"id": "team_id_1", "name": "deleted_team_name"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_deleted" + + async def test_on_teams_team_hard_deleted(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamHardDeleted", + "team": {"id": "team_id_1", "name": "hard_deleted_team_name"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_hard_deleted" + async def test_on_teams_team_renamed_activity(self): # arrange activity = Activity( @@ -357,6 +453,50 @@ async def test_on_teams_team_renamed_activity(self): assert bot.record[0] == "on_conversation_update_activity" assert bot.record[1] == "on_teams_team_renamed_activity" + async def test_on_teams_team_restored(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamRestored", + "team": {"id": "team_id_1", "name": "restored_team_name"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_restored" + + async def test_on_teams_team_unarchived(self): + # arrange + activity = Activity( + type=ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamUnarchived", + "team": {"id": "team_id_1", "name": "unarchived_team_name"}, + }, + channel_id=Channels.ms_teams, + ) + + turn_context = TurnContext(NotImplementedAdapter(), activity) + + # Act + bot = TestingTeamsActivityHandler() + await bot.on_turn(turn_context) + + # Assert + assert len(bot.record) == 2 + assert bot.record[0] == "on_conversation_update_activity" + assert bot.record[1] == "on_teams_team_unarchived" + async def test_on_teams_members_added_activity(self): # arrange activity = Activity( From 5f8cec081570cb69f0757fd5fdbe9acf9c255a22 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 14:59:48 -0300 Subject: [PATCH 516/616] fix comments return type --- .../botbuilder/core/teams/teams_activity_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 54b707e34..d9a306dcf 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -34,7 +34,7 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: :param turn_context: A context object for this turn. - :returns: A task that represents the work queued to execute. + :returns: An InvokeResponse that represents the work queued to execute. .. remarks:: Invoke activities communicate programmatic commands from a client or channel to a bot. @@ -184,7 +184,7 @@ async def on_teams_card_action_invoke( :param turn_context: A context object for this turn. - :returns: A task that represents the work queued to execute. + :returns: An InvokeResponse that represents the work queued to execute. """ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED) @@ -559,7 +559,7 @@ async def on_teams_members_added_dispatch( # pylint: disable=unused-argument """ Override this in a derived class to provide logic for when members other than the bot join the channel, such as your bot's welcome logic. - UseIt will get the associated members with the provided accounts. + It will get the associated members with the provided accounts. :param members_added: A list of all the accounts added to the channel, as described by the conversation update activity. From ac590c1d34ab6e29e24670ab231e2842ac6f686f Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Wed, 1 Jul 2020 15:27:40 -0300 Subject: [PATCH 517/616] fix pylint comment --- .../botbuilder/core/teams/teams_activity_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 3b728da85..4deddd9dc 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -403,7 +403,7 @@ async def on_teams_team_renamed_activity( # pylint: disable=unused-argument ): return - async def on_teams_team_restored( # pyling: disable=unused-argument + async def on_teams_team_restored( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext ): """ From a141a9f4aaab8ee7c43f945845a593bf960cb9fc Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Thu, 2 Jul 2020 16:40:22 -0300 Subject: [PATCH 518/616] update bot_state --- .../botbuilder/core/bot_state.py | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 3c6b79329..0e38e9af0 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -6,6 +6,7 @@ from typing import Callable, Dict, Union from jsonpickle.pickler import Pickler from botbuilder.core.state_property_accessor import StatePropertyAccessor +from .bot_assert import BotAssert from .turn_context import TurnContext from .storage import Storage from .property_manager import PropertyManager @@ -61,6 +62,18 @@ def __init__(self, storage: Storage, context_service_key: str): self._storage = storage self._context_service_key = context_service_key + def get_cached_state(self, turn_context: TurnContext): + """ + Gets the cached bot state instance that wraps the raw cached data for this "BotState" + from the turn context. + + :param turn_context: The context object for this turn. + :type turn_context: :class:`TurnContext` + :return: The cached bot state instance. + """ + BotAssert.context_not_none(turn_context) + return turn_context.turn_state.get(self._context_service_key) + def create_property(self, name: str) -> StatePropertyAccessor: """ Creates a property definition and registers it with this :class:`BotState`. @@ -75,7 +88,8 @@ def create_property(self, name: str) -> StatePropertyAccessor: return BotStatePropertyAccessor(self, name) def get(self, turn_context: TurnContext) -> Dict[str, object]: - cached = turn_context.turn_state.get(self._context_service_key) + BotAssert.context_not_none(turn_context) + cached = self.get_cached_state(turn_context) return getattr(cached, "state", None) @@ -88,10 +102,9 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: :param force: Optional, true to bypass the cache :type force: bool """ - if turn_context is None: - raise TypeError("BotState.load(): turn_context cannot be None.") + BotAssert.context_not_none(turn_context) - cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state = self.get_cached_state(turn_context) storage_key = self.get_storage_key(turn_context) if force or not cached_state or not cached_state.state: @@ -111,10 +124,9 @@ async def save_changes( :param force: Optional, true to save state to storage whether or not there are changes :type force: bool """ - if turn_context is None: - raise TypeError("BotState.save_changes(): turn_context cannot be None.") + BotAssert.context_not_none(turn_context) - cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state = self.get_cached_state(turn_context) if force or (cached_state is not None and cached_state.is_changed): storage_key = self.get_storage_key(turn_context) @@ -134,8 +146,7 @@ async def clear_state(self, turn_context: TurnContext): .. remarks:: This function must be called in order for the cleared state to be persisted to the underlying store. """ - if turn_context is None: - raise TypeError("BotState.clear_state(): turn_context cannot be None.") + BotAssert.context_not_none(turn_context) # Explicitly setting the hash will mean IsChanged is always true. And that will force a Save. cache_value = CachedBotState() @@ -151,8 +162,7 @@ async def delete(self, turn_context: TurnContext) -> None: :return: None """ - if turn_context is None: - raise TypeError("BotState.delete(): turn_context cannot be None.") + BotAssert.context_not_none(turn_context) turn_context.turn_state.pop(self._context_service_key) @@ -174,15 +184,12 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str :return: The value of the property """ - if turn_context is None: - raise TypeError( - "BotState.get_property_value(): turn_context cannot be None." - ) + BotAssert.context_not_none(turn_context) if not property_name: raise TypeError( "BotState.get_property_value(): property_name cannot be None." ) - cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state = self.get_cached_state(turn_context) # if there is no value, this will throw, to signal to IPropertyAccesor that a default value should be computed # This allows this to work with value types @@ -201,11 +208,10 @@ async def delete_property_value( :return: None """ - if turn_context is None: - raise TypeError("BotState.delete_property(): turn_context cannot be None.") + BotAssert.context_not_none(turn_context) if not property_name: raise TypeError("BotState.delete_property(): property_name cannot be None.") - cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state = self.get_cached_state(turn_context) del cached_state.state[property_name] async def set_property_value( @@ -223,11 +229,10 @@ async def set_property_value( :return: None """ - if turn_context is None: - raise TypeError("BotState.delete_property(): turn_context cannot be None.") + BotAssert.context_not_none(turn_context) if not property_name: raise TypeError("BotState.delete_property(): property_name cannot be None.") - cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state = self.get_cached_state(turn_context) cached_state.state[property_name] = value From a38946581f8d40d5a2885ca13fe55cc313babebe Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Fri, 3 Jul 2020 12:29:37 -0300 Subject: [PATCH 519/616] add test_bot_state_get_cached_state and update test_bot_state_get --- .../botbuilder-core/tests/test_bot_state.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index 2c0eb815e..fdf8ed6fa 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -473,13 +473,32 @@ async def test_bot_state_get(self): storage = MemoryStorage({}) - conversation_state = ConversationState(storage) + test_bot_state = BotStateForTest(storage) ( - await conversation_state.create_property("test-name").get( + await test_bot_state.create_property("test-name").get( turn_context, lambda: TestPocoState() ) ).value = "test-value" - result = conversation_state.get(turn_context) + result = test_bot_state.get(turn_context) assert result["test-name"].value == "test-value" + + async def test_bot_state_get_cached_state(self): + # pylint: disable=unnecessary-lambda + turn_context = TestUtilities.create_empty_context() + turn_context.activity.conversation = ConversationAccount(id="1234") + + storage = MemoryStorage({}) + + test_bot_state = BotStateForTest(storage) + ( + await test_bot_state.create_property("test-name").get( + turn_context, lambda: TestPocoState() + ) + ).value = "test-value" + + result = test_bot_state.get_cached_state(turn_context) + + assert result is not None + assert result == test_bot_state.get_cached_state(turn_context) From 8cf7995cd486f62f1440fd0b91beda6b0877b50c Mon Sep 17 00:00:00 2001 From: Den Scollo Date: Mon, 6 Jul 2020 10:59:14 -0300 Subject: [PATCH 520/616] Fix conflicts on teams activity handler file --- .../botbuilder/core/teams/teams_activity_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index baebcf9ad..657fff9cb 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -521,6 +521,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): if channel_data.event_type == "teamHardDeleted": return await self.on_teams_team_hard_deleted( channel_data.team, turn_context + ) if channel_data.event_type == "channelRestored": return await self.on_teams_channel_restored( channel_data.channel, channel_data.team, turn_context From 6df2a36dafa2142724e4397a994f681497ab18f0 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 6 Jul 2020 10:19:19 -0500 Subject: [PATCH 521/616] Added ApplicationInsights Queue size setting --- .../application_insights_telemetry_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index e0bae05ca..2ed566b4a 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -38,13 +38,18 @@ def __init__( instrumentation_key: str, telemetry_client: TelemetryClient = None, telemetry_processor: Callable[[object, object], bool] = None, + client_queue_size: int = None, ): self._instrumentation_key = instrumentation_key + self._client = ( telemetry_client if telemetry_client is not None else TelemetryClient(self._instrumentation_key) ) + if client_queue_size: + self._client.channel.queue.max_queue_length = client_queue_size + # Telemetry Processor processor = ( telemetry_processor From b6756f72fcbd9b3048c5822087a05c492401c97d Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Mon, 6 Jul 2020 12:29:26 -0300 Subject: [PATCH 522/616] pylint warning too-many-lines --- .../botbuilder/core/teams/teams_activity_handler.py | 2 ++ .../tests/teams/test_teams_activity_handler.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index 657fff9cb..c96c60483 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +# pylint: disable=too-many-lines + from http import HTTPStatus from botbuilder.schema import ChannelAccount, ErrorResponseException, SignInConstants from botbuilder.core import ActivityHandler, InvokeResponse diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index cc7eabb9d..63b265fba 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -1,4 +1,7 @@ -from typing import List +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# pylint: disable=too-many-lines import aiounittest from botbuilder.core import BotAdapter, TurnContext @@ -26,6 +29,7 @@ ) from botframework.connector import Channels from simple_adapter import SimpleAdapter +from typing import List class TestingTeamsActivityHandler(TeamsActivityHandler): From 6378087ed079ae244436c5145bcd009f82822954 Mon Sep 17 00:00:00 2001 From: Santiago Grangetto Date: Mon, 6 Jul 2020 12:36:38 -0300 Subject: [PATCH 523/616] fix import order --- .../botbuilder-core/tests/teams/test_teams_activity_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py index 63b265fba..3a2f2318c 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py @@ -3,6 +3,7 @@ # pylint: disable=too-many-lines +from typing import List import aiounittest from botbuilder.core import BotAdapter, TurnContext from botbuilder.core.teams import TeamsActivityHandler @@ -29,7 +30,6 @@ ) from botframework.connector import Channels from simple_adapter import SimpleAdapter -from typing import List class TestingTeamsActivityHandler(TeamsActivityHandler): From 3d219a51230cedfee2b38a39555600d584046a5e Mon Sep 17 00:00:00 2001 From: Perzan <46938038+Perzan@users.noreply.github.com> Date: Tue, 7 Jul 2020 12:25:41 -0400 Subject: [PATCH 524/616] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 60add0019..880e6880f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ For more information jump to a section below. * [Getting started](#getting-started) * [Getting support and providing feedback](#getting-support-and-providing-feedback) * [Contributing and our code of conduct](contributing-and-our-code-of-conduct) -* [Reporting security sssues](#reporting-security-issues) +* [Reporting security issues](#reporting-security-issues) ## Build Status From 8b5d8aa7d3bc508463027e432f7bd582a33b17ba Mon Sep 17 00:00:00 2001 From: Denise Scollo Date: Tue, 7 Jul 2020 13:28:33 -0300 Subject: [PATCH 525/616] Update telemetry logger to include attachments (#1221) * add attachments to telemetry logger * apply black style Co-authored-by: Santiago Grangetto --- .../botbuilder-core/botbuilder/core/telemetry_constants.py | 1 + .../botbuilder/core/telemetry_logger_middleware.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/telemetry_constants.py b/libraries/botbuilder-core/botbuilder/core/telemetry_constants.py index 1ae0f1816..a67a56fbd 100644 --- a/libraries/botbuilder-core/botbuilder/core/telemetry_constants.py +++ b/libraries/botbuilder-core/botbuilder/core/telemetry_constants.py @@ -5,6 +5,7 @@ class TelemetryConstants: """Telemetry logger property names.""" + ATTACHMENTS_PROPERTY: str = "attachments" CHANNEL_ID_PROPERTY: str = "channelId" CONVERSATION_ID_PROPERTY: str = "conversationId" CONVERSATION_NAME_PROPERTY: str = "conversationName" diff --git a/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py b/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py index f1539f48c..cac90c94f 100644 --- a/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py @@ -211,6 +211,10 @@ async def fill_send_event_properties( # Use the LogPersonalInformation flag to toggle logging PII data, text and user name are common examples if self.log_personal_information: + if activity.attachments and activity.attachments.strip(): + properties[ + TelemetryConstants.ATTACHMENTS_PROPERTY + ] = activity.attachments if activity.from_property.name and activity.from_property.name.strip(): properties[ TelemetryConstants.FROM_NAME_PROPERTY From 2c892e9e8d3d54a3a5f9ae30da34debb541a8485 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 7 Jul 2020 11:47:21 -0500 Subject: [PATCH 526/616] Initial CODEOWNERS --- .github/CODEOWNERS | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..382a6f71c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,39 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +# Bot Framework SDK notes: +# The first code owners for a file or library are the primary approvers. +# The later code owners represent a "escalation path" in case the primary code owners are unavailable. +# - @microsoft/bb-python will also never receive a request for a PR review and should be manually requested +# for PRs that only trigger the Global rule ("*") +# - @microsoft/bf-admin should never receive a request for a PR review + +# Global rule: +* @microsoft/bb-python @microsoft/bf-admin + +# Adapters +/libraries/botbuilder-adapters-slack/** @garypretty @microsoft/bb-python @microsoft/bf-admin + +# Platform Integration Libaries +/libraries/botbuilder-integration-aiohttp/** @microsoft/bf-admin @axelsrz @tracyboehrer +/libraries/botbuilder-integration-applicationinsights-aiohttp/** @microsoft/bf-admin @axelsrz @tracyboehrer + +# BotBuilder libraries +/libraries/botbuilder-ai/botbuilder/ai/luis/** @microsoft/bf-admin @bb-python @munozemilio +/libraries/botbuilder-ai/botbuilder/ai/qna/** @microsoft/bf-admin @bb-python @johnataylor +/libraries/botbuilder-applicationinsights/** @microsoft/bf-admin @bb-python @munozemilio +/libraries/botbuilder-core/** @johnataylor @microsoft/bb-python @microsoft/bf-admin +/libraries/botbuilder-dialogs/** @johnataylor @microsoft/bb-python @microsoft/bf-admin +/libraries/botframework-connector/** @microsoft/bf-admin @bb-python @johnataylor +/libraries/botframework-connector/botframework/connector/auth/** @microsoft/bf-admin @bb-python @bf-auth +/libraries/botbuilder-streaming/** @microsoft/bf-admin @microsoft/bf-streaming From 03eb7a12fd5f25c66a9f172aaa0d0348d4a1324c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 7 Jul 2020 11:54:01 -0500 Subject: [PATCH 527/616] Added bf-teams to CODEOWNERS --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 382a6f71c..a7461df2b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -33,6 +33,7 @@ /libraries/botbuilder-ai/botbuilder/ai/qna/** @microsoft/bf-admin @bb-python @johnataylor /libraries/botbuilder-applicationinsights/** @microsoft/bf-admin @bb-python @munozemilio /libraries/botbuilder-core/** @johnataylor @microsoft/bb-python @microsoft/bf-admin +/libraries/botbuilder-core/botbuilder/core/teams/** @microsoft/bb-python @microsoft/bf-admin @microsoft/bf-teams /libraries/botbuilder-dialogs/** @johnataylor @microsoft/bb-python @microsoft/bf-admin /libraries/botframework-connector/** @microsoft/bf-admin @bb-python @johnataylor /libraries/botframework-connector/botframework/connector/auth/** @microsoft/bf-admin @bb-python @bf-auth From c694955f054edc23d2c3104f0238e10ee35472af Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 7 Jul 2020 10:59:31 -0700 Subject: [PATCH 528/616] Adding use_bot_state --- .../botbuilder/core/adapter_extensions.py | 34 +++++++++++++++++++ .../core/register_class_middleware.py | 6 ++-- .../tests/test_dialogextensions.py | 2 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py b/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py index 335394c8d..03ec37ad7 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from botbuilder.core import ( BotAdapter, + BotState, Storage, RegisterClassMiddleware, UserState, @@ -23,6 +24,39 @@ def use_storage(adapter: BotAdapter, storage: Storage) -> BotAdapter: """ return adapter.use(RegisterClassMiddleware(storage)) + @staticmethod + def use_bot_state( + bot_adapter: BotAdapter, *bot_states: BotState, auto: bool = True + ) -> BotAdapter: + """ + Registers bot state object into the TurnContext. The botstate will be available via the turn context. + + :param bot_adapter: The BotAdapter on which to register the state objects. + :param bot_states: One or more BotState objects to register. + :return: The updated adapter. + """ + if not bot_states: + raise TypeError("At least one BotAdapter is required") + + for bot_state in bot_states: + bot_adapter.use( + RegisterClassMiddleware( + bot_state, AdapterExtensions.fullname(bot_state) + ) + ) + + if auto: + bot_adapter.use(AutoSaveStateMiddleware(bot_states)) + + return bot_adapter + + @staticmethod + def fullname(obj): + module = obj.__class__.__module__ + if module is None or module == str.__class__.__module__: + return obj.__class__.__name__ # Avoid reporting __builtin__ + return module + "." + obj.__class__.__name__ + @staticmethod def use_state( adapter: BotAdapter, diff --git a/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py b/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py index 332f56077..38be1f46b 100644 --- a/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/register_class_middleware.py @@ -10,8 +10,9 @@ class RegisterClassMiddleware(Middleware): Middleware for adding an object to or registering a service with the current turn context. """ - def __init__(self, service): + def __init__(self, service, key: str = None): self.service = service + self._key = key async def on_turn( self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] @@ -19,7 +20,8 @@ async def on_turn( # C# has TurnStateCollection with has overrides for adding items # to TurnState. Python does not. In C#'s case, there is an 'Add' # to handle adding object, and that uses the fully qualified class name. - context.turn_state[self.fullname(self.service)] = self.service + key = self._key or self.fullname(self.service) + context.turn_state[key] = self.service await logic() @staticmethod diff --git a/libraries/botbuilder-dialogs/tests/test_dialogextensions.py b/libraries/botbuilder-dialogs/tests/test_dialogextensions.py index 2899b859c..cdad45c31 100644 --- a/libraries/botbuilder-dialogs/tests/test_dialogextensions.py +++ b/libraries/botbuilder-dialogs/tests/test_dialogextensions.py @@ -221,7 +221,7 @@ async def capture_eoc( logic, TestAdapter.create_conversation_reference(conversation_id) ) AdapterExtensions.use_storage(adapter, storage) - AdapterExtensions.use_state(adapter, user_state, convo_state) + AdapterExtensions.use_bot_state(adapter, user_state, convo_state) adapter.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) return TestFlow(None, adapter) From 912f36b9bae560968e5b499770704de17f7a04fd Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 7 Jul 2020 11:14:06 -0700 Subject: [PATCH 529/616] Adding deprecation warning --- .../botbuilder/core/adapter_extensions.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py b/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py index 03ec37ad7..db13d74b5 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/adapter_extensions.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from warnings import warn + from botbuilder.core import ( BotAdapter, BotState, @@ -65,7 +67,7 @@ def use_state( auto: bool = True, ) -> BotAdapter: """ - Registers user and conversation state objects with the adapter. These objects will be available via + [DEPRECATED] Registers user and conversation state objects with the adapter. These objects will be available via the turn context's `turn_state` property. :param adapter: The BotAdapter on which to register the state objects. @@ -74,6 +76,11 @@ def use_state( :param auto: True to automatically persist state each turn. :return: The BotAdapter """ + warn( + "This method is deprecated in 4.9. You should use the method .use_bot_state() instead.", + DeprecationWarning, + ) + if not adapter: raise TypeError("BotAdapter is required") From 0891351134e4c9762bff0f935a85acad9d4de5cc Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 7 Jul 2020 16:35:52 -0700 Subject: [PATCH 530/616] Potential arlington support for the future --- .../botframework/connector/auth/government_constants.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/auth/government_constants.py b/libraries/botframework-connector/botframework/connector/auth/government_constants.py index 8dcb19b34..0d768397a 100644 --- a/libraries/botframework-connector/botframework/connector/auth/government_constants.py +++ b/libraries/botframework-connector/botframework/connector/auth/government_constants.py @@ -15,9 +15,7 @@ class GovernmentConstants(ABC): TO CHANNEL FROM BOT: Login URL """ TO_CHANNEL_FROM_BOT_LOGIN_URL = ( - "https://login.microsoftonline.us/" - "cab8a31a-1906-4287-a0d8-4eef66b95f6e/" - "oauth2/v2.0/token" + "https://login.microsoftonline.us/MicrosoftServices.onmicrosoft.us" ) """ From bead8859f91c4d934b1fa41e151e01e599b016b3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 9 Jul 2020 23:09:39 -0700 Subject: [PATCH 531/616] return ResourceResponse --- .../botbuilder/core/skills/skill_handler.py | 15 ++++++-- .../tests/skills/test_skill_handler.py | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 2f7ef72d6..0735e0d84 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -151,7 +151,14 @@ async def _process_activity( if not skill_conversation_reference: raise KeyError("SkillConversationReference not found") + if not skill_conversation_reference.conversation_reference: + raise KeyError("conversationReference not found") + + # If an activity is sent, return the ResourceResponse + resource_response: ResourceResponse = None + async def callback(context: TurnContext): + nonlocal resource_response context.turn_state[ SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY ] = skill_conversation_reference @@ -177,7 +184,7 @@ async def callback(context: TurnContext): self._apply_event_to_turn_context_activity(context, activity) await self._bot.on_turn(context) else: - await context.send_activity(activity) + resource_response = await context.send_activity(activity) await self._adapter.continue_conversation( skill_conversation_reference.conversation_reference, @@ -185,7 +192,11 @@ async def callback(context: TurnContext): claims_identity=claims_identity, audience=skill_conversation_reference.oauth_scope, ) - return ResourceResponse(id=str(uuid4())) + + if not resource_response: + resource_response = ResourceResponse(id=str(uuid4())) + + return resource_response @staticmethod def _apply_eoc_to_turn_context_activity( diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 6fd9e1225..77b8728af 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -238,6 +238,42 @@ async def test_on_send_to_conversation(self): ) assert activity.caller_id is None + async def test_forwarding_on_send_to_conversation(self): + self._conversation_id = await self._test_id_factory.create_skill_conversation_id( + self._conversation_reference + ) + + resource_response_id = "rId" + + async def side_effect( + *arg_list, **args_dict + ): # pylint: disable=unused-argument + fake_context = Mock() + fake_context.turn_state = {} + fake_context.send_activity = MagicMock(return_value=Future()) + fake_context.send_activity.return_value.set_result( + ResourceResponse(id=resource_response_id) + ) + await arg_list[1](fake_context) + + mock_adapter = Mock() + mock_adapter.continue_conversation = side_effect + mock_adapter.send_activities = MagicMock(return_value=Future()) + mock_adapter.send_activities.return_value.set_result([]) + + sut = self.create_skill_handler_for_testing(mock_adapter) + + activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) + TurnContext.apply_conversation_reference(activity, self._conversation_reference) + + assert not activity.caller_id + + response = await sut.test_on_send_to_conversation( + self._claims_identity, self._conversation_id, activity + ) + + assert response.id is resource_response_id + async def test_on_reply_to_activity(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( self._conversation_reference From f26ad85b36908ceff012fa1ebbe6cb7b083ee336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 10 Jul 2020 14:35:45 -0700 Subject: [PATCH 532/616] Changed Python versions to always use latest on Azure (#1243) * Update python 3.7 and 3.6 versions * Trying x numbering for latest version --- pipelines/botbuilder-python-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index 5d1186c65..b148cc5ad 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -6,9 +6,9 @@ variables: COVERALLS_GIT_COMMIT: $(Build.SourceVersion) COVERALLS_SERVICE_JOB_ID: $(Build.BuildId) COVERALLS_SERVICE_NAME: python-ci - python.36: 3.6.10 - python.37: 3.7.7 - python.38: 3.8.3 + python.36: 3.6.x + python.37: 3.7.x + python.38: 3.8.x # PythonCoverallsToken: get this from Azure jobs: From 1bce520ac40ec9efedcbee002cae00848b5a74f8 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 13 Jul 2020 09:04:35 -0500 Subject: [PATCH 533/616] Implement hash for App Insights session ID --- .../processor/telemetry_processor.py | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/processor/telemetry_processor.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/processor/telemetry_processor.py index 7a15acb16..f03588c82 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/processor/telemetry_processor.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/processor/telemetry_processor.py @@ -1,7 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import base64 import json from abc import ABC, abstractmethod +from _sha256 import sha256 class TelemetryProcessor(ABC): @@ -11,8 +13,9 @@ class TelemetryProcessor(ABC): def activity_json(self) -> json: """Retrieve the request body as json (Activity).""" body_text = self.get_request_body() - body = json.loads(body_text) if body_text is not None else None - return body + if body_text: + return body_text if isinstance(body_text, dict) else json.loads(body_text) + return None @abstractmethod def can_process(self) -> bool: @@ -67,15 +70,34 @@ def __call__(self, data, context) -> bool: conversation = ( post_data["conversation"] if "conversation" in post_data else None ) - conversation_id = conversation["id"] if "id" in conversation else None + + session_id = "" + if "id" in conversation: + conversation_id = conversation["id"] + session_id = base64.b64encode( + sha256(conversation_id.encode("utf-8")).digest() + ).decode() + + # Set the user id on the Application Insights telemetry item. context.user.id = channel_id + user_id - context.session.id = conversation_id - # Additional bot-specific properties + # Set the session id on the Application Insights telemetry item. + # Hashed ID is used due to max session ID length for App Insights session Id + context.session.id = session_id + + # Set the activity id: + # https://github.com/Microsoft/botframework-obi/blob/master/botframework-activity/botframework-activity.md#id if "id" in post_data: data.properties["activityId"] = post_data["id"] + + # Set the channel id: + # https://github.com/Microsoft/botframework-obi/blob/master/botframework-activity/botframework-activity.md#channel-id if "channelId" in post_data: data.properties["channelId"] = post_data["channelId"] + + # Set the activity type: + # https://github.com/Microsoft/botframework-obi/blob/master/botframework-activity/botframework-activity.md#type if "type" in post_data: data.properties["activityType"] = post_data["type"] + return True From b0e63326f5686a366eb445e9ce9a5d32dec6d437 Mon Sep 17 00:00:00 2001 From: Kyle Delaney Date: Mon, 13 Jul 2020 11:35:56 -0700 Subject: [PATCH 534/616] Make ActivityPrompt a normal class - remove ABC Parity with JS: https://github.com/microsoft/botbuilder-js/pull/744 See also: https://github.com/microsoft/botbuilder-dotnet/pull/4263 --- .../botbuilder/dialogs/prompts/activity_prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index 70e02f457..6170852f7 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -20,7 +20,7 @@ from .prompt_validator_context import PromptValidatorContext -class ActivityPrompt(Dialog, ABC): +class ActivityPrompt(Dialog): """ Waits for an activity to be received. From 985969d9cc2fdae274a54ca68b4b220250c4f868 Mon Sep 17 00:00:00 2001 From: Steven Gum <14935595+stevengum@users.noreply.github.com> Date: Mon, 13 Jul 2020 18:21:04 -0700 Subject: [PATCH 535/616] update CODEOWNERS (#1250) --- .github/CODEOWNERS | 99 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 24 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a7461df2b..4ccfae130 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,30 +11,81 @@ # In each subsection folders are ordered first by depth, then alphabetically. # This should make it easy to add new rules without breaking existing ones. -# Bot Framework SDK notes: -# The first code owners for a file or library are the primary approvers. -# The later code owners represent a "escalation path" in case the primary code owners are unavailable. -# - @microsoft/bb-python will also never receive a request for a PR review and should be manually requested -# for PRs that only trigger the Global rule ("*") -# - @microsoft/bf-admin should never receive a request for a PR review - # Global rule: -* @microsoft/bb-python @microsoft/bf-admin +* @microsoft/bb-python + +# Functional tests +/libraries/functional-tests/** @tracyboehrer # Adapters -/libraries/botbuilder-adapters-slack/** @garypretty @microsoft/bb-python @microsoft/bf-admin - -# Platform Integration Libaries -/libraries/botbuilder-integration-aiohttp/** @microsoft/bf-admin @axelsrz @tracyboehrer -/libraries/botbuilder-integration-applicationinsights-aiohttp/** @microsoft/bf-admin @axelsrz @tracyboehrer - -# BotBuilder libraries -/libraries/botbuilder-ai/botbuilder/ai/luis/** @microsoft/bf-admin @bb-python @munozemilio -/libraries/botbuilder-ai/botbuilder/ai/qna/** @microsoft/bf-admin @bb-python @johnataylor -/libraries/botbuilder-applicationinsights/** @microsoft/bf-admin @bb-python @munozemilio -/libraries/botbuilder-core/** @johnataylor @microsoft/bb-python @microsoft/bf-admin -/libraries/botbuilder-core/botbuilder/core/teams/** @microsoft/bb-python @microsoft/bf-admin @microsoft/bf-teams -/libraries/botbuilder-dialogs/** @johnataylor @microsoft/bb-python @microsoft/bf-admin -/libraries/botframework-connector/** @microsoft/bf-admin @bb-python @johnataylor -/libraries/botframework-connector/botframework/connector/auth/** @microsoft/bf-admin @bb-python @bf-auth -/libraries/botbuilder-streaming/** @microsoft/bf-admin @microsoft/bf-streaming +/libraries/botbuilder-adapters-slack/** @tracyboehrer @garypretty + +# Platform Integration Libaries (aiohttp) +/libraries/botbuilder-integration-aiohttp/** @microsoft/bb-python-integration +/libraries/botbuilder-integration-applicationinsights-aiohttp/** @microsoft/bb-python-integration @garypretty + +# Application Insights/Telemetry +/libraries/botbuilder-applicationinsights/** @axelsrz @garypretty + +# AI: LUIS + QnA Maker +/libraries/botbuilder-ai/** @microsoft/bf-cog-services + +# Azure (Storage) +/libraries/botbuilder-azure/** @tracyboehrer @EricDahlvang + +# Adaptive Dialogs +/libraries/botbuilder-dialogs-*/** @tracyboehrer @microsoft/bf-adaptive + +# AdaptiveExpressions & LanguageGeneration libraries +/libraries/adaptive-expressions/** @axelsrz @microsoft/bf-adaptive +/libraries/botbuilder-lg/** @axelsrz @microsoft/bf-adaptive + +# BotBuilder Testing +/libraries/botbuilder-testing/** @axelsrz @gabog + +# Streaming library +/libraries/botbuilder-streaming/** @microsoft/bf-streaming + +# BotBuilder library +/libraries/botbuilder-core/** @axelsrz @gabog @johnataylor + +# BotBuilder Dialogs +/libraries/botbuilder-dialogs/** @microsoft/bf-dialogs + +# Swagger +/libraries/swagger/** @axelsrz @EricDahlvang + +# Bot Framework Schema +/libraries/botbuilder-schema/** @EricDahlvang @johnataylor + +# Bot Framework connector +libraries\botframework-connector/** @axelsrz @carlosscastro @johnataylor + +# Bot Framework Authentication +/libraries/botbuilder-core/botbuilder/core/oauth/** @microsoft/bf-auth +/libraries/botframework-connector/botframework/connector/auth/** @microsoft/bf-auth + +# Bot Framework Skills +/libraries/botbuilder-core/botbuilder/core/skills/** @microsoft/bf-skills +/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/** @microsoft/bf-skills +/tests/skills/** @microsoft/bf-skills + +# Bot Framework & Microsoft Teams +/libraries/botbuilder-core/botbuilder/core/teams/** @microsoft/bf-teams +/libraries/botbuilder-schema/botbuilder/schema/teams/** @microsoft/bf-teams +/tests/teams/** @microsoft/bf-teams + +# Ownership by specific files or file types +# This section MUST stay at the bottom of the CODEOWNERS file. For more information, see +# https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#example-of-a-codeowners-file + +# Shipped package files +# e.g. READMEs, requirements.txt, setup.py, MANIFEST.in +/libraries/**/README.rst @microsoft/bb-python +/libraries/**/requirements.txt @microsoft/bb-python +/libraries/**/setup.py @microsoft/bb-python +/libraries/**/setup.cfg @microsoft/bb-python +/libraries/**/MANIFEST.in @microsoft/bb-python + +# CODEOWNERS +/.github/CODEOWNERS @stevengum @cleemullins @microsoft/bb-python From 9c2c1eb0104f1f213f3473aff1ffba20ebe66f9f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 14 Jul 2020 10:07:05 -0500 Subject: [PATCH 536/616] Add Teams specific telemetry properties --- .../core/telemetry_logger_middleware.py | 30 +++++++++++- .../tests/test_telemetry_middleware.py | 46 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py b/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py index cac90c94f..33fcd6681 100644 --- a/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/telemetry_logger_middleware.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. """Middleware Component for logging Activity messages.""" - from typing import Awaitable, Callable, List, Dict from botbuilder.schema import Activity, ConversationReference, ActivityTypes +from botbuilder.schema.teams import TeamsChannelData, TeamInfo +from botframework.connector import Channels + from .bot_telemetry_client import BotTelemetryClient from .bot_assert import BotAssert from .middleware_set import Middleware @@ -183,6 +185,10 @@ async def fill_receive_event_properties( if activity.speak and activity.speak.strip(): properties[TelemetryConstants.SPEAK_PROPERTY] = activity.speak + TelemetryLoggerMiddleware.__populate_additional_channel_properties( + activity, properties + ) + # Additional properties can override "stock" properties if additional_properties: for prop in additional_properties: @@ -288,3 +294,25 @@ async def fill_delete_event_properties( properties[prop.key] = prop.value return properties + + @staticmethod + def __populate_additional_channel_properties( + activity: Activity, properties: dict, + ): + if activity.channel_id == Channels.ms_teams: + teams_channel_data: TeamsChannelData = activity.channel_data + + properties["TeamsTenantId"] = ( + teams_channel_data.tenant + if teams_channel_data and teams_channel_data.tenant + else "" + ) + + properties["TeamsUserAadObjectId"] = ( + activity.from_property.aad_object_id if activity.from_property else "" + ) + + if teams_channel_data and teams_channel_data.team: + properties["TeamsTeamInfo"] = TeamInfo.serialize( + teams_channel_data.team + ) diff --git a/libraries/botbuilder-core/tests/test_telemetry_middleware.py b/libraries/botbuilder-core/tests/test_telemetry_middleware.py index eca0c0fcf..6a5cc8e5d 100644 --- a/libraries/botbuilder-core/tests/test_telemetry_middleware.py +++ b/libraries/botbuilder-core/tests/test_telemetry_middleware.py @@ -7,11 +7,14 @@ from typing import Dict from unittest.mock import Mock import aiounittest +from botframework.connector import Channels + from botbuilder.core import ( NullTelemetryClient, TelemetryLoggerMiddleware, TelemetryLoggerConstants, TurnContext, + MessageFactory, ) from botbuilder.core.adapters import TestAdapter, TestFlow from botbuilder.schema import ( @@ -19,7 +22,9 @@ ActivityTypes, ChannelAccount, ConversationAccount, + ConversationReference, ) +from botbuilder.schema.teams import TeamInfo, TeamsChannelData, TenantInfo class TestTelemetryMiddleware(aiounittest.AsyncTestCase): @@ -228,6 +233,47 @@ async def process(context: TurnContext) -> None: ] self.assert_telemetry_calls(telemetry, telemetry_call_expected) + async def test_log_teams(self): + telemetry = Mock() + my_logger = TelemetryLoggerMiddleware(telemetry, True) + + adapter = TestAdapter( + template_or_conversation=ConversationReference(channel_id=Channels.ms_teams) + ) + adapter.use(my_logger) + + team_info = TeamInfo(id="teamId", name="teamName",) + + channel_data = TeamsChannelData( + team=team_info, tenant=TenantInfo(id="tenantId"), + ) + + activity = MessageFactory.text("test") + activity.channel_data = channel_data + activity.from_property = ChannelAccount( + id="userId", name="userName", aad_object_id="aaId", + ) + + test_flow = TestFlow(None, adapter) + await test_flow.send(activity) + + telemetry_call_expected = [ + ( + TelemetryLoggerConstants.BOT_MSG_RECEIVE_EVENT, + { + "text": "test", + "fromId": "userId", + "recipientId": "bot", + "recipientName": "Bot", + "TeamsTenantId": TenantInfo(id="tenantId"), + "TeamsUserAadObjectId": "aaId", + "TeamsTeamInfo": TeamInfo.serialize(team_info), + }, + ), + ] + + self.assert_telemetry_calls(telemetry, telemetry_call_expected) + def create_reply(self, activity, text, locale=None): return Activity( type=ActivityTypes.message, From c8d7421235a3e49078712a5f47262caf7cdc49fa Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 14 Jul 2020 14:09:35 -0500 Subject: [PATCH 537/616] Updated README to include info about pylint and black --- README.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 880e6880f..6771e8005 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ### [What's new with Bot Framework](https://docs.microsoft.com/en-us/azure/bot-service/what-is-new?view=azure-bot-service-4.0) -This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botframework-sdk), which is part of the Microsoft Bot Framework - a comprehensive framework for building enterprise-grade conversational AI experiences. +This repository contains code for the Python version of the [Microsoft Bot Framework SDK](https://github.com/Microsoft/botframework-sdk), which is part of the Microsoft Bot Framework - a comprehensive framework for building enterprise-grade conversational AI experiences. This SDK enables developers to model conversation and build sophisticated bot applications using Python. SDKs for [JavaScript](https://github.com/Microsoft/botbuilder-js), [.NET](https://github.com/Microsoft/botbuilder-dotnet) and [Java (preview)](https://github.com/Microsoft/botbuilder-java) are also available. @@ -40,13 +40,13 @@ To get started building bots using the SDK, see the [Azure Bot Service Documenta The [Bot Framework Samples](https://github.com/microsoft/botbuilder-samples) includes a rich set of samples repository. -If you want to debug an issue, would like to [contribute](#contributing), or understand how the Bot Builder SDK works, instructions for building and testing the SDK are below. +If you want to debug an issue, would like to [contribute](#contributing-code), or understand how the Bot Builder SDK works, instructions for building and testing the SDK are below. ### Prerequisites - [Git](https://git-scm.com/downloads) - [Python 3.8.2](https://www.python.org/downloads/) -Python "Virtual Environments" allow Python packages to be installed in an isolated location for a particular application, rather than being installed globally, as such it is common practice to use them. Click [here](https://packaging.python.org/tutorials/installing-packages/#creating-virtual-environments) to learn more about creating _and activating_ Virtual Environments in Python. +Python "Virtual Environments" allow Python packages to be installed in an isolated location for a particular application, rather than being installed globally, as such it is common practice to use them. Click [here](https://packaging.python.org/tutorials/installing-packages/#creating-virtual-environments) to learn more about creating _and activating_ Virtual Environments in Python. ### Clone Clone a copy of the repo: @@ -60,12 +60,7 @@ cd botbuilder-python ### Using the SDK locally -You will need the following 3 packages installed in your environment: -- [botframework-connector](https://pypi.org/project/botframework-connector/) -- [botbuilder-core](https://pypi.org/project/botbuilder-core/) -- [botbuilder-schema](https://pypi.org/project/botbuilder-schema/) - -To use a local copy of the SDK you can link to these packages with the pip -e option. +To use a local copy of the SDK you can link to these packages with the pip -e option. ```bash pip install -e ./libraries/botbuilder-schema @@ -108,12 +103,12 @@ plugins: cov-2.5.1 Below are the various channels that are available to you for obtaining support and providing feedback. Please pay carful attention to which channel should be used for which type of content. e.g. general "how do I..." questions should be asked on Stack Overflow, Twitter or Gitter, with GitHub issues being for feature requests and bug reports. ### Github issues -[Github issues](https://github.com/Microsoft/botbuilder-python/issues) should be used for bugs and feature requests. +[Github issues](https://github.com/Microsoft/botbuilder-python/issues) should be used for bugs and feature requests. ### Stack overflow [Stack Overflow](https://stackoverflow.com/questions/tagged/botframework) is a great place for getting high-quality answers. Our support team, as well as many of our community members are already on Stack Overflow providing answers to 'how-to' questions. -### Azure Support +### Azure Support If you issues relates to [Azure Bot Service](https://azure.microsoft.com/en-gb/services/bot-service/), you can take advantage of the available [Azure support options](https://azure.microsoft.com/en-us/support/options/). ### Twitter @@ -125,15 +120,25 @@ The [Gitter Channel](https://gitter.im/Microsoft/BotBuilder) provides a place wh ## Contributing and our code of conduct We welcome contributions and suggestions. Please see our [contributing guidelines](./contributing.md) for more information. -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +### Contributing Code + +In order to create pull requests, submitted code must pass ```pylint``` and ```black``` checks. Run both tools on every file you've changed. + +For more information and installation instructions, see: + +* [black](https://pypi.org/project/black/) +* [pylint](https://pylint.org/) + ## Reporting Security Issues -Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) +Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) at [secure@microsoft.com](mailto:secure@microsoft.com). You should receive a response within 24 hours. If for some - reason you do not, please follow up via email to ensure we received your original message. Further information, - including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155) key, can be found in the + reason you do not, please follow up via email to ensure we received your original message. Further information, + including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155) key, can be found in the [Security TechCenter](https://technet.microsoft.com/en-us/security/default). Copyright (c) Microsoft Corporation. All rights reserved. From 37aa4233de35e3024e04e52b146405b765cc4c47 Mon Sep 17 00:00:00 2001 From: Josh <50158775+Virtual-Josh@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:27:28 -0700 Subject: [PATCH 538/616] correcting none check to check parameter (#1254) --- .../botbuilder/core/teams/teams_info.py | 4 ++-- .../tests/teams/test_teams_info.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index e3ca332b6..e781f4696 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -22,8 +22,8 @@ async def send_message_to_teams_channel( ) -> Tuple[ConversationReference, str]: if not turn_context: raise ValueError("The turn_context cannot be None") - if not turn_context.activity: - raise ValueError("The turn_context.activity cannot be None") + if not activity: + raise ValueError("The activity cannot be None") if not teams_channel_id: raise ValueError("The teams_channel_id cannot be None or empty") diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 41c3e5439..0b1f707b1 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -13,6 +13,26 @@ class TestTeamsInfo(aiounittest.AsyncTestCase): + async def test_send_message_to_teams_channels_without_activity(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + activity = Activity() + turn_context = TurnContext(adapter, activity) + + try: + await TeamsInfo.send_message_to_teams_channel( + turn_context, None, "channelId123" + ) + except ValueError: + pass + else: + assert False, "should have raise ValueError" + async def test_send_message_to_teams(self): def create_conversation(): pass From eaf1af51b09dc69085c00ec8aa54ea27c6253bdc Mon Sep 17 00:00:00 2001 From: Maximilian Cosmo Sitter <48606431+mcsitter@users.noreply.github.com> Date: Sun, 19 Jul 2020 13:06:45 +0200 Subject: [PATCH 539/616] Fix broken link to LICENSE --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6771e8005..8a5befa93 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,6 @@ at [secure@microsoft.com](mailto:secure@microsoft.com). You should receive a re Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the [MIT](./LICENSE.md) License. +Licensed under the [MIT](./LICENSE) License. From 6910c69a9a07d89c5843943826df4264b85cede2 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 23 Jul 2020 09:57:20 -0500 Subject: [PATCH 540/616] Jwiley84/assertnoreply --- .../botbuilder/core/adapters/test_adapter.py | 52 ++++++++++++++++--- .../tests/test_test_adapter.py | 33 +++++++++++- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index fed4388b5..a5637d86c 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -13,6 +13,12 @@ from typing import Awaitable, Coroutine, Dict, List, Callable, Union from copy import copy from threading import Lock +from botframework.connector.auth import AppCredentials, ClaimsIdentity +from botframework.connector.token_api.models import ( + SignInUrlResponse, + TokenExchangeResource, + TokenExchangeRequest, +) from botbuilder.schema import ( ActivityTypes, Activity, @@ -22,12 +28,6 @@ ResourceResponse, TokenResponse, ) -from botframework.connector.auth import AppCredentials, ClaimsIdentity -from botframework.connector.token_api.models import ( - SignInUrlResponse, - TokenExchangeResource, - TokenExchangeRequest, -) from ..bot_adapter import BotAdapter from ..turn_context import TurnContext from ..oauth.extended_user_token_provider import ExtendedUserTokenProvider @@ -595,6 +595,7 @@ async def assert_reply( :param is_substring: :return: """ + # TODO: refactor method so expected can take a Callable[[Activity], None] def default_inspector(reply, description=None): if isinstance(expected, Activity): @@ -651,6 +652,45 @@ async def wait_for_activity(): return TestFlow(await test_flow_previous(), self.adapter) + async def assert_no_reply( + self, description=None, timeout=None, # pylint: disable=unused-argument + ) -> "TestFlow": + """ + Generates an assertion if the bot responds when no response is expected. + :param description: + :param timeout: + """ + if description is None: + description = "" + + async def test_flow_previous(): + nonlocal timeout + if not timeout: + timeout = 3000 + start = datetime.now() + adapter = self.adapter + + async def wait_for_activity(): + nonlocal timeout + current = datetime.now() + + if (current - start).total_seconds() * 1000 > timeout: + # operation timed out and recieved no reply + return + + if adapter.activity_buffer: + reply = adapter.activity_buffer.pop(0) + raise RuntimeError( + f"TestAdapter.assert_no_reply(): '{reply.text}' is responded when waiting for no reply." + ) + + await asyncio.sleep(0.05) + await wait_for_activity() + + await wait_for_activity() + + return TestFlow(await test_flow_previous(), self.adapter) + def validate_activity(activity, expected) -> None: """ diff --git a/libraries/botbuilder-core/tests/test_test_adapter.py b/libraries/botbuilder-core/tests/test_test_adapter.py index 4312ca352..447f74ead 100644 --- a/libraries/botbuilder-core/tests/test_test_adapter.py +++ b/libraries/botbuilder-core/tests/test_test_adapter.py @@ -3,10 +3,10 @@ import aiounittest +from botframework.connector.auth import MicrosoftAppCredentials from botbuilder.core import TurnContext from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity, ConversationReference, ChannelAccount -from botframework.connector.auth import MicrosoftAppCredentials RECEIVED_MESSAGE = Activity(type="message", text="received") UPDATED_ACTIVITY = Activity(type="message", text="update") @@ -245,3 +245,34 @@ async def test_get_user_token_returns_token_with_magice_code(self): assert token_response assert token == token_response.token assert connection_name == token_response.connection_name + + async def test_should_validate_no_reply_when_no_reply_expected(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + test_flow = await adapter.test("test", "received") + await test_flow.assert_no_reply("should be no additional replies") + + async def test_should_timeout_waiting_for_assert_no_reply_when_no_reply_expected( + self, + ): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + test_flow = await adapter.test("test", "received") + await test_flow.assert_no_reply("no reply received", 500) + + async def test_should_throw_error_with_assert_no_reply_when_no_reply_expected_but_was_received( + self, + ): + async def logic(context: TurnContext): + activities = [RECEIVED_MESSAGE, RECEIVED_MESSAGE] + await context.send_activities(activities) + + adapter = TestAdapter(logic) + test_flow = await adapter.test("test", "received") + + with self.assertRaises(Exception): + await test_flow.assert_no_reply("should be no additional replies") From 59dadd0f81496a2ac45b69819259973344bc8e28 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 23 Jul 2020 11:23:33 -0500 Subject: [PATCH 541/616] Added InstallationUpdate Activity type handling (#1272) --- .../botbuilder/core/activity_handler.py | 15 +++++++++++++++ .../tests/test_activity_handler.py | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 0757ff28c..74515e709 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -86,6 +86,8 @@ async def on_turn(self, turn_context: TurnContext): await self.on_end_of_conversation_activity(turn_context) elif turn_context.activity.type == ActivityTypes.typing: await self.on_typing_activity(turn_context) + elif turn_context.activity.type == ActivityTypes.installation_update: + await self.on_installation_update(turn_context) else: await self.on_unrecognized_activity_type(turn_context) @@ -365,6 +367,19 @@ async def on_typing_activity( # pylint: disable=unused-argument """ return + async def on_installation_update( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + """ + Override this in a derived class to provide logic specific to + ActivityTypes.InstallationUpdate activities. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + :returns: A task that represents the work queued to execute + """ + return + async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index d0f5b4f79..3cdd052f8 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -73,6 +73,10 @@ async def on_typing_activity(self, turn_context: TurnContext): self.record.append("on_typing_activity") return await super().on_typing_activity(turn_context) + async def on_installation_update(self, turn_context: TurnContext): + self.record.append("on_installation_update") + return await super().on_installation_update(turn_context) + async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) @@ -229,6 +233,19 @@ async def test_typing_activity(self): assert len(bot.record) == 1 assert bot.record[0] == "on_typing_activity" + async def test_on_installation_update(self): + activity = Activity(type=ActivityTypes.installation_update) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 1 + assert bot.record[0] == "on_installation_update" + async def test_healthcheck(self): activity = Activity(type=ActivityTypes.invoke, name="healthcheck",) From d7ff8098a9aeca008e1068c92636b38539a2bca6 Mon Sep 17 00:00:00 2001 From: virtual-josh Date: Tue, 14 Jul 2020 11:25:02 -0700 Subject: [PATCH 542/616] saving tests so far adding tests to teams info black and pylint removing duplicate method --- .../tests/teams/test_teams_info.py | 169 ++++++++++++++++-- 1 file changed, 158 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 0b1f707b1..9ddc5662c 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -6,11 +6,26 @@ from botbuilder.core import TurnContext, MessageFactory from botbuilder.core.teams import TeamsInfo, TeamsActivityHandler -from botbuilder.schema import Activity -from botbuilder.schema.teams import TeamsChannelData, TeamInfo -from botframework.connector import Channels +from botbuilder.schema import ( + Activity, + ChannelAccount, + ConversationAccount, +) from simple_adapter_with_create_conversation import SimpleAdapterWithCreateConversation +ACTIVITY = Activity( + id="1234", + type="message", + text="test", + from_property=ChannelAccount(id="user", name="User Name"), + recipient=ChannelAccount(id="bot", name="Bot Name"), + conversation=ConversationAccount(id="convo", name="Convo Name"), + channel_data={"channelData": {}}, + channel_id="UnitTest", + locale="en-us", + service_url="https://example.org", +) + class TestTeamsInfo(aiounittest.AsyncTestCase): async def test_send_message_to_teams_channels_without_activity(self): @@ -41,17 +56,149 @@ def create_conversation(): call_create_conversation=create_conversation ) - activity = Activity( - type="message", - text="test_send_message_to_teams_channel", - channel_id=Channels.ms_teams, - service_url="https://example.org", - channel_data=TeamsChannelData(team=TeamInfo(id="team-id")), - ) - turn_context = TurnContext(adapter, activity) + turn_context = TurnContext(adapter, ACTIVITY) handler = TestTeamsActivityHandler() await handler.on_turn(turn_context) + async def test_send_message_to_teams_channels_without_turn_context(self): + try: + await TeamsInfo.send_message_to_teams_channel( + None, ACTIVITY, "channelId123" + ) + except ValueError: + pass + else: + assert False, "should have raise ValueError" + + async def test_send_message_to_teams_channels_without_teams_channel_id(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + turn_context = TurnContext(adapter, ACTIVITY) + + try: + await TeamsInfo.send_message_to_teams_channel(turn_context, ACTIVITY, "") + except ValueError: + pass + else: + assert False, "should have raise ValueError" + + async def test_send_message_to_teams_channel_works(self): + adapter = SimpleAdapterWithCreateConversation() + + turn_context = TurnContext(adapter, ACTIVITY) + result = await TeamsInfo.send_message_to_teams_channel( + turn_context, ACTIVITY, "teamId123" + ) + assert result[0].activity_id == "new_conversation_id" + assert result[1] == "reference123" + + async def test_get_team_details_works_without_team_id(self): + adapter = SimpleAdapterWithCreateConversation() + ACTIVITY.channel_data = {} + turn_context = TurnContext(adapter, ACTIVITY) + result = TeamsInfo.get_team_id(turn_context) + + assert result == "" + + async def test_get_team_details_works_with_team_id(self): + adapter = SimpleAdapterWithCreateConversation() + team_id = "teamId123" + ACTIVITY.channel_data = {"team": {"id": team_id}} + turn_context = TurnContext(adapter, ACTIVITY) + result = TeamsInfo.get_team_id(turn_context) + + assert result == team_id + + async def test_get_team_details_without_team_id(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + turn_context = TurnContext(adapter, ACTIVITY) + + try: + await TeamsInfo.get_team_details(turn_context) + except TypeError: + pass + else: + assert False, "should have raise TypeError" + + async def test_get_team_channels_without_team_id(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + turn_context = TurnContext(adapter, ACTIVITY) + + try: + await TeamsInfo.get_team_channels(turn_context) + except TypeError: + pass + else: + assert False, "should have raise TypeError" + + async def test_get_paged_team_members_without_team_id(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + turn_context = TurnContext(adapter, ACTIVITY) + + try: + await TeamsInfo.get_paged_team_members(turn_context) + except TypeError: + pass + else: + assert False, "should have raise TypeError" + + async def test_get_team_members_without_team_id(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + turn_context = TurnContext(adapter, ACTIVITY) + + try: + await TeamsInfo.get_team_member(turn_context) + except TypeError: + pass + else: + assert False, "should have raise TypeError" + + async def test_get_team_members_without_member_id(self): + def create_conversation(): + pass + + adapter = SimpleAdapterWithCreateConversation( + call_create_conversation=create_conversation + ) + + turn_context = TurnContext(adapter, ACTIVITY) + + try: + await TeamsInfo.get_team_member(turn_context, "teamId123") + except TypeError: + pass + else: + assert False, "should have raise TypeError" + class TestTeamsActivityHandler(TeamsActivityHandler): async def on_turn(self, turn_context: TurnContext): From a0978a9cbe20dc0345b07024717676d12abacca0 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 23 Jul 2020 13:57:28 -0500 Subject: [PATCH 543/616] Add a constant for "empty speak tag" --- .../botbuilder/schema/__init__.py | 2 ++ .../botbuilder/schema/speech_constants.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 libraries/botbuilder-schema/botbuilder/schema/speech_constants.py diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index d133a8db4..d2183f6eb 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -70,6 +70,7 @@ from .callerid_constants import CallerIdConstants from .health_results import HealthResults from .healthcheck_response import HealthCheckResponse +from .speech_constants import SpeechConstants __all__ = [ "Activity", @@ -139,4 +140,5 @@ "CallerIdConstants", "HealthResults", "HealthCheckResponse", + "SpeechConstants", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/speech_constants.py b/libraries/botbuilder-schema/botbuilder/schema/speech_constants.py new file mode 100644 index 000000000..0fbc396e6 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/speech_constants.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class SpeechConstants: + """ + Defines constants that can be used in the processing of speech interactions. + """ + + EMPTY_SPEAK_TAG = '' + """ + The xml tag structure to indicate an empty speak tag, to be used in the 'speak' property of an Activity. + When set this indicates to the channel that speech should not be generated. + """ From e5faf32c608a4818ad00b6766ef33663140afd3b Mon Sep 17 00:00:00 2001 From: Carlos Castro Date: Tue, 23 Jun 2020 20:55:25 -0700 Subject: [PATCH 544/616] Teams + SSO: Update OAuthcard channel support and return 412 instead of 409 on sso fail --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 505f23021..812444416 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -546,7 +546,7 @@ async def _recognize_token( if not token_exchange_response or not token_exchange_response.token: await context.send_activity( self._get_token_exchange_invoke_response( - int(HTTPStatus.CONFLICT), + int(HTTPStatus.PRECONDITION_FAILED), "The bot is unable to exchange token. Proceed with regular login.", ) ) @@ -609,7 +609,6 @@ def _is_teams_verification_invoke(context: TurnContext) -> bool: @staticmethod def _channel_suppports_oauth_card(channel_id: str) -> bool: if channel_id in [ - Channels.ms_teams, Channels.cortana, Channels.skype, Channels.skype_for_business, From 45efb71bc501a222ee91c40bd8cf6bdd403f3c23 Mon Sep 17 00:00:00 2001 From: Carlos Castro Date: Mon, 13 Jul 2020 15:06:33 -0700 Subject: [PATCH 545/616] [Patch] SSO + Teams: maintain sign in link for channels that require it (#1245) * SSO + Teams: maintain sign in link for channels that require it * Fix syntax :) * OAuthPrompt: Formatting Co-authored-by: Axel Suarez --- .../botbuilder/dialogs/prompts/oauth_prompt.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 812444416..588b22c38 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -362,7 +362,9 @@ async def _send_oauth_card( ): if context.activity.channel_id == Channels.emulator: card_action_type = ActionTypes.open_url - else: + elif not OAuthPrompt._channel_requires_sign_in_link( + context.activity.channel_id + ): link = None json_token_ex_resource = ( @@ -617,6 +619,13 @@ def _channel_suppports_oauth_card(channel_id: str) -> bool: return True + @staticmethod + def _channel_requires_sign_in_link(channel_id: str) -> bool: + if channel_id in [Channels.ms_teams]: + return True + + return False + @staticmethod def _is_token_exchange_request_invoke(context: TurnContext) -> bool: activity = context.activity From 66438c6f31c3f0d33e871e5499fd0b8a8e6446b6 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 28 Jul 2020 11:33:59 -0500 Subject: [PATCH 546/616] Add support for skill OAuthCard to Emulator and WebChat (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated get_sign_in_resource_from_user_and_credentials to match dotnet GetSingInResourceAsync * Updated get_sign_in_resource_from_user_and_credentials to match dotnet GetSingInResourceAsync Co-authored-by: Axel Suárez --- .../botbuilder/core/bot_framework_adapter.py | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 731e37b70..515c183da 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -1174,39 +1174,43 @@ async def get_sign_in_resource_from_user_and_credentials( ) -> SignInUrlResponse: if not connection_name: raise TypeError( - "BotFrameworkAdapter.get_sign_in_resource_from_user(): missing connection_name" + "BotFrameworkAdapter.get_sign_in_resource_from_user_and_credentials(): missing connection_name" ) - if ( - not turn_context.activity.from_property - or not turn_context.activity.from_property.id - ): - raise TypeError( - "BotFrameworkAdapter.get_sign_in_resource_from_user(): missing activity id" - ) - if user_id and turn_context.activity.from_property.id != user_id: + if not user_id: raise TypeError( - "BotFrameworkAdapter.get_sign_in_resource_from_user(): cannot get signin resource" - " for a user that is different from the conversation" + "BotFrameworkAdapter.get_sign_in_resource_from_user_and_credentials(): missing user_id" ) - client = await self._create_token_api_client( - turn_context, oauth_app_credentials - ) - conversation = TurnContext.get_conversation_reference(turn_context.activity) + activity = turn_context.activity - state = TokenExchangeState( + app_id = self.__get_app_id(turn_context) + token_exchange_state = TokenExchangeState( connection_name=connection_name, - conversation=conversation, - relates_to=turn_context.activity.relates_to, - ms_app_id=client.config.credentials.microsoft_app_id, + conversation=ConversationReference( + activity_id=activity.id, + bot=activity.recipient, + channel_id=activity.channel_id, + conversation=activity.conversation, + locale=activity.locale, + service_url=activity.service_url, + user=activity.from_property, + ), + relates_to=activity.relates_to, + ms_app_id=app_id, ) - final_state = base64.b64encode( - json.dumps(state.serialize()).encode(encoding="UTF-8", errors="strict") + state = base64.b64encode( + json.dumps(token_exchange_state.serialize()).encode( + encoding="UTF-8", errors="strict" + ) ).decode() + client = await self._create_token_api_client( + turn_context, oauth_app_credentials + ) + return client.bot_sign_in.get_sign_in_resource( - final_state, final_redirect=final_redirect + state, final_redirect=final_redirect ) async def exchange_token( From 8f915bedc2354489244f5ee120dd5026af00f528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 28 Jul 2020 16:48:38 -0700 Subject: [PATCH 547/616] Create DailyBuildProposal.md --- specs/DailyBuildProposal.md | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 specs/DailyBuildProposal.md diff --git a/specs/DailyBuildProposal.md b/specs/DailyBuildProposal.md new file mode 100644 index 000000000..7381dc7c2 --- /dev/null +++ b/specs/DailyBuildProposal.md @@ -0,0 +1,58 @@ +# Daily Build Propsal for .Net BotBuilder SDK + +This proposal describes our plan to publish daily builds for consumption. The goals of this are: +1. Make it easy for developers (1P and 3P) to consume our daily builds. +2. Exercise our release process frequently, so issues don't arise at critical times. +3. Meet Developers where they are. + +Use the [ASP.Net Team](https://github.com/dotnet/aspnetcore/blob/master/docs/DailyBuilds.md) as inspiration, and draft off the work they do. + +# Versioning +Move to Python suggested versioning for dailies defined in [PEP440](https://www.python.org/dev/peps/pep-0440/#developmental-releases). + +The tags we use for preview versions are: +``` +..dev{incrementing value} +-rc{incrementing value} +``` + +# Daily Builds +All our Python wheel packages would be pushed to the SDK_Public project at [fuselabs.visualstudio.com](https://fuselabs.visualstudio.com). + + Note: Only a public project on Devops can have a public feed. The public project on our Enterprise Tenant is [SDK_Public](https://fuselabs.visualstudio.com/SDK_Public). + +This means developers could add this feed their projects by adding the following command on a pip conf file, or in the pip command itself: + +```bash +extra-index-url=https://pkgs.dev.azure.com/ConversationalAI/BotFramework/_packaging/SDK%40Local/pypi/simple/ +``` + +## Debugging +To debug daily builds in VSCode: +* In the launch.json configuration file set the option `"justMyCode": false`. + +## Daily Build Lifecyle +Daily builds older than 90 days are automatically deleted. + +# Summary - Weekly Builds +Once per week, preferably on a Monday, a daily build is pushed to PyPI test. This build happens from master, the same as a standard daily build. This serves 2 purposes: + +1. Keeps PyPI "Fresh" for people that don't want daily builds. +2. Keeps the release pipelines active and working, and prevents issues. + +These builds will have the "-dev" tag and ARE the the daily build. + +**This release pipeline should be the EXACT same pipeline that releases our production bits.** + +Weekly builds older than 1 year should be automatically delisted. + +## Adding packages to the feed +Our existing Release pipelines would add packages to the feed. +# Migration from MyGet + +1. Initially, our daily builds should go to both MyGet and Azure Devops. +2. Our docs are updated once builds are in both locations. +3. Towards the end of 2020, we stop publising to MyGet. + +# Containers +ASP.Net and .Net Core 5 also publish a container to [Docker Hub](https://hub.docker.com/_/microsoft-dotnet-nightly-aspnet/) as part of their daily feed. We should consider that, along with our samples, in the next iteration of this work. From 71816601c7514e4d38e92c50201bf658525d342e Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Fri, 31 Jul 2020 14:27:28 -0700 Subject: [PATCH 548/616] update azure-cosmos to 3.2.0 --- libraries/botbuilder-azure/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index 7b1a77c64..50ae09a60 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -5,7 +5,7 @@ from setuptools import setup REQUIRES = [ - "azure-cosmos==3.1.2", + "azure-cosmos==3.2.0", "azure-storage-blob==2.1.0", "botbuilder-schema==4.10.0", "botframework-connector==4.10.0", From 35bbf9f735d96ba1408f00ba2193474f65daf679 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 4 Aug 2020 10:43:01 -0500 Subject: [PATCH 549/616] Fixes Unauthorized error when calling ContinueConversation (#1312) --- .../botbuilder/core/bot_framework_adapter.py | 16 ++++++++++++---- .../tests/test_bot_framework_adapter.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 515c183da..31540ccec 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -279,10 +279,18 @@ async def continue_conversation( context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = callback context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = audience - # Add the channel service URL to the trusted services list so we can send messages back. - # the service URL for skills is trusted because it is applied by the SkillHandler based - # on the original request received by the root bot - AppCredentials.trust_service_url(reference.service_url) + # If we receive a valid app id in the incoming token claims, add the channel service URL to the + # trusted services list so we can send messages back. + # The service URL for skills is trusted because it is applied by the SkillHandler based on the original + # request received by the root bot + app_id_from_claims = JwtTokenValidation.get_app_id_from_claims( + claims_identity.claims + ) + if app_id_from_claims: + if SkillValidation.is_skill_claim( + claims_identity.claims + ) or await self._credential_provider.is_valid_appid(app_id_from_claims): + AppCredentials.trust_service_url(reference.service_url) client = await self.create_connector_client( reference.service_url, claims_identity, audience diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 8c6c98867..fe4f55e3f 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -571,8 +571,14 @@ async def callback(context: TurnContext): scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope + # Ensure the serviceUrl was added to the trusted hosts + assert AppCredentials.is_trusted_service(channel_service_url) + refs = ConversationReference(service_url=channel_service_url) + # Ensure the serviceUrl is NOT in the trusted hosts + assert not AppCredentials.is_trusted_service(channel_service_url) + await adapter.continue_conversation( refs, callback, claims_identity=skills_identity ) @@ -629,8 +635,14 @@ async def callback(context: TurnContext): scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] assert skill_2_app_id == scope + # Ensure the serviceUrl was added to the trusted hosts + assert AppCredentials.is_trusted_service(skill_2_service_url) + refs = ConversationReference(service_url=skill_2_service_url) + # Ensure the serviceUrl is NOT in the trusted hosts + assert not AppCredentials.is_trusted_service(skill_2_service_url) + await adapter.continue_conversation( refs, callback, claims_identity=skills_identity, audience=skill_2_app_id ) From c34e994470bb8e1be6bbb9a4b1d0565a10f76337 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 4 Aug 2020 11:28:08 -0500 Subject: [PATCH 550/616] pylint correction --- .../botbuilder/dialogs/prompts/activity_prompt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py index 6170852f7..a8f2f944e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/activity_prompt.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from abc import ABC from typing import Callable, Dict from botbuilder.core import TurnContext From a251259c26be993661a51f037d0dc5cbc4740080 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 6 Aug 2020 09:18:42 -0500 Subject: [PATCH 551/616] Refactored SkillDialog to call ConversationFacotry.CreateConversationId only once --- .../botbuilder/dialogs/skills/skill_dialog.py | 39 ++++++++++++++----- .../tests/test_skill_dialog.py | 6 ++- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py index b26fa6341..a2bdd7a57 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -4,6 +4,7 @@ from copy import deepcopy from typing import List +from botframework.connector.token_api.models import TokenExchangeRequest from botbuilder.schema import ( Activity, ActivityTypes, @@ -22,13 +23,16 @@ DialogReason, DialogInstance, ) -from botframework.connector.token_api.models import TokenExchangeRequest from .begin_skill_dialog_options import BeginSkillDialogOptions from .skill_dialog_options import SkillDialogOptions class SkillDialog(Dialog): + SKILLCONVERSATIONIDSTATEKEY = ( + "Microsoft.Bot.Builder.Dialogs.SkillDialog.SkillConversationId" + ) + def __init__(self, dialog_options: SkillDialogOptions, dialog_id: str): super().__init__(dialog_id) if not dialog_options: @@ -65,8 +69,18 @@ async def begin_dialog(self, dialog_context: DialogContext, options: object = No self._deliver_mode_state_key ] = dialog_args.activity.delivery_mode + # Create the conversationId and store it in the dialog context state so we can use it later + skill_conversation_id = await self._create_skill_conversation_id( + dialog_context.context, dialog_context.context.activity + ) + dialog_context.active_dialog.state[ + SkillDialog.SKILLCONVERSATIONIDSTATEKEY + ] = skill_conversation_id + # Send the activity to the skill. - eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity) + eoc_activity = await self._send_to_skill( + dialog_context.context, skill_activity, skill_conversation_id + ) if eoc_activity: return await dialog_context.end_dialog(eoc_activity.value) @@ -101,7 +115,12 @@ async def continue_dialog(self, dialog_context: DialogContext): ] # Just forward to the remote skill - eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity) + skill_conversation_id = dialog_context.active_dialog.state[ + SkillDialog.SKILLCONVERSATIONIDSTATEKEY + ] + eoc_activity = await self._send_to_skill( + dialog_context.context, skill_activity, skill_conversation_id + ) if eoc_activity: return await dialog_context.end_dialog(eoc_activity.value) @@ -123,7 +142,8 @@ async def reprompt_dialog( # pylint: disable=unused-argument ) # connection Name is not applicable for a RePrompt, as we don't expect as OAuthCard in response. - await self._send_to_skill(context, reprompt_event) + skill_conversation_id = instance.state[SkillDialog.SKILLCONVERSATIONIDSTATEKEY] + await self._send_to_skill(context, reprompt_event, skill_conversation_id) async def resume_dialog( # pylint: disable=unused-argument self, dialog_context: "DialogContext", reason: DialogReason, result: object @@ -152,7 +172,10 @@ async def end_dialog( activity.additional_properties = context.activity.additional_properties # connection Name is not applicable for an EndDialog, as we don't expect as OAuthCard in response. - await self._send_to_skill(context, activity) + skill_conversation_id = instance.state[ + SkillDialog.SKILLCONVERSATIONIDSTATEKEY + ] + await self._send_to_skill(context, activity, skill_conversation_id) await super().end_dialog(context, instance, reason) @@ -187,7 +210,7 @@ def _on_validate_activity( return True async def _send_to_skill( - self, context: TurnContext, activity: Activity + self, context: TurnContext, activity: Activity, skill_conversation_id: str ) -> Activity: if activity.type == ActivityTypes.invoke: # Force ExpectReplies for invoke activities so we can get the replies right away and send @@ -195,10 +218,6 @@ async def _send_to_skill( # response from the skill and any other activities sent, including EoC. activity.delivery_mode = DeliveryModes.expect_replies - skill_conversation_id = await self._create_skill_conversation_id( - context, activity - ) - # Always save state before forwarding # (the dialog stack won't get updated with the skillDialog and things won't work if you don't) await self.dialog_options.conversation_state.save_changes(context, True) diff --git a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py index 4b246189f..c5509e6a8 100644 --- a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py +++ b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py @@ -6,6 +6,7 @@ from unittest.mock import Mock import aiounittest +from botframework.connector.token_api.models import TokenExchangeResource from botbuilder.core import ( ConversationState, MemoryStorage, @@ -40,7 +41,6 @@ BeginSkillDialogOptions, DialogTurnStatus, ) -from botframework.connector.token_api.models import TokenExchangeResource class SimpleConversationIdFactory(ConversationIdFactoryBase): @@ -148,10 +148,13 @@ async def capture( conversation_state=conversation_state, ) + assert len(dialog_options.conversation_id_factory.conversation_refs) == 0 + # Send something to the dialog to start it await client.send_activity(MessageFactory.text("irrelevant")) # Assert results and data sent to the SkillClient for fist turn + assert len(dialog_options.conversation_id_factory.conversation_refs) == 1 assert dialog_options.bot_id == from_bot_id_sent assert dialog_options.skill.app_id == to_bot_id_sent assert dialog_options.skill.skill_endpoint == to_url_sent @@ -162,6 +165,7 @@ async def capture( await client.send_activity(MessageFactory.text("Second message")) # Assert results for second turn + assert len(dialog_options.conversation_id_factory.conversation_refs) == 1 assert activity_sent.text == "Second message" assert DialogTurnStatus.Waiting == client.dialog_turn_result.status From 310521195b6506f258a32f1da5d2c282f17b22a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Fri, 7 Aug 2020 16:26:49 -0700 Subject: [PATCH 552/616] Added coverage report to the pipeline (#1256) Co-authored-by: tracyboehrer --- pipelines/botbuilder-python-ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index b148cc5ad..c5d11005f 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -69,6 +69,13 @@ jobs: pytest --junitxml=junit/test-results.$(PYTHON_VERSION).xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html displayName: Pytest + - task: PublishCodeCoverageResults@1 + displayName: 'Publish Test Coverage' + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' + reportDirectory: '$(System.DefaultWorkingDirectory)/**/htmlcov' + - task: PublishTestResults@2 displayName: 'Publish Test Results **/test-results.$(PYTHON_VERSION).xml' inputs: From c259ae70c5df498f2ef221981986790da4c88684 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Mon, 10 Aug 2020 16:56:42 -0700 Subject: [PATCH 553/616] Add end_on_invalid_message and fix timeout issue (#1339) * Add end_on_invalid_message and fix timeout issue * fixing pylint Co-authored-by: Axel Suarez --- .../dialogs/prompts/oauth_prompt.py | 18 +- .../dialogs/prompts/oauth_prompt_settings.py | 7 + .../tests/test_oauth_prompt.py | 154 ++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index 588b22c38..c9d8bb5a9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -203,13 +203,17 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu The prompt generally continues to receive the user's replies until it accepts the user's reply as valid input for the prompt. """ - # Recognize token - recognized = await self._recognize_token(dialog_context) - # Check for timeout state = dialog_context.active_dialog.state is_message = dialog_context.context.activity.type == ActivityTypes.message - has_timed_out = is_message and ( + is_timeout_activity_type = ( + is_message + or OAuthPrompt._is_token_response_event(dialog_context.context) + or OAuthPrompt._is_teams_verification_invoke(dialog_context.context) + or OAuthPrompt._is_token_exchange_request_invoke(dialog_context.context) + ) + + has_timed_out = is_timeout_activity_type and ( datetime.now() > state[OAuthPrompt.PERSISTED_EXPIRES] ) @@ -221,6 +225,9 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu else: state["state"]["attemptCount"] += 1 + # Recognize token + recognized = await self._recognize_token(dialog_context) + # Validate the return value is_valid = False if self._validator is not None: @@ -238,6 +245,9 @@ async def continue_dialog(self, dialog_context: DialogContext) -> DialogTurnResu # Return recognized value or re-prompt if is_valid: return await dialog_context.end_dialog(recognized.value) + if is_message and self._settings.end_on_invalid_message: + # If EndOnInvalidMessage is set, complete the prompt with no result. + return await dialog_context.end_dialog(None) # Send retry prompt if ( diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py index 1d8f04eca..c071c590e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py @@ -11,6 +11,7 @@ def __init__( text: str = None, timeout: int = None, oauth_app_credentials: AppCredentials = None, + end_on_invalid_message: bool = False, ): """ Settings used to configure an `OAuthPrompt` instance. @@ -22,9 +23,15 @@ def __init__( `OAuthPrompt` defaults value to `900,000` ms (15 minutes). oauth_app_credentials (AppCredentials): (Optional) AppCredentials to use for OAuth. If None, the Bots credentials are used. + end_on_invalid_message (bool): (Optional) value indicating whether the OAuthPrompt should end upon + receiving an invalid message. Generally the OAuthPrompt will ignore incoming messages from the + user during the auth flow, if they are not related to the auth flow. This flag enables ending the + OAuthPrompt rather than ignoring the user's message. Typically, this flag will be set to 'true', + but is 'false' by default for backwards compatibility. """ self.connection_name = connection_name self.title = title self.text = text self.timeout = timeout self.oath_app_credentials = oauth_app_credentials + self.end_on_invalid_message = end_on_invalid_message diff --git a/libraries/botbuilder-dialogs/tests/test_oauth_prompt.py b/libraries/botbuilder-dialogs/tests/test_oauth_prompt.py index a5802103a..a6b22553b 100644 --- a/libraries/botbuilder-dialogs/tests/test_oauth_prompt.py +++ b/libraries/botbuilder-dialogs/tests/test_oauth_prompt.py @@ -9,6 +9,7 @@ ChannelAccount, ConversationAccount, InputHints, + SignInConstants, TokenResponse, ) @@ -260,3 +261,156 @@ async def callback_handler(turn_context: TurnContext): await adapter.send("Hello") self.assertTrue(called) + + async def test_should_end_oauth_prompt_on_invalid_message_when_end_on_invalid_message( + self, + ): + connection_name = "myConnection" + token = "abc123" + magic_code = "888999" + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("prompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete: + if results.result and results.result.token: + await turn_context.send_activity("Failed") + + else: + await turn_context.send_activity("Ended") + + await convo_state.save_changes(turn_context) + + # Initialize TestAdapter. + adapter = TestAdapter(exec_test) + + # Create ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and AttachmentPrompt. + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", + OAuthPromptSettings(connection_name, "Login", None, 300000, None, True), + ) + ) + + def inspector( + activity: Activity, description: str = None + ): # pylint: disable=unused-argument + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + + # send a mock EventActivity back to the bot with the token + adapter.add_user_token( + connection_name, + activity.channel_id, + activity.recipient.id, + token, + magic_code, + ) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(inspector) + step3 = await step2.send("test invalid message") + await step3.assert_reply("Ended") + + async def test_should_timeout_oauth_prompt_with_message_activity(self,): + activity = Activity(type=ActivityTypes.message, text="any") + await self.run_timeout_test(activity) + + async def test_should_timeout_oauth_prompt_with_token_response_event_activity( + self, + ): + activity = Activity( + type=ActivityTypes.event, name=SignInConstants.token_response_event_name + ) + await self.run_timeout_test(activity) + + async def test_should_timeout_oauth_prompt_with_verify_state_operation_activity( + self, + ): + activity = Activity( + type=ActivityTypes.invoke, name=SignInConstants.verify_state_operation_name + ) + await self.run_timeout_test(activity) + + async def test_should_not_timeout_oauth_prompt_with_custom_event_activity(self,): + activity = Activity(type=ActivityTypes.event, name="custom event name") + await self.run_timeout_test(activity, False, "Ended", "Failed") + + async def run_timeout_test( + self, + activity: Activity, + should_succeed: bool = True, + token_response: str = "Failed", + no_token_resonse="Ended", + ): + connection_name = "myConnection" + token = "abc123" + magic_code = "888999" + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + await dialog_context.prompt("prompt", PromptOptions()) + elif results.status == DialogTurnStatus.Complete or ( + results.status == DialogTurnStatus.Waiting and not should_succeed + ): + if results.result and results.result.token: + await turn_context.send_activity(token_response) + + else: + await turn_context.send_activity(no_token_resonse) + + await convo_state.save_changes(turn_context) + + # Initialize TestAdapter. + adapter = TestAdapter(exec_test) + + # Create ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and AttachmentPrompt. + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add( + OAuthPrompt( + "prompt", OAuthPromptSettings(connection_name, "Login", None, 1), + ) + ) + + def inspector( + activity: Activity, description: str = None + ): # pylint: disable=unused-argument + assert len(activity.attachments) == 1 + assert ( + activity.attachments[0].content_type + == CardFactory.content_types.oauth_card + ) + + # send a mock EventActivity back to the bot with the token + adapter.add_user_token( + connection_name, + activity.channel_id, + activity.recipient.id, + token, + magic_code, + ) + + step1 = await adapter.send("Hello") + step2 = await step1.assert_reply(inspector) + step3 = await step2.send(activity) + await step3.assert_reply(no_token_resonse) From e8859626bf197a68cf9559458571fe592d1ffcd4 Mon Sep 17 00:00:00 2001 From: Chris Mullins Date: Tue, 11 Aug 2020 13:55:10 -0700 Subject: [PATCH 554/616] Update README.md Update to reflect 4.11 as the new nightly build branch. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a5befa93..1e1011107 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For more information jump to a section below. | Branch | Description | Build Status | Coverage Status | Code Style | |----|---------------|--------------|-----------------|--| -| Master | 4.10.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=master) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | +| Master | 4.11.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=master) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | ## Packages From c5c5a3657d85242abc44e49202217f9d7e3b71c0 Mon Sep 17 00:00:00 2001 From: Gabo Gilabert Date: Fri, 14 Aug 2020 15:01:43 -0400 Subject: [PATCH 555/616] Updated feature and bug templates to use new labels Deleted auto label generation workflow that was generating random labels --- .github/ISSUE_TEMPLATE/python-sdk-bug.md | 8 +++++--- .../ISSUE_TEMPLATE/python-sdk-feature-request.md | 8 +++++--- .github/workflows/main.yml | 13 ------------- 3 files changed, 10 insertions(+), 19 deletions(-) delete mode 100644 .github/workflows/main.yml diff --git a/.github/ISSUE_TEMPLATE/python-sdk-bug.md b/.github/ISSUE_TEMPLATE/python-sdk-bug.md index 3fd6037d9..435fe4310 100644 --- a/.github/ISSUE_TEMPLATE/python-sdk-bug.md +++ b/.github/ISSUE_TEMPLATE/python-sdk-bug.md @@ -1,9 +1,13 @@ --- name: Python SDK Bug about: Create a bug report for a bug you found in the Bot Builder Python SDK - +title: "" +labels: "needs-triage, bug" +assignees: "" --- +### [Github issues](https://github.com/Microsoft/botbuilder-python) should be used for bugs and feature requests. Use [Stack Overflow](https://stackoverflow.com/questions/tagged/botframework) for general "how-to" questions. + ## Version What package version of the SDK are you using. @@ -25,5 +29,3 @@ If applicable, add screenshots to help explain your problem. ## Additional context Add any other context about the problem here. - -[bug] diff --git a/.github/ISSUE_TEMPLATE/python-sdk-feature-request.md b/.github/ISSUE_TEMPLATE/python-sdk-feature-request.md index e3f0aad0e..d498599d9 100644 --- a/.github/ISSUE_TEMPLATE/python-sdk-feature-request.md +++ b/.github/ISSUE_TEMPLATE/python-sdk-feature-request.md @@ -1,9 +1,13 @@ --- name: Python SDK Feature Request about: Suggest a feature for the Bot Builder Python SDK - +title: "" +labels: "needs-triage, feature-request" +assignees: "" --- +### Use this [query](https://github.com/Microsoft/botbuilder-python/issues?q=is%3Aissue+is%3Aopen++label%3Afeature-request+) to search for the most popular feature requests. + **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] @@ -15,5 +19,3 @@ A clear and concise description of any alternative solutions or features you've **Additional context** Add any other context or screenshots about the feature request here. - -[enhancement] diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 8ae9df9dc..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,13 +0,0 @@ - -on: [issues] - -jobs: - on-issue-update: - runs-on: ubuntu-latest - name: Tag issues - steps: - - name: Issue tagging - id: issue-autotagger - uses: christopheranderson/issue-autotagger@v1 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" From a0a26d651575e383c00cc8f0e07d9842bf49f988 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 25 Aug 2020 19:27:30 -0700 Subject: [PATCH 556/616] Add installation update sub events to ActivityHandler --- .../botbuilder/core/activity_handler.py | 30 ++++++++++++++++ .../tests/test_activity_handler.py | 36 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 74515e709..bce2f0032 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -374,6 +374,36 @@ async def on_installation_update( # pylint: disable=unused-argument Override this in a derived class to provide logic specific to ActivityTypes.InstallationUpdate activities. + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + :returns: A task that represents the work queued to execute + """ + if turn_context.activity.action == "add": + return await self.on_installation_update_add_activity(turn_context) + if turn_context.activity.action == "remove": + return await self.on_installation_update_remove_activity(turn_context) + return + + async def on_installation_update_add_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + """ + Override this in a derived class to provide logic specific to + ActivityTypes.InstallationUpdate activities with 'action' set to 'add'. + + :param turn_context: The context object for this turn + :type turn_context: :class:`botbuilder.core.TurnContext` + :returns: A task that represents the work queued to execute + """ + return + + async def on_installation_update_remove_activity( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + """ + Override this in a derived class to provide logic specific to + ActivityTypes.InstallationUpdate activities with 'action' set to 'remove'. + :param turn_context: The context object for this turn :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 3cdd052f8..710e6c872 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -77,6 +77,14 @@ async def on_installation_update(self, turn_context: TurnContext): self.record.append("on_installation_update") return await super().on_installation_update(turn_context) + async def on_installation_update_add_activity(self, turn_context: TurnContext): + self.record.append("on_installation_update_add_activity") + return await super().on_installation_update_add_activity(turn_context) + + async def on_installation_update_remove_activity(self, turn_context: TurnContext): + self.record.append("on_installation_update_remove_activity") + return await super().on_installation_update_remove_activity(turn_context) + async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") return await super().on_unrecognized_activity_type(turn_context) @@ -246,6 +254,34 @@ async def test_on_installation_update(self): assert len(bot.record) == 1 assert bot.record[0] == "on_installation_update" + async def test_on_installation_update_add_activity(self): + activity = Activity(type=ActivityTypes.installation_update, action="add") + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 2 + assert bot.record[0] == "on_installation_update" + assert bot.record[1] == "on_installation_update_add_activity" + + async def test_on_installation_update_add_remove_activity(self): + activity = Activity(type=ActivityTypes.installation_update, action="remove") + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 2 + assert bot.record[0] == "on_installation_update" + assert bot.record[1] == "on_installation_update_remove_activity" + async def test_healthcheck(self): activity = Activity(type=ActivityTypes.invoke, name="healthcheck",) From 1ae18361eb9178eb10d21a922000f62e51376f9a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 27 Aug 2020 16:13:24 -0500 Subject: [PATCH 557/616] Link updates for master rename (#1366) --- README.md | 2 +- specs/DailyBuildProposal.md | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1e1011107..cd32b704e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For more information jump to a section below. | Branch | Description | Build Status | Coverage Status | Code Style | |----|---------------|--------------|-----------------|--| -| Master | 4.11.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=master)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=master) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | +| Main | 4.11.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=main)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=main) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | ## Packages diff --git a/specs/DailyBuildProposal.md b/specs/DailyBuildProposal.md index 7381dc7c2..f68e154ad 100644 --- a/specs/DailyBuildProposal.md +++ b/specs/DailyBuildProposal.md @@ -1,14 +1,14 @@ -# Daily Build Propsal for .Net BotBuilder SDK +# Daily Build Propsal for .Net BotBuilder SDK This proposal describes our plan to publish daily builds for consumption. The goals of this are: -1. Make it easy for developers (1P and 3P) to consume our daily builds. -2. Exercise our release process frequently, so issues don't arise at critical times. +1. Make it easy for developers (1P and 3P) to consume our daily builds. +2. Exercise our release process frequently, so issues don't arise at critical times. 3. Meet Developers where they are. -Use the [ASP.Net Team](https://github.com/dotnet/aspnetcore/blob/master/docs/DailyBuilds.md) as inspiration, and draft off the work they do. +Use the [ASP.Net Team](https://github.com/dotnet/aspnetcore/blob/master/docs/DailyBuilds.md) as inspiration, and draft off the work they do. # Versioning -Move to Python suggested versioning for dailies defined in [PEP440](https://www.python.org/dev/peps/pep-0440/#developmental-releases). +Move to Python suggested versioning for dailies defined in [PEP440](https://www.python.org/dev/peps/pep-0440/#developmental-releases). The tags we use for preview versions are: ``` @@ -17,11 +17,11 @@ The tags we use for preview versions are: ``` # Daily Builds -All our Python wheel packages would be pushed to the SDK_Public project at [fuselabs.visualstudio.com](https://fuselabs.visualstudio.com). +All our Python wheel packages would be pushed to the SDK_Public project at [fuselabs.visualstudio.com](https://fuselabs.visualstudio.com). - Note: Only a public project on Devops can have a public feed. The public project on our Enterprise Tenant is [SDK_Public](https://fuselabs.visualstudio.com/SDK_Public). + Note: Only a public project on Devops can have a public feed. The public project on our Enterprise Tenant is [SDK_Public](https://fuselabs.visualstudio.com/SDK_Public). -This means developers could add this feed their projects by adding the following command on a pip conf file, or in the pip command itself: +This means developers could add this feed their projects by adding the following command on a pip conf file, or in the pip command itself: ```bash extra-index-url=https://pkgs.dev.azure.com/ConversationalAI/BotFramework/_packaging/SDK%40Local/pypi/simple/ @@ -32,27 +32,27 @@ To debug daily builds in VSCode: * In the launch.json configuration file set the option `"justMyCode": false`. ## Daily Build Lifecyle -Daily builds older than 90 days are automatically deleted. +Daily builds older than 90 days are automatically deleted. # Summary - Weekly Builds -Once per week, preferably on a Monday, a daily build is pushed to PyPI test. This build happens from master, the same as a standard daily build. This serves 2 purposes: +Once per week, preferably on a Monday, a daily build is pushed to PyPI test. This build happens from 'main', the same as a standard daily build. This serves 2 purposes: 1. Keeps PyPI "Fresh" for people that don't want daily builds. -2. Keeps the release pipelines active and working, and prevents issues. +2. Keeps the release pipelines active and working, and prevents issues. -These builds will have the "-dev" tag and ARE the the daily build. +These builds will have the "-dev" tag and ARE the the daily build. **This release pipeline should be the EXACT same pipeline that releases our production bits.** -Weekly builds older than 1 year should be automatically delisted. +Weekly builds older than 1 year should be automatically delisted. ## Adding packages to the feed Our existing Release pipelines would add packages to the feed. # Migration from MyGet -1. Initially, our daily builds should go to both MyGet and Azure Devops. -2. Our docs are updated once builds are in both locations. +1. Initially, our daily builds should go to both MyGet and Azure Devops. +2. Our docs are updated once builds are in both locations. 3. Towards the end of 2020, we stop publising to MyGet. # Containers -ASP.Net and .Net Core 5 also publish a container to [Docker Hub](https://hub.docker.com/_/microsoft-dotnet-nightly-aspnet/) as part of their daily feed. We should consider that, along with our samples, in the next iteration of this work. +ASP.Net and .Net Core 5 also publish a container to [Docker Hub](https://hub.docker.com/_/microsoft-dotnet-nightly-aspnet/) as part of their daily feed. We should consider that, along with our samples, in the next iteration of this work. From c1e98deef5e51ceea63feeb7a9950071af097a12 Mon Sep 17 00:00:00 2001 From: Ashley Finafrock <35248895+Zerryth@users.noreply.github.com> Date: Fri, 28 Aug 2020 10:34:27 -0700 Subject: [PATCH 558/616] Allow requests module as QnAMaker's HTTP client (#1369) * Allow QnA to make reqs using requests library * Added unit test: test requests http client w/o timeout * Reordered methods to the default, more-frequently used method first * Linted --- .../botbuilder/ai/qna/models/prompt.py | 2 +- .../ai/qna/models/qna_response_context.py | 2 +- .../botbuilder/ai/qna/models/query_result.py | 2 +- .../botbuilder/ai/qna/models/query_results.py | 2 +- .../ai/qna/utils/generate_answer_utils.py | 14 +++- .../ai/qna/utils/http_request_utils.py | 72 +++++++++++++++---- libraries/botbuilder-ai/tests/qna/test_qna.py | 58 ++++++++++++++- 7 files changed, 130 insertions(+), 22 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py index 0865c2d22..b0a2fe7fe 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/prompt.py @@ -15,7 +15,7 @@ class Prompt(Model): } def __init__(self, **kwargs): - super(Prompt, self).__init__(**kwargs) + super().__init__(**kwargs) self.display_order = kwargs.get("display_order", None) self.qna_id = kwargs.get("qna_id", None) self.display_text = kwargs.get("display_text", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py index e3814cca9..bf68bb213 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/qna_response_context.py @@ -26,6 +26,6 @@ def __init__(self, **kwargs): """ - super(QnAResponseContext, self).__init__(**kwargs) + super().__init__(**kwargs) self.is_context_only = kwargs.get("is_context_only", None) self.prompts = kwargs.get("prompts", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py index f91febf5f..a0b1c2c0a 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_result.py @@ -18,7 +18,7 @@ class QueryResult(Model): } def __init__(self, **kwargs): - super(QueryResult, self).__init__(**kwargs) + super().__init__(**kwargs) self.questions = kwargs.get("questions", None) self.answer = kwargs.get("answer", None) self.score = kwargs.get("score", None) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_results.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_results.py index 17fd2a2c8..f3c413618 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_results.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/query_results.py @@ -25,6 +25,6 @@ def __init__( active_learning_enabled: The active learning enable flag. """ - super(QueryResults, self).__init__(**kwargs) + super().__init__(**kwargs) self.answers = answers self.active_learning_enabled = active_learning_enabled diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py index aaed7fbca..b12c492c7 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py @@ -2,7 +2,9 @@ # Licensed under the MIT License. from copy import copy -from typing import List, Union +from typing import Any, List, Union +import json +import requests from aiohttp import ClientResponse, ClientSession @@ -109,7 +111,8 @@ def _hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: with the options passed as arguments into get_answers(). Return: ------- - QnAMakerOptions with options passed into constructor overwritten by new options passed into get_answers() + QnAMakerOptions with options passed into constructor overwritten + by new options passed into get_answers() rtype: ------ @@ -162,7 +165,7 @@ async def _query_qna_service( http_request_helper = HttpRequestUtils(self._http_client) - response: ClientResponse = await http_request_helper.execute_http_request( + response: Any = await http_request_helper.execute_http_request( url, question, self._endpoint, options.timeout ) @@ -200,14 +203,19 @@ async def _format_qna_result( self, result, options: QnAMakerOptions ) -> QueryResults: json_res = result + if isinstance(result, ClientResponse): json_res = await result.json() + if isinstance(result, requests.Response): + json_res = json.loads(result.text) + answers_within_threshold = [ {**answer, "score": answer["score"] / 100} for answer in json_res["answers"] if answer["score"] / 100 > options.score_threshold ] + sorted_answers = sorted( answers_within_threshold, key=lambda ans: ans["score"], reverse=True ) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py index c1d0035e5..977f839de 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/http_request_utils.py @@ -3,6 +3,8 @@ import json import platform +from typing import Any +import requests from aiohttp import ClientResponse, ClientSession, ClientTimeout @@ -12,9 +14,15 @@ class HttpRequestUtils: - """ HTTP request utils class. """ + """ HTTP request utils class. - def __init__(self, http_client: ClientSession): + Parameters: + ----------- + + http_client: Client to make HTTP requests with. Default client used in the SDK is `aiohttp.ClientSession`. + """ + + def __init__(self, http_client: Any): self._http_client = http_client async def execute_http_request( @@ -23,7 +31,7 @@ async def execute_http_request( payload_body: object, endpoint: QnAMakerEndpoint, timeout: float = None, - ) -> ClientResponse: + ) -> Any: """ Execute HTTP request. @@ -57,19 +65,16 @@ async def execute_http_request( headers = self._get_headers(endpoint) - if timeout: - # Convert miliseconds to seconds (as other BotBuilder SDKs accept timeout value in miliseconds) - # aiohttp.ClientSession units are in seconds - request_timeout = ClientTimeout(total=timeout / 1000) - - response: ClientResponse = await self._http_client.post( - request_url, - data=serialized_payload_body, - headers=headers, - timeout=request_timeout, + if isinstance(self._http_client, ClientSession): + response: ClientResponse = await self._make_request_with_aiohttp( + request_url, serialized_payload_body, headers, timeout + ) + elif self._is_using_requests_module(): + response: requests.Response = self._make_request_with_requests( + request_url, serialized_payload_body, headers, timeout ) else: - response: ClientResponse = await self._http_client.post( + response = await self._http_client.post( request_url, data=serialized_payload_body, headers=headers ) @@ -94,3 +99,42 @@ def _get_user_agent(self): user_agent = f"{package_user_agent} {platform_user_agent}" return user_agent + + def _is_using_requests_module(self) -> bool: + return (type(self._http_client).__name__ == "module") and ( + self._http_client.__name__ == "requests" + ) + + async def _make_request_with_aiohttp( + self, request_url: str, payload_body: str, headers: dict, timeout: float + ) -> ClientResponse: + if timeout: + # aiohttp.ClientSession's timeouts are in seconds + timeout_in_seconds = ClientTimeout(total=timeout / 1000) + + return await self._http_client.post( + request_url, + data=payload_body, + headers=headers, + timeout=timeout_in_seconds, + ) + + return await self._http_client.post( + request_url, data=payload_body, headers=headers + ) + + def _make_request_with_requests( + self, request_url: str, payload_body: str, headers: dict, timeout: float + ) -> requests.Response: + if timeout: + # requests' timeouts are in seconds + timeout_in_seconds = timeout / 1000 + + return self._http_client.post( + request_url, + data=payload_body, + headers=headers, + timeout=timeout_in_seconds, + ) + + return self._http_client.post(request_url, data=payload_body, headers=headers) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 309967839..03e176d6e 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -5,6 +5,7 @@ # pylint: disable=too-many-lines import json +import requests from os import path from typing import List, Dict import unittest @@ -19,7 +20,8 @@ QueryResult, QnARequestContext, ) -from botbuilder.ai.qna.utils import QnATelemetryConstants +from botbuilder.ai.qna.utils import HttpRequestUtils, QnATelemetryConstants +from botbuilder.ai.qna.models import GenerateAnswerRequestBody from botbuilder.core import BotAdapter, BotTelemetryClient, TurnContext from botbuilder.core.adapters import TestAdapter from botbuilder.schema import ( @@ -164,6 +166,27 @@ async def test_active_learning_enabled_status(self): self.assertEqual(1, len(result.answers)) self.assertFalse(result.active_learning_enabled) + async def test_returns_answer_using_requests_module(self): + question: str = "how do I clean the stove?" + response_path: str = "ReturnsAnswer.json" + response_json = QnaApplicationTest._get_json_for_file(response_path) + + qna = QnAMaker( + endpoint=QnaApplicationTest.tests_endpoint, http_client=requests + ) + context = QnaApplicationTest._get_context(question, TestAdapter()) + + with patch("requests.post", return_value=response_json): + result = await qna.get_answers_raw(context) + answers = result.answers + + self.assertIsNotNone(result) + self.assertEqual(1, len(answers)) + self.assertEqual( + "BaseCamp: You can use a damp rag to clean around the Power Pack", + answers[0].answer, + ) + async def test_returns_answer_using_options(self): # Arrange question: str = "up" @@ -254,6 +277,39 @@ async def test_returns_answer_with_timeout(self): options.timeout, qna._generate_answer_helper.options.timeout ) + async def test_returns_answer_using_requests_module_with_no_timeout(self): + url = f"{QnaApplicationTest._host}/knowledgebases/{QnaApplicationTest._knowledge_base_id}/generateAnswer" + question = GenerateAnswerRequestBody( + question="how do I clean the stove?", + top=1, + score_threshold=0.3, + strict_filters=[], + context=None, + qna_id=None, + is_test=False, + ranker_type="Default" + ) + response_path = "ReturnsAnswer.json" + response_json = QnaApplicationTest._get_json_for_file(response_path) + + http_request_helper = HttpRequestUtils(requests) + + with patch("requests.post", return_value=response_json): + result = await http_request_helper.execute_http_request( + url, + question, + QnaApplicationTest.tests_endpoint, + timeout=None + ) + answers = result["answers"] + + self.assertIsNotNone(result) + self.assertEqual(1, len(answers)) + self.assertEqual( + "BaseCamp: You can use a damp rag to clean around the Power Pack", + answers[0]["answer"], + ) + async def test_telemetry_returns_answer(self): # Arrange question: str = "how do I clean the stove?" From 159901f4e447dd8dec5f691d367281319dc01922 Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Thu, 3 Sep 2020 16:41:44 -0700 Subject: [PATCH 559/616] Throw if is_skill_claim and claims_validator is null (#1375) * Throw if is_skill_claim and claims_validator is null * Update jwt_token_validation.py --- .../botframework/connector/auth/jwt_token_validation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index 2d21c4af1..aecf27ce6 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -165,6 +165,9 @@ async def validate_claims( ): if auth_config and auth_config.claims_validator: await auth_config.claims_validator(claims) + elif SkillValidation.is_skill_claim(claims): + # Skill claims must be validated using AuthenticationConfiguration claims_validator + raise PermissionError("Unauthorized Access. Request is not authorized. Skill Claims require validation.") @staticmethod def is_government(channel_service: str) -> bool: From e88913b06eac9994883456df802a0a7046d11dc0 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Thu, 3 Sep 2020 17:16:38 -0700 Subject: [PATCH 560/616] Ref comment fixes botbuilder-ai --- .../botbuilder/ai/luis/luis_recognizer.py | 4 ++-- .../botbuilder-ai/botbuilder/ai/qna/qnamaker.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 2bb73948f..c94a2149f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -80,7 +80,7 @@ def top_intent( :param default_intent: Intent name to return should a top intent be found, defaults to None. :type default_intent: str, optional :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in - the set are below this threshold then the `defaultIntent` will be returned, defaults to 0.0. + the set are below this threshold then the `defaultIntent` will be returned, defaults to 0.0. :type min_score: float, optional :raises TypeError: :return: The top scoring intent name. @@ -191,7 +191,7 @@ def fill_luis_event_properties( defaults to None :param telemetry_properties: :class:`typing.Dict[str, str]`, optional :return: A dictionary that is sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` - method for the BotMessageSend event. + method for the BotMessageSend event. :rtype: `typing.Dict[str, str]` """ diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 00d026339..62ffa1b8d 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -166,14 +166,16 @@ async def fill_qna_event( """ Fills the event properties and metrics for the QnaMessage event for telemetry. - :return: A tuple of event data properties and metrics that will be sent to the - :func:`botbuilder.core.BotTelemetryClient.track_event` method for the QnAMessage event. - The properties and metrics returned the standard properties logged - with any properties passed from the :func:`get_answers` method. + :param query_results: QnA service results. + :type quert_results: :class:`QueryResult` + :param turn_context: Context object containing information for a single turn of conversation with a user. + :type turn_context: :class:`botbuilder.core.TurnContext` + :param telemetry_properties: Properties to add/override for the event. + :type telemetry_properties: :class:`Typing.Dict` + :param telemetry_metrics: Metrics to add/override for the event. + :type telemetry_metrics: :class:`Typing.Dict` :return: Event properties and metrics for the QnaMessage event for telemetry. :rtype: :class:`EventData` - ------ - EventData """ properties: Dict[str, str] = dict() From 13190c4e3c0f3586515a079ed5b9e92771b04261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Thu, 3 Sep 2020 18:52:51 -0700 Subject: [PATCH 561/616] Black version updated in pipeline (#1382) * Update botbuilder-python-ci.yml pinned right version of black in the pipeline * black compliant * pylint compliant --- libraries/botbuilder-ai/tests/qna/test_qna.py | 22 ++++++++----------- .../connector/auth/jwt_token_validation.py | 4 +++- pipelines/botbuilder-python-ci.yml | 2 +- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 03e176d6e..e733b6564 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -4,12 +4,13 @@ # pylint: disable=protected-access # pylint: disable=too-many-lines -import json -import requests +import unittest from os import path from typing import List, Dict -import unittest from unittest.mock import patch + +import json +import requests from aiohttp import ClientSession import aiounittest @@ -171,9 +172,7 @@ async def test_returns_answer_using_requests_module(self): response_path: str = "ReturnsAnswer.json" response_json = QnaApplicationTest._get_json_for_file(response_path) - qna = QnAMaker( - endpoint=QnaApplicationTest.tests_endpoint, http_client=requests - ) + qna = QnAMaker(endpoint=QnaApplicationTest.tests_endpoint, http_client=requests) context = QnaApplicationTest._get_context(question, TestAdapter()) with patch("requests.post", return_value=response_json): @@ -185,7 +184,7 @@ async def test_returns_answer_using_requests_module(self): self.assertEqual( "BaseCamp: You can use a damp rag to clean around the Power Pack", answers[0].answer, - ) + ) async def test_returns_answer_using_options(self): # Arrange @@ -287,7 +286,7 @@ async def test_returns_answer_using_requests_module_with_no_timeout(self): context=None, qna_id=None, is_test=False, - ranker_type="Default" + ranker_type="Default", ) response_path = "ReturnsAnswer.json" response_json = QnaApplicationTest._get_json_for_file(response_path) @@ -296,10 +295,7 @@ async def test_returns_answer_using_requests_module_with_no_timeout(self): with patch("requests.post", return_value=response_json): result = await http_request_helper.execute_http_request( - url, - question, - QnaApplicationTest.tests_endpoint, - timeout=None + url, question, QnaApplicationTest.tests_endpoint, timeout=None ) answers = result["answers"] @@ -308,7 +304,7 @@ async def test_returns_answer_using_requests_module_with_no_timeout(self): self.assertEqual( "BaseCamp: You can use a damp rag to clean around the Power Pack", answers[0]["answer"], - ) + ) async def test_telemetry_returns_answer(self): # Arrange diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index aecf27ce6..737ba39ad 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -167,7 +167,9 @@ async def validate_claims( await auth_config.claims_validator(claims) elif SkillValidation.is_skill_claim(claims): # Skill claims must be validated using AuthenticationConfiguration claims_validator - raise PermissionError("Unauthorized Access. Request is not authorized. Skill Claims require validation.") + raise PermissionError( + "Unauthorized Access. Request is not authorized. Skill Claims require validation." + ) @staticmethod def is_government(channel_service: str) -> bool: diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index c5d11005f..a55083ff1 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -59,7 +59,7 @@ jobs: pip install -r ./libraries/botbuilder-ai/tests/requirements.txt pip install coveralls pip install pylint==2.4.4 - pip install black + pip install black==19.10b0 displayName: 'Install dependencies' - script: | From 3bfdc9fdfb55fd83b266d003aaa4d7f0eff7ed5a Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Tue, 8 Sep 2020 11:52:32 -0700 Subject: [PATCH 562/616] Add SkillValidation Claims tests (#1383) * Add SkillValidation Claims tests * fix skill validation tests --- .../botframework-connector/tests/test_auth.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index a05b88796..e7371215c 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -62,7 +62,6 @@ async def test_claims_validation(self): # No validator should pass. await JwtTokenValidation.validate_claims(default_auth_config, claims) - # ClaimsValidator configured but no exception should pass. mock_validator = Mock() auth_with_validator = AuthenticationConfiguration( claims_validator=mock_validator @@ -75,6 +74,34 @@ async def test_claims_validation(self): assert "Invalid claims." in str(excinfo.value) + # No validator with not skill cliams should pass. + default_auth_config.claims_validator = None + claims: List[Dict] = { + AuthenticationConstants.VERSION_CLAIM: "1.0", + AuthenticationConstants.AUDIENCE_CLAIM: "this_bot_id", + AuthenticationConstants.APP_ID_CLAIM: "this_bot_id", # Skill claims aud!=azp + } + + await JwtTokenValidation.validate_claims(default_auth_config, claims) + + # No validator with skill cliams should fail. + claims: List[Dict] = { + AuthenticationConstants.VERSION_CLAIM: "1.0", + AuthenticationConstants.AUDIENCE_CLAIM: "this_bot_id", + AuthenticationConstants.APP_ID_CLAIM: "not_this_bot_id", # Skill claims aud!=azp + } + + mock_validator.side_effect = PermissionError( + "Unauthorized Access. Request is not authorized. Skill Claims require validation." + ) + with pytest.raises(PermissionError) as excinfo_skill: + await JwtTokenValidation.validate_claims(auth_with_validator, claims) + + assert ( + "Unauthorized Access. Request is not authorized. Skill Claims require validation." + in str(excinfo_skill.value) + ) + @pytest.mark.asyncio async def test_connector_auth_header_correct_app_id_and_service_url_should_validate( self, From c44e273ac662881f3dd08be5475cfa116c675d32 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 15 Sep 2020 16:41:38 -0700 Subject: [PATCH 563/616] botbuilder-adapter-slack reference comment updates --- .../adapters/slack/slack_adapter.py | 44 ++++++++++----- .../botbuilder/adapters/slack/slack_helper.py | 56 +++++++++++++------ .../adapters/slack/slack_options.py | 28 ++++++---- 3 files changed, 86 insertions(+), 42 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 918c01d70..652115f5b 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -24,8 +24,7 @@ class SlackAdapter(BotAdapter, ABC): """ - BotAdapter that can handle incoming slack events. Incoming slack events are deserialized to an Activity - that is dispatch through the middleware and bot pipeline. + BotAdapter that can handle incoming Slack events. Incoming Slack events are deserialized to an Activity that is dispatched through the middleware and bot pipeline. """ def __init__( @@ -41,11 +40,14 @@ async def send_activities( self, context: TurnContext, activities: List[Activity] ) -> List[ResourceResponse]: """ - Standard BotBuilder adapter method to send a message from the bot to the messaging API. + Send a message from the bot to the messaging API. :param context: A TurnContext representing the current incoming message and environment. + :type context: :class:`botbuilder.core.TurnContext` :param activities: An array of outgoing activities to be sent back to the messaging API. + :type activities: :class:`typing.List` :return: An array of ResourceResponse objects containing the IDs that Slack assigned to the sent messages. + :rtype: :class:`typing.List` """ if not context: @@ -76,11 +78,14 @@ async def send_activities( async def update_activity(self, context: TurnContext, activity: Activity): """ - Standard BotBuilder adapter method to update a previous message with new content. + Update a previous message with new content. :param context: A TurnContext representing the current incoming message and environment. + :type context: :class:`botbuilder.core.TurnContext` :param activity: The updated activity in the form '{id: `id of activity to update`, ...}'. - :return: A resource response with the Id of the updated activity. + :type activity: :class:`botbuilder.schema.Activity` + :return: A resource response with the ID of the updated activity. + :rtype: :class:`botbuilder.schema.ResourceResponse` """ if not context: @@ -106,11 +111,13 @@ async def delete_activity( self, context: TurnContext, reference: ConversationReference ): """ - Standard BotBuilder adapter method to delete a previous message. + Delete a previous message. :param context: A TurnContext representing the current incoming message and environment. + :type context: :class:`botbuilder.core.TurnContext` :param reference: An object in the form "{activityId: `id of message to delete`, - conversation: { id: `id of slack channel`}}". + conversation: { id: `id of Slack channel`}}". + :type reference: :class:`botbuilder.schema.ConversationReference` """ if not context: @@ -135,15 +142,22 @@ async def continue_conversation( audience: str = None, ): """ - Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. - Most _channels require a user to initiate a conversation with a bot before the bot can send activities - to the user. + Send a proactive message to a conversation. + + .. remarks:: + + Most channels require a user to initiate a conversation with a bot before the bot can send activities to the user. - :param bot_id: Unused for this override. :param reference: A reference to the conversation to continue. + :type reference: :class:`botbuilder.schema.ConversationReference` :param callback: The method to call for the resulting bot turn. + :type callback: :class:`typing.Callable` + :param bot_id: Unused for this override. + :type bot_id: str :param claims_identity: A ClaimsIdentity for the conversation. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` :param audience: Unused for this override. + :type audience: str """ if not reference: @@ -171,10 +185,14 @@ async def process(self, req: Request, logic: Callable) -> Response: """ Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. - :param req: The aoihttp Request object + :param req: The aiohttp Request object. + :type req: :class:`aiohttp.web_request.Request` :param logic: The method to call for the resulting bot turn. - :return: The aoihttp Response + :type logic: :class:`typing.List` + :return: The aiohttp Response. + :rtype: :class:`aiohttp.web_response.Response` """ + if not req: raise Exception("Request is required") diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py index de5b7e672..7aea3456a 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -27,10 +27,12 @@ class SlackHelper: @staticmethod def activity_to_slack(activity: Activity) -> SlackMessage: """ - Formats a BotBuilder activity into an outgoing Slack message. + Formats a BotBuilder Activity into an outgoing Slack message. + :param activity: A BotBuilder Activity object. - :return: A Slack message object with {text, attachments, channel, thread ts} as well - as any fields found in activity.channelData + :type activity: :class:`botbuilder.schema.Activity` + :return: A Slack message object with {text, attachments, channel, thread ts} and any fields found in activity.channelData. + :rtype: :class:`SlackMessage` """ if not activity: @@ -83,13 +85,18 @@ def response( # pylint: disable=unused-argument req: Request, code: int, text: str = None, encoding: str = None ) -> Response: """ - Formats an aiohttp Response - - :param req: The original aoihttp Request - :param code: The HTTP result code to return - :param text: The text to return - :param encoding: The text encoding. Defaults to utf-8 + Formats an aiohttp Response. + + :param req: The original aiohttp Request. + :type req: :class:`aiohttp.web_request.Request` + :param code: The HTTP result code to return. + :type code: int + :param text: The text to return. + :type text: str + :param encoding: The text encoding. Defaults to UTF-8. + :type encoding: str :return: The aoihttp Response + :rtype: :class:`aiohttp.web_response.Response` """ response = Response(status=code) @@ -103,10 +110,12 @@ def response( # pylint: disable=unused-argument @staticmethod def payload_to_activity(payload: SlackPayload) -> Activity: """ - Creates an activity based on the slack event payload. + Creates an activity based on the Slack event payload. - :param payload: The payload of the slack event. + :param payload: The payload of the Slack event. + :type payload: :class:`SlackPayload` :return: An activity containing the event data. + :rtype: :class:`botbuilder.schema.Activity` """ if not payload: @@ -138,11 +147,14 @@ def payload_to_activity(payload: SlackPayload) -> Activity: @staticmethod async def event_to_activity(event: SlackEvent, client: SlackClient) -> Activity: """ - Creates an activity based on the slack event data. + Creates an activity based on the Slack event data. - :param event: The data of the slack event. + :param event: The data of the Slack event. + :type event: :class:`SlackEvent` :param client: The Slack client. + :type client: :class:`SlackClient` :return: An activity containing the event data. + :rtype: :class:`botbuilder.schema.Activity` """ if not event: @@ -191,11 +203,14 @@ async def command_to_activity( body: SlackRequestBody, client: SlackClient ) -> Activity: """ - Creates an activity based on a slack event related to a slash command. + Creates an activity based on a Slack event related to a slash command. - :param body: The data of the slack event. + :param body: The data of the Slack event. + :type body: :class:`SlackRequestBody` :param client: The Slack client. + :type client: :class:`SlackClient` :return: An activity containing the event data. + :rtype: :class:`botbuilder.schema.Activity` """ if not body: @@ -223,7 +238,9 @@ def query_string_to_dictionary(query: str) -> {}: Converts a query string to a dictionary with key-value pairs. :param query: The query string to convert. + :type query: str :return: A dictionary with the query values. + :rtype: :class:`typing.Dict` """ values = {} @@ -247,9 +264,12 @@ def deserialize_body(content_type: str, request_body: str) -> SlackRequestBody: """ Deserializes the request's body as a SlackRequestBody object. - :param content_type: The content type of the body - :param request_body: The body of the request - :return: A SlackRequestBody object + :param content_type: The content type of the body. + :type content_type: str + :param request_body: The body of the request. + :type request_body: str + :return: A SlackRequestBody object. + :rtype: :class:`SlackRequestBody` """ if not request_body: diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py index a855ea98a..1f74e31a4 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py @@ -4,7 +4,7 @@ class SlackAdapterOptions: """ - Class for defining implementation of the SlackAdapter Options. + Defines the implementation of the SlackAdapter options. """ def __init__( @@ -14,10 +14,14 @@ def __init__( slack_client_signing_secret: str, ): """ - Initializes new instance of SlackAdapterOptions + Initializes a new instance of SlackAdapterOptions. + :param slack_verification_token: A token for validating the origin of incoming webhooks. + :type slack_verification_token: str :param slack_bot_token: A token for a bot to work on a single workspace. - :param slack_client_signing_secret: The token used to validate that incoming webhooks are originated from Slack. + :type slack_bot_token: str + :param slack_client_signing_secret: The token used to validate that incoming webhooks originated from Slack. + :type slack_client_signing_secret: str """ self.slack_verification_token = slack_verification_token self.slack_bot_token = slack_bot_token @@ -29,18 +33,20 @@ def __init__( async def get_token_for_team(self, team_id: str) -> str: """ - A method that receives a Slack team id and returns the bot token associated with that team. Required for - multi-team apps. - :param team_id:Team ID. - :return:The bot token associated with the team. + Receives a Slack team ID and returns the bot token associated with that team. Required for multi-team apps. + + :param team_id: The team ID. + :type team_id: str + :raises: :func:`NotImplementedError` """ raise NotImplementedError() async def get_bot_user_by_team(self, team_id: str) -> str: """ - A method that receives a Slack team id and returns the bot user id associated with that team. Required for - multi-team apps. - :param team_id:Team ID. - :return:The bot user id associated with that team. + A method that receives a Slack team ID and returns the bot user ID associated with that team. Required for multi-team apps. + + :param team_id: The team ID. + :type team_id: str + :raises: :func:`NotImplementedError` """ raise NotImplementedError() From 351973996aecd8b8b51dd6904a0beb1a5e13b16a Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 15 Sep 2020 18:11:48 -0700 Subject: [PATCH 564/616] Update luis_recognizer.py --- .../botbuilder/ai/luis/luis_recognizer.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index c94a2149f..5a6f7514e 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -20,7 +20,7 @@ class LuisRecognizer(Recognizer): """ - A LUIS based implementation of . + A LUIS based implementation of :class:`botbuilder.core.Recognizer`. """ # The value type for a LUIS trace activity. @@ -45,7 +45,7 @@ def __init__( :type prediction_options: :class:`LuisPredictionOptions`, optional :param include_api_results: True to include raw LUIS API response, defaults to False. :type include_api_results: bool, optional - :raises TypeError: + :raises: TypeError """ if isinstance(application, LuisApplication): @@ -79,10 +79,10 @@ def top_intent( :type results: :class:`botbuilder.core.RecognizerResult` :param default_intent: Intent name to return should a top intent be found, defaults to None. :type default_intent: str, optional - :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in - the set are below this threshold then the `defaultIntent` will be returned, defaults to 0.0. + :param min_score: Minimum score needed for an intent to be considered as a top intent. If all + intents in the set are below this threshold then the `defaultIntent` is returned, defaults to 0.0. :type min_score: float, optional - :raises TypeError: + :raises: TypeError :return: The top scoring intent name. :rtype: str """ @@ -108,9 +108,9 @@ async def recognize( # pylint: disable=arguments-differ telemetry_metrics: Dict[str, float] = None, luis_prediction_options: LuisPredictionOptions = None, ) -> RecognizerResult: - """Return results of the analysis (Suggested actions and intents). + """Return results of the analysis (suggested actions and intents). - :param turn_context: Context object containing information for a single turn of conversation with a user. + :param turn_context: Context object containing information for a single conversation turn with a user. :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None. @@ -138,7 +138,7 @@ def on_recognizer_result( ): """Invoked prior to a LuisResult being logged. - :param recognizer_result: The Luis Results for the call. + :param recognizer_result: The LuisResult for the call. :type recognizer_result: :class:`botbuilder.core.RecognizerResult` :param turn_context: Context object containing information for a single turn of conversation with a user. :type turn_context: :class:`botbuilder.core.TurnContext` @@ -187,11 +187,9 @@ def fill_luis_event_properties( :type recognizer_result: :class:`botbuilder.core.RecognizerResult` :param turn_context: Context object containing information for a single turn of conversation with a user. :type turn_context: :class:`botbuilder.core.TurnContext` - :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, - defaults to None - :param telemetry_properties: :class:`typing.Dict[str, str]`, optional - :return: A dictionary that is sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` - method for the BotMessageSend event. + :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None. + :type telemetry_properties: :class:`typing.Dict[str, str]`, optional + :return: A dictionary sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` for the BotMessageSend event. :rtype: `typing.Dict[str, str]` """ From af681bc311969a253c4987446f99f2abee206911 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Tue, 15 Sep 2020 19:21:20 -0700 Subject: [PATCH 565/616] appinsights ref comment fixes --- .../application_insights_telemetry_client.py | 59 ++++++++++++++++--- .../django/bot_telemetry_middleware.py | 22 +++---- .../applicationinsights/django/logging.py | 2 +- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 2ed566b4a..879337031 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -68,13 +68,19 @@ def track_pageview( ) -> None: """ Send information about the page viewed in the application (a web page for instance). + :param name: the name of the page that was viewed. + :type name: str :param url: the URL of the page that was viewed. + :type url: str :param duration: the duration of the page view in milliseconds. (defaults to: 0) + :duration: int :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :type properties: :class:`typing.Dict[str, object]` :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_pageview(name, url, duration, properties, measurements) @@ -88,13 +94,16 @@ def track_exception( ) -> None: """ Send information about a single exception that occurred in the application. + :param exception_type: the type of the exception that was thrown. :param value: the exception that the client wants to send. :param trace: the traceback information as returned by :func:`sys.exc_info`. :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :type properties: :class:`typing.Dict[str, object]` :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_exception( exception_type, value, trace, properties, measurements @@ -108,11 +117,15 @@ def track_event( ) -> None: """ Send information about a single event that has occurred in the context of the application. + :param name: the data to associate to this event. + :type name: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :type properties: :class:`typing.Dict[str, object]` :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_event(name, properties=properties, measurements=measurements) @@ -129,19 +142,27 @@ def track_metric( ) -> NotImplemented: """ Send information about a single metric data point that was captured for the application. + :param name: The name of the metric that was captured. + :type name: str :param value: The value of the metric that was captured. + :type value: float :param tel_type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) :param count: the number of metrics that were aggregated into this data point. (defaults to: None) + :type count: int :param min_val: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) + :type min_val: float :param max_val: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) + :type max_val: float :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) + :type std_dev: float :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) - """ + :type properties: :class:`typing.Dict[str, object]` + """ self._client.track_metric( name, value, tel_type, count, min_val, max_val, std_dev, properties ) @@ -151,8 +172,11 @@ def track_trace( ): """ Sends a single trace statement. + :param name: the trace statement. + :type name: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :type properties: :class:`typing.Dict[str, object]` :param severity: the severity level of this trace, one of DEBUG, INFO, WARNING, ERROR, CRITICAL """ self._client.track_trace(name, properties, severity) @@ -172,19 +196,30 @@ def track_request( ): """ Sends a single request that was captured for the application. + :param name: The name for this request. All requests with the same name will be grouped together. + :type name: str :param url: The actual URL for this request (to show in individual request instances). + :type url: str :param success: True if the request ended in success, False otherwise. + :type success: bool :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat`. (defaults to: None) + :type start_time: str :param duration: the number of milliseconds that this request lasted. (defaults to: None) + :type duration: int :param response_code: the response code that this request returned. (defaults to: None) + :type response_code: str :param http_method: the HTTP method that triggered this request. (defaults to: None) + :type http_method: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :type properties: :class:`typing.Dict[str, object]` :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :type measurements: :class:`typing.Dict[str, object]` :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) + :type request_id: str """ self._client.track_request( name, @@ -214,25 +249,33 @@ def track_dependency( ): """ Sends a single dependency telemetry that was captured for the application. + :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. + :type name: str :param data: the command initiated by this dependency call. + :type data: str Examples are SQL statement and HTTP URL with all query parameters. :param type_name: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. - (default to: None) - :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) - :param duration: the number of milliseconds that this dependency call lasted. - (defaults to: None) - :param success: true if the dependency call ended in success, false otherwise. - (defaults to: None) + :type type_name: str + :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) + :type target: str + :param duration: the number of milliseconds that this dependency call lasted. (defaults to: None) + :type duration: int + :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) + :type success: bool :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) + :type result_code: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :type properties: :class:`typing.Dict[str, object]` :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) - :param id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) + :type measurements: :class:`typing.Dict[str, object]` + :param dependency_id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) + :type dependency_id: str """ self._client.track_dependency( name, diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py index 10b4b9b20..6c014e64d 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py @@ -10,10 +10,10 @@ def retrieve_bot_body(): - """ retrieve_bot_body + """ Retrieve the POST body text from temporary cache. - The POST body corresponds with the thread id and should resides in - cache just for lifetime of request. + + The POST body corresponds to the thread ID and should reside in cache just for the lifetime of a request. """ result = _REQUEST_BODIES.get(current_thread().ident, None) @@ -22,15 +22,17 @@ def retrieve_bot_body(): class BotTelemetryMiddleware: """ - Save off the POST body to later populate bot-specific properties to - add to Application Insights. + Save off the POST body to later populate bot-specific properties to add to Application Insights. Example activating MIDDLEWARE in Django settings: - MIDDLEWARE = [ - # Ideally add somewhere near top - 'botbuilder.applicationinsights.django.BotTelemetryMiddleware', - ... - ] + + .. code-block:: python + + MIDDLEWARE = [ + # Ideally add somewhere near top + 'botbuilder.applicationinsights.django.BotTelemetryMiddleware', + ... + ] """ def __init__(self, get_response): diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/logging.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/logging.py index dc36a362b..78e651aa7 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/logging.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/logging.py @@ -8,7 +8,7 @@ class LoggingHandler(logging.LoggingHandler): """This class is a LoggingHandler that uses the same settings as the Django middleware to configure the telemetry client. This can be referenced from LOGGING in your Django settings.py file. As an - example, this code would send all Django log messages--WARNING and up--to Application Insights: + example, this code would send all Django log messages, WARNING and up, to Application Insights: .. code:: python From 2c150ff9dbf2885f20e4c85b56ee565814b68904 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 12:49:36 -0700 Subject: [PATCH 566/616] Update application_insights_telemetry_client.py --- .../application_insights_telemetry_client.py | 52 ++++++------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 879337031..9c9383cc7 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -75,11 +75,9 @@ def track_pageview( :type url: str :param duration: the duration of the page view in milliseconds. (defaults to: 0) :duration: int - :param properties: the set of custom properties the client wants attached to this data item. - (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. - (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_pageview(name, url, duration, properties, measurements) @@ -98,11 +96,9 @@ def track_exception( :param exception_type: the type of the exception that was thrown. :param value: the exception that the client wants to send. :param trace: the traceback information as returned by :func:`sys.exc_info`. - :param properties: the set of custom properties the client wants attached to this data item. - (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. - (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_exception( @@ -120,11 +116,9 @@ def track_event( :param name: the data to associate to this event. :type name: str - :param properties: the set of custom properties the client wants attached to this data item. - (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. - (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_event(name, properties=properties, measurements=measurements) @@ -150,17 +144,13 @@ def track_metric( :param tel_type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) :param count: the number of metrics that were aggregated into this data point. (defaults to: None) :type count: int - :param min_val: the minimum of all metrics collected that were aggregated into this data point. - (defaults to: None) + :param min_val: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) :type min_val: float - :param max_val: the maximum of all metrics collected that were aggregated into this data point. - (defaults to: None) + :param max_val: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) :type max_val: float - :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. - (defaults to: None) + :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) :type std_dev: float - :param properties: the set of custom properties the client wants attached to this data item. - (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` """ self._client.track_metric( @@ -203,8 +193,7 @@ def track_request( :type url: str :param success: True if the request ended in success, False otherwise. :type success: bool - :param start_time: the start time of the request. The value should look the same as the one returned by - :func:`datetime.isoformat`. (defaults to: None) + :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat`. (defaults to: None) :type start_time: str :param duration: the number of milliseconds that this request lasted. (defaults to: None) :type duration: int @@ -212,11 +201,9 @@ def track_request( :type response_code: str :param http_method: the HTTP method that triggered this request. (defaults to: None) :type http_method: str - :param properties: the set of custom properties the client wants attached to this data item. - (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. - (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) :type measurements: :class:`typing.Dict[str, object]` :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) :type request_id: str @@ -253,12 +240,9 @@ def track_dependency( :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. :type name: str - :param data: the command initiated by this dependency call. + :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all query parameters. :type data: str - Examples are SQL statement and HTTP URL with all query parameters. - :param type_name: the dependency type name. Low cardinality value for logical grouping of dependencies and - interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. - (default to: None) + :param type_name: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. (default to: None) :type type_name: str :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) :type target: str @@ -266,13 +250,11 @@ def track_dependency( :type duration: int :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) :type success: bool - :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. - (defaults to: None) + :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) :type result_code: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. - (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) :type measurements: :class:`typing.Dict[str, object]` :param dependency_id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) :type dependency_id: str From 7278d14922b5ff903c48cf858a031656d779576c Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 12:55:36 -0700 Subject: [PATCH 567/616] Update qnamaker.py --- libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 62ffa1b8d..f7583c571 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -126,6 +126,7 @@ def get_low_score_variation(self, query_result: QueryResult) -> List[QueryResult :param query_result: User query output. :type query_result: :class:`QueryResult` :return: Filtered array of ambiguous questions. + :rtype: :class:`typing.List[QueryResult]` """ return ActiveLearningUtils.get_low_score_variation(query_result) @@ -171,9 +172,9 @@ async def fill_qna_event( :param turn_context: Context object containing information for a single turn of conversation with a user. :type turn_context: :class:`botbuilder.core.TurnContext` :param telemetry_properties: Properties to add/override for the event. - :type telemetry_properties: :class:`Typing.Dict` + :type telemetry_properties: :class:`typing.Dict[str, str]` :param telemetry_metrics: Metrics to add/override for the event. - :type telemetry_metrics: :class:`Typing.Dict` + :type telemetry_metrics: :class:`typing.Dict[str, float]` :return: Event properties and metrics for the QnaMessage event for telemetry. :rtype: :class:`EventData` """ From 960831cd0d25ada7a1a5f74db5934890b55a576d Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 12:59:29 -0700 Subject: [PATCH 568/616] Update luis_recognizer.py --- .../botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 5a6f7514e..df8a07900 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -79,10 +79,9 @@ def top_intent( :type results: :class:`botbuilder.core.RecognizerResult` :param default_intent: Intent name to return should a top intent be found, defaults to None. :type default_intent: str, optional - :param min_score: Minimum score needed for an intent to be considered as a top intent. If all - intents in the set are below this threshold then the `defaultIntent` is returned, defaults to 0.0. + :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in the set are below this threshold then the `defaultIntent` is returned, defaults to 0.0. :type min_score: float, optional - :raises: TypeError + :raises: TypeError :return: The top scoring intent name. :rtype: str """ From eac6112e22d0c2c7e681e6feb3710a9e259ac5b8 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 13:04:21 -0700 Subject: [PATCH 569/616] Update slack_adapter.py --- .../botbuilder/adapters/slack/slack_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 652115f5b..9fb06e283 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -45,9 +45,9 @@ async def send_activities( :param context: A TurnContext representing the current incoming message and environment. :type context: :class:`botbuilder.core.TurnContext` :param activities: An array of outgoing activities to be sent back to the messaging API. - :type activities: :class:`typing.List` + :type activities: :class:`typing.List[Activity]` :return: An array of ResourceResponse objects containing the IDs that Slack assigned to the sent messages. - :rtype: :class:`typing.List` + :rtype: :class:`typing.List[ResourceResponse]` """ if not context: @@ -188,7 +188,7 @@ async def process(self, req: Request, logic: Callable) -> Response: :param req: The aiohttp Request object. :type req: :class:`aiohttp.web_request.Request` :param logic: The method to call for the resulting bot turn. - :type logic: :class:`typing.List` + :type logic: :class:`Callable` :return: The aiohttp Response. :rtype: :class:`aiohttp.web_response.Response` """ From 53440951d1c9ce9dd0547989e0b8953286f26bc5 Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 13:05:13 -0700 Subject: [PATCH 570/616] Update slack_adapter.py --- .../botbuilder/adapters/slack/slack_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 9fb06e283..e73784830 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -188,7 +188,7 @@ async def process(self, req: Request, logic: Callable) -> Response: :param req: The aiohttp Request object. :type req: :class:`aiohttp.web_request.Request` :param logic: The method to call for the resulting bot turn. - :type logic: :class:`Callable` + :type logic: :class:`tying.Callable` :return: The aiohttp Response. :rtype: :class:`aiohttp.web_response.Response` """ From 3b943da923015dd87d66fcdc1583cbd28f0b96df Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 13:07:29 -0700 Subject: [PATCH 571/616] Update slack_adapter.py --- .../botbuilder/adapters/slack/slack_adapter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index e73784830..68d23afb3 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -115,8 +115,7 @@ async def delete_activity( :param context: A TurnContext representing the current incoming message and environment. :type context: :class:`botbuilder.core.TurnContext` - :param reference: An object in the form "{activityId: `id of message to delete`, - conversation: { id: `id of Slack channel`}}". + :param reference: An object in the form "{activityId: `id of message to delete`,conversation: { id: `id of Slack channel`}}". :type reference: :class:`botbuilder.schema.ConversationReference` """ From dcfe8dbfb0d1c8c2a0ad7063fa7dfe04af3d820c Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 16 Sep 2020 13:11:10 -0700 Subject: [PATCH 572/616] Reference comment copy edits --- .../integration/aiohttp/bot_framework_http_client.py | 4 ++-- .../aiohttp/aiohttp_telemetry_middleware.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index eac0ecaa4..436f27c29 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -28,8 +28,8 @@ class BotFrameworkHttpClient(BotFrameworkClient): """ - A skill host adapter implements API to forward activity to a skill and - implements routing ChannelAPI calls from the Skill up through the bot/adapter. + A skill host adapter that implements the API to forward activity to a skill and + implements routing ChannelAPI calls from the skill up through the bot/adapter. """ INVOKE_ACTIVITY_NAME = "SkillEvents.ChannelApiInvoke" diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py index acc0c69cc..f55218bb2 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py @@ -6,8 +6,9 @@ def retrieve_aiohttp_body(): - """ retrieve_flask_body + """ Retrieve the POST body text from temporary cache. + The POST body corresponds with the thread id and should resides in cache just for lifetime of request. """ From 0bcd0eda1b87243b1ca4aedd6ac8000390d1bd9a Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Fri, 18 Sep 2020 13:00:20 -0700 Subject: [PATCH 573/616] Update bot_telemetry_middleware.py --- .../applicationinsights/django/bot_telemetry_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py index 6c014e64d..ed74b8af1 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py @@ -13,7 +13,7 @@ def retrieve_bot_body(): """ Retrieve the POST body text from temporary cache. - The POST body corresponds to the thread ID and should reside in cache just for the lifetime of a request. + The POST body corresponds to the thread ID and must reside in the cache just for the lifetime of the request. """ result = _REQUEST_BODIES.get(current_thread().ident, None) From 7c7489de00215b94fa16a4b3eb8ea1fa49e3ba46 Mon Sep 17 00:00:00 2001 From: Aditya Rao Date: Mon, 21 Sep 2020 00:46:59 +0530 Subject: [PATCH 574/616] Fixed Recognizer on empty utterance --- libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 2bb73948f..ce4e30caa 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -272,7 +272,7 @@ async def _recognize_internal( if not utterance or utterance.isspace(): recognizer_result = RecognizerResult( - text=utterance, intents={"": IntentScore(score=1.0)}, entities={} + text=utterance ) else: From 43946098dc1f90877bd62f93622ca9a2ee96d888 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 1 Oct 2020 15:53:42 -0700 Subject: [PATCH 575/616] skill dialog delete conversation id --- .../botbuilder/ai/luis/luis_recognizer.py | 4 +- .../application_insights_telemetry_client.py | 2 +- .../botbuilder/dialogs/skills/skill_dialog.py | 5 ++ .../tests/test_skill_dialog.py | 68 +++++++++++++++++-- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 69807fca8..af1d229e5 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -268,9 +268,7 @@ async def _recognize_internal( options = self._options if not utterance or utterance.isspace(): - recognizer_result = RecognizerResult( - text=utterance - ) + recognizer_result = RecognizerResult(text=utterance) else: luis_recognizer = self._build_recognizer(options) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 9c9383cc7..7c70cc9d7 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -152,7 +152,7 @@ def track_metric( :type std_dev: float :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - """ + """ self._client.track_metric( name, value, tel_type, count, min_val, max_val, std_dev, properties ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py index a2bdd7a57..62fee1ace 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -249,6 +249,11 @@ async def _send_to_skill( if from_skill_activity.type == ActivityTypes.end_of_conversation: # Capture the EndOfConversation activity if it was sent from skill eoc_activity = from_skill_activity + + # The conversation has ended, so cleanup the conversation id + await self.dialog_options.conversation_id_factory.delete_conversation_reference( + skill_conversation_id + ) elif await self._intercept_oauth_cards( context, from_skill_activity, self.dialog_options.connection_name ): diff --git a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py index c5509e6a8..91b6dfcba 100644 --- a/libraries/botbuilder-dialogs/tests/test_skill_dialog.py +++ b/libraries/botbuilder-dialogs/tests/test_skill_dialog.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import uuid from http import HTTPStatus -from typing import Callable, Union +from typing import Callable, Union, List from unittest.mock import Mock import aiounittest @@ -46,6 +46,7 @@ class SimpleConversationIdFactory(ConversationIdFactoryBase): def __init__(self): self.conversation_refs = {} + self.create_count = 0 async def create_skill_conversation_id( self, @@ -53,6 +54,7 @@ async def create_skill_conversation_id( SkillConversationIdFactoryOptions, ConversationReference ], ) -> str: + self.create_count += 1 key = ( options_or_conversation_reference.activity.conversation.id + options_or_conversation_reference.activity.service_url @@ -72,7 +74,8 @@ async def get_conversation_reference( return self.conversation_refs[skill_conversation_id] async def delete_conversation_reference(self, skill_conversation_id: str): - raise NotImplementedError() + self.conversation_refs.pop(skill_conversation_id, None) + return class SkillDialogTests(aiounittest.AsyncTestCase): @@ -506,6 +509,57 @@ async def post_return(): self.assertIsNotNone(final_activity) self.assertEqual(len(final_activity.attachments), 1) + async def test_end_of_conversation_from_expect_replies_calls_delete_conversation_reference( + self, + ): + activity_sent: Activity = None + + # Callback to capture the parameters sent to the skill + async def capture_action( + from_bot_id: str, # pylint: disable=unused-argument + to_bot_id: str, # pylint: disable=unused-argument + to_uri: str, # pylint: disable=unused-argument + service_url: str, # pylint: disable=unused-argument + conversation_id: str, # pylint: disable=unused-argument + activity: Activity, + ): + # Capture values sent to the skill so we can assert the right parameters were used. + nonlocal activity_sent + activity_sent = activity + + eoc = Activity.create_end_of_conversation_activity() + expected_replies = list([eoc]) + + # Create a mock skill client to intercept calls and capture what is sent. + mock_skill_client = self._create_mock_skill_client( + capture_action, expected_replies=expected_replies + ) + + # Use Memory for conversation state + conversation_state = ConversationState(MemoryStorage()) + dialog_options = self.create_skill_dialog_options( + conversation_state, mock_skill_client + ) + + # Create the SkillDialogInstance and the activity to send. + sut = SkillDialog(dialog_options, dialog_id="dialog") + activity_to_send = Activity.create_message_activity() + activity_to_send.delivery_mode = DeliveryModes.expect_replies + activity_to_send.text = str(uuid.uuid4()) + client = DialogTestClient( + "test", + sut, + BeginSkillDialogOptions(activity_to_send), + conversation_state=conversation_state, + ) + + # Send something to the dialog to start it + await client.send_activity("hello") + + simple_id_factory: SimpleConversationIdFactory = dialog_options.conversation_id_factory + self.assertEqual(0, len(simple_id_factory.conversation_refs)) + self.assertEqual(1, simple_id_factory.create_count) + @staticmethod def create_skill_dialog_options( conversation_state: ConversationState, @@ -547,9 +601,15 @@ def create_oauth_card_attachment_activity(uri: str) -> Activity: return attachment_activity def _create_mock_skill_client( - self, callback: Callable, return_status: Union[Callable, int] = 200 + self, + callback: Callable, + return_status: Union[Callable, int] = 200, + expected_replies: List[Activity] = None, ) -> BotFrameworkClient: mock_client = Mock() + activity_list = ExpectedReplies( + activities=expected_replies or [MessageFactory.text("dummy activity")] + ) async def mock_post_activity( from_bot_id: str, @@ -572,7 +632,7 @@ async def mock_post_activity( if isinstance(return_status, Callable): return await return_status() - return InvokeResponse(status=return_status) + return InvokeResponse(status=return_status, body=activity_list) mock_client.post_activity.side_effect = mock_post_activity From 96299630faaf05b807fae2106f484c7371362e1a Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 2 Oct 2020 20:21:51 -0500 Subject: [PATCH 576/616] Revert "Fixed Recognizer on empty utterance" (#1397) * Revert "Fixed Recognizer on empty utterance" * fixing black formatting * Fixing pylint violations * pylint import fixes Co-authored-by: Axel Suarez --- .../adapters/slack/slack_adapter.py | 15 +++-- .../botbuilder/adapters/slack/slack_helper.py | 5 +- .../adapters/slack/slack_options.py | 9 +-- .../botbuilder/ai/luis/luis_recognizer.py | 11 ++-- .../application_insights_telemetry_client.py | 61 ++++++++++++------- .../django/bot_telemetry_middleware.py | 8 +-- .../aiohttp/aiohttp_telemetry_middleware.py | 4 +- 7 files changed, 68 insertions(+), 45 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 68d23afb3..3a03d7553 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -24,7 +24,8 @@ class SlackAdapter(BotAdapter, ABC): """ - BotAdapter that can handle incoming Slack events. Incoming Slack events are deserialized to an Activity that is dispatched through the middleware and bot pipeline. + BotAdapter that can handle incoming Slack events. Incoming Slack events are deserialized to an Activity that is + dispatched through the middleware and bot pipeline. """ def __init__( @@ -115,7 +116,8 @@ async def delete_activity( :param context: A TurnContext representing the current incoming message and environment. :type context: :class:`botbuilder.core.TurnContext` - :param reference: An object in the form "{activityId: `id of message to delete`,conversation: { id: `id of Slack channel`}}". + :param reference: An object in the form "{activityId: `id of message to delete`,conversation: { id: `id of Slack + channel`}}". :type reference: :class:`botbuilder.schema.ConversationReference` """ @@ -142,10 +144,11 @@ async def continue_conversation( ): """ Send a proactive message to a conversation. - + .. remarks:: - - Most channels require a user to initiate a conversation with a bot before the bot can send activities to the user. + + Most channels require a user to initiate a conversation with a bot before the bot can send activities to the + user. :param reference: A reference to the conversation to continue. :type reference: :class:`botbuilder.schema.ConversationReference` @@ -188,7 +191,7 @@ async def process(self, req: Request, logic: Callable) -> Response: :type req: :class:`aiohttp.web_request.Request` :param logic: The method to call for the resulting bot turn. :type logic: :class:`tying.Callable` - :return: The aiohttp Response. + :return: The aiohttp Response. :rtype: :class:`aiohttp.web_response.Response` """ diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py index 7aea3456a..d71fd7852 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -28,10 +28,11 @@ class SlackHelper: def activity_to_slack(activity: Activity) -> SlackMessage: """ Formats a BotBuilder Activity into an outgoing Slack message. - + :param activity: A BotBuilder Activity object. :type activity: :class:`botbuilder.schema.Activity` - :return: A Slack message object with {text, attachments, channel, thread ts} and any fields found in activity.channelData. + :return: A Slack message object with {text, attachments, channel, thread ts} and any fields found in + activity.channelData. :rtype: :class:`SlackMessage` """ diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py index 1f74e31a4..11cc9b62b 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py @@ -15,7 +15,7 @@ def __init__( ): """ Initializes a new instance of SlackAdapterOptions. - + :param slack_verification_token: A token for validating the origin of incoming webhooks. :type slack_verification_token: str :param slack_bot_token: A token for a bot to work on a single workspace. @@ -34,7 +34,7 @@ def __init__( async def get_token_for_team(self, team_id: str) -> str: """ Receives a Slack team ID and returns the bot token associated with that team. Required for multi-team apps. - + :param team_id: The team ID. :type team_id: str :raises: :func:`NotImplementedError` @@ -43,8 +43,9 @@ async def get_token_for_team(self, team_id: str) -> str: async def get_bot_user_by_team(self, team_id: str) -> str: """ - A method that receives a Slack team ID and returns the bot user ID associated with that team. Required for multi-team apps. - + A method that receives a Slack team ID and returns the bot user ID associated with that team. Required for + multi-team apps. + :param team_id: The team ID. :type team_id: str :raises: :func:`NotImplementedError` diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 69807fca8..8eef3e4dc 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -79,7 +79,8 @@ def top_intent( :type results: :class:`botbuilder.core.RecognizerResult` :param default_intent: Intent name to return should a top intent be found, defaults to None. :type default_intent: str, optional - :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in the set are below this threshold then the `defaultIntent` is returned, defaults to 0.0. + :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in the set + are below this threshold then the `defaultIntent` is returned, defaults to 0.0. :type min_score: float, optional :raises: TypeError :return: The top scoring intent name. @@ -186,9 +187,11 @@ def fill_luis_event_properties( :type recognizer_result: :class:`botbuilder.core.RecognizerResult` :param turn_context: Context object containing information for a single turn of conversation with a user. :type turn_context: :class:`botbuilder.core.TurnContext` - :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None. + :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults + to None. :type telemetry_properties: :class:`typing.Dict[str, str]`, optional - :return: A dictionary sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` for the BotMessageSend event. + :return: A dictionary sent as "Properties" to :func:`botbuilder.core.BotTelemetryClient.track_event` for the + BotMessageSend event. :rtype: `typing.Dict[str, str]` """ @@ -269,7 +272,7 @@ async def _recognize_internal( if not utterance or utterance.isspace(): recognizer_result = RecognizerResult( - text=utterance + text=utterance, intents={"": IntentScore(score=1.0)}, entities={} ) else: diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 9c9383cc7..39b1eac3a 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -68,7 +68,7 @@ def track_pageview( ) -> None: """ Send information about the page viewed in the application (a web page for instance). - + :param name: the name of the page that was viewed. :type name: str :param url: the URL of the page that was viewed. @@ -77,7 +77,8 @@ def track_pageview( :duration: int :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: + None) :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_pageview(name, url, duration, properties, measurements) @@ -92,13 +93,14 @@ def track_exception( ) -> None: """ Send information about a single exception that occurred in the application. - + :param exception_type: the type of the exception that was thrown. :param value: the exception that the client wants to send. :param trace: the traceback information as returned by :func:`sys.exc_info`. :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: + None) :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_exception( @@ -113,12 +115,13 @@ def track_event( ) -> None: """ Send information about a single event that has occurred in the context of the application. - + :param name: the data to associate to this event. :type name: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: + None) :type measurements: :class:`typing.Dict[str, object]` """ self._client.track_event(name, properties=properties, measurements=measurements) @@ -136,7 +139,7 @@ def track_metric( ) -> NotImplemented: """ Send information about a single metric data point that was captured for the application. - + :param name: The name of the metric that was captured. :type name: str :param value: The value of the metric that was captured. @@ -144,15 +147,18 @@ def track_metric( :param tel_type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) :param count: the number of metrics that were aggregated into this data point. (defaults to: None) :type count: int - :param min_val: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param min_val: the minimum of all metrics collected that were aggregated into this data point. (defaults to: + None) :type min_val: float - :param max_val: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param max_val: the maximum of all metrics collected that were aggregated into this data point. (defaults to: + None) :type max_val: float - :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) + :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. + (defaults to: None) :type std_dev: float :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - """ + """ self._client.track_metric( name, value, tel_type, count, min_val, max_val, std_dev, properties ) @@ -162,7 +168,7 @@ def track_trace( ): """ Sends a single trace statement. - + :param name: the trace statement. :type name: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) @@ -186,14 +192,15 @@ def track_request( ): """ Sends a single request that was captured for the application. - + :param name: The name for this request. All requests with the same name will be grouped together. :type name: str :param url: The actual URL for this request (to show in individual request instances). :type url: str :param success: True if the request ended in success, False otherwise. :type success: bool - :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat`. (defaults to: None) + :param start_time: the start time of the request. The value should look the same as the one returned by + :func:`datetime.isoformat`. (defaults to: None) :type start_time: str :param duration: the number of milliseconds that this request lasted. (defaults to: None) :type duration: int @@ -203,7 +210,8 @@ def track_request( :type http_method: str :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: + None) :type measurements: :class:`typing.Dict[str, object]` :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) :type request_id: str @@ -236,13 +244,16 @@ def track_dependency( ): """ Sends a single dependency telemetry that was captured for the application. - + :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. :type name: str - :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all query parameters. + :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all + query parameters. :type data: str - :param type_name: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. (default to: None) + :param type_name: the dependency type name. Low cardinality value for logical grouping of dependencies and + interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. + (default to: None) :type type_name: str :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) :type target: str @@ -250,14 +261,18 @@ def track_dependency( :type duration: int :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) :type success: bool - :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) + :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. + (defaults to: None) :type result_code: str - :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. + (defaults to: None) :type properties: :class:`typing.Dict[str, object]` - :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. + (defaults to: None) :type measurements: :class:`typing.Dict[str, object]` - :param dependency_id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) - :type dependency_id: str + :param dependency_id: the id for this dependency call. If None, a new uuid will be generated. + (defaults to: None) + :type dependency_id: str """ self._client.track_dependency( name, diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py index ed74b8af1..4508dcef1 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/django/bot_telemetry_middleware.py @@ -10,9 +10,9 @@ def retrieve_bot_body(): - """ + """ Retrieve the POST body text from temporary cache. - + The POST body corresponds to the thread ID and must reside in the cache just for the lifetime of the request. """ @@ -25,9 +25,9 @@ class BotTelemetryMiddleware: Save off the POST body to later populate bot-specific properties to add to Application Insights. Example activating MIDDLEWARE in Django settings: - + .. code-block:: python - + MIDDLEWARE = [ # Ideally add somewhere near top 'botbuilder.applicationinsights.django.BotTelemetryMiddleware', diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py index f55218bb2..30615f5c2 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/aiohttp_telemetry_middleware.py @@ -6,9 +6,9 @@ def retrieve_aiohttp_body(): - """ + """ Retrieve the POST body text from temporary cache. - + The POST body corresponds with the thread id and should resides in cache just for lifetime of request. """ From de1aba5c5d07a357e017394fb61e80905a53ad39 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 5 Oct 2020 14:16:13 -0700 Subject: [PATCH 577/616] Avoid typing activity if is skill --- .../botbuilder/core/adapters/test_adapter.py | 9 +++-- .../botbuilder/core/show_typing_middleware.py | 18 ++++++++-- .../tests/test_show_typing_middleware.py | 35 +++++++++++++++++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index a5637d86c..09ffb3e76 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -153,7 +153,7 @@ async def process_activity( self._conversation_lock.release() activity.timestamp = activity.timestamp or datetime.utcnow() - await self.run_pipeline(TurnContext(self, activity), logic) + await self.run_pipeline(self.create_turn_context(activity), logic) async def send_activities( self, context, activities: List[Activity] @@ -227,7 +227,7 @@ async def create_conversation( members_removed=[], conversation=ConversationAccount(id=str(uuid.uuid4())), ) - context = TurnContext(self, update) + context = self.create_turn_context(update) return await callback(context) async def receive_activity(self, activity): @@ -252,7 +252,7 @@ async def receive_activity(self, activity): request.id = str(self._next_id) # Create context object and run middleware. - context = TurnContext(self, request) + context = self.create_turn_context(request) return await self.run_pipeline(context, self.logic) def get_next_activity(self) -> Activity: @@ -534,6 +534,9 @@ async def exchange_token_from_credentials( return None + def create_turn_context(self, activity: Activity) -> TurnContext: + return TurnContext(self, activity) + class TestFlow: __test__ = False diff --git a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py index a659cd8bf..80b353b4f 100644 --- a/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/show_typing_middleware.py @@ -4,7 +4,9 @@ from typing import Awaitable, Callable from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ClaimsIdentity, SkillValidation +from .bot_adapter import BotAdapter from .middleware_set import Middleware from .turn_context import TurnContext @@ -82,9 +84,12 @@ async def aux(): def stop_interval(): timer.set_clear_timer() - # if it's a message, start sending typing activities until the - # bot logic is done. - if context.activity.type == ActivityTypes.message: + # Start a timer to periodically send the typing activity + # (bots running as skills should not send typing activity) + if ( + context.activity.type == ActivityTypes.message + and not ShowTypingMiddleware._is_skill_bot(context) + ): start_interval(context, self._delay, self._period) # call the bot logic @@ -93,3 +98,10 @@ def stop_interval(): stop_interval() return result + + @staticmethod + def _is_skill_bot(context: TurnContext) -> bool: + claims_identity = context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) + return isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims) diff --git a/libraries/botbuilder-core/tests/test_show_typing_middleware.py b/libraries/botbuilder-core/tests/test_show_typing_middleware.py index b3b10a13d..9d0e0b7ce 100644 --- a/libraries/botbuilder-core/tests/test_show_typing_middleware.py +++ b/libraries/botbuilder-core/tests/test_show_typing_middleware.py @@ -1,11 +1,31 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import asyncio +from uuid import uuid4 import aiounittest -from botbuilder.core import ShowTypingMiddleware +from botbuilder.core import ShowTypingMiddleware, TurnContext from botbuilder.core.adapters import TestAdapter -from botbuilder.schema import ActivityTypes +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import AuthenticationConstants, ClaimsIdentity + + +class SkillTestAdapter(TestAdapter): + def create_turn_context(self, activity: Activity) -> TurnContext: + turn_context = super().create_turn_context(activity) + + claims_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.VERSION_CLAIM: "2.0", + AuthenticationConstants.AUDIENCE_CLAIM: str(uuid4()), + AuthenticationConstants.AUTHORIZED_PARTY: str(uuid4()), + }, + is_authenticated=True, + ) + + turn_context.turn_state[self.BOT_IDENTITY_KEY] = claims_identity + + return turn_context class TestShowTypingMiddleware(aiounittest.AsyncTestCase): @@ -65,3 +85,14 @@ def assert_is_message(activity, description): # pylint: disable=unused-argument step1 = await adapter.send("foo") await step1.assert_reply(assert_is_message) + + async def test_not_send_not_send_typing_indicator_when_bot_running_as_skill(self): + async def aux(context): + await asyncio.sleep(1) + await context.send_activity(f"echo:{context.activity.text}") + + skill_adapter = SkillTestAdapter(aux) + skill_adapter.use(ShowTypingMiddleware(0.001, 1)) + + step1 = await skill_adapter.send("foo") + await step1.assert_reply("echo:foo") From 36421bf22d6e2eb0eba7c45d25af332dab7f1e88 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 6 Oct 2020 20:02:39 -0700 Subject: [PATCH 578/616] Testing SkillHttpClient --- .../aiohttp/skills/skill_http_client.py | 2 +- .../requirements.txt | 3 +- .../tests/skills/test_skill_http_client.py | 205 ++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 libraries/botbuilder-integration-aiohttp/tests/skills/test_skill_http_client.py diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py index df875f734..68da498ab 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/skill_http_client.py @@ -50,7 +50,7 @@ async def post_activity_to_skill( originating_audience = ( GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE if self._channel_provider is not None - and self._channel_provider.IsGovernment() + and self._channel_provider.is_government() else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE ) diff --git a/libraries/botbuilder-integration-aiohttp/requirements.txt b/libraries/botbuilder-integration-aiohttp/requirements.txt index b2706949b..5983ea638 100644 --- a/libraries/botbuilder-integration-aiohttp/requirements.txt +++ b/libraries/botbuilder-integration-aiohttp/requirements.txt @@ -1,4 +1,5 @@ msrest==0.6.10 botframework-connector==4.10.0 botbuilder-schema==4.10.0 -aiohttp==3.6.2 \ No newline at end of file +aiohttp==3.6.2 +ddt==1.2.1 \ No newline at end of file diff --git a/libraries/botbuilder-integration-aiohttp/tests/skills/test_skill_http_client.py b/libraries/botbuilder-integration-aiohttp/tests/skills/test_skill_http_client.py new file mode 100644 index 000000000..df889cc82 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/tests/skills/test_skill_http_client.py @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from typing import Awaitable, Callable, Dict, Union + + +from unittest.mock import Mock +import aiounittest + +from botbuilder.core import MessageFactory, InvokeResponse +from botbuilder.core.skills import ( + BotFrameworkSkill, + ConversationIdFactoryBase, + SkillConversationIdFactoryOptions, + SkillConversationReference, +) +from botbuilder.integration.aiohttp.skills import SkillHttpClient +from botbuilder.schema import Activity, ConversationAccount, ConversationReference +from botframework.connector.auth import ( + AuthenticationConstants, + ChannelProvider, + GovernmentConstants, +) + + +class SimpleConversationIdFactory(ConversationIdFactoryBase): + def __init__(self, conversation_id: str): + self._conversation_id = conversation_id + self._conversation_refs: Dict[str, SkillConversationReference] = {} + # Public property to capture and assert the options passed to CreateSkillConversationIdAsync. + self.creation_options: SkillConversationIdFactoryOptions = None + + async def create_skill_conversation_id( + self, + options_or_conversation_reference: Union[ + SkillConversationIdFactoryOptions, ConversationReference + ], + ) -> str: + self.creation_options = options_or_conversation_reference + + key = self._conversation_id + self._conversation_refs[key] = self._conversation_refs.get( + key, + SkillConversationReference( + conversation_reference=options_or_conversation_reference.activity.get_conversation_reference(), + oauth_scope=options_or_conversation_reference.from_bot_oauth_scope, + ), + ) + return key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> SkillConversationReference: + return self._conversation_refs[skill_conversation_id] + + async def delete_conversation_reference(self, skill_conversation_id: str): + raise NotImplementedError() + + +class TestSkillHttpClientTests(aiounittest.AsyncTestCase): + async def test_post_activity_with_originating_audience(self): + conversation_id = str(uuid4()) + conversation_id_factory = SimpleConversationIdFactory(conversation_id) + test_activity = MessageFactory.text("some message") + test_activity.conversation = ConversationAccount() + skill = BotFrameworkSkill( + id="SomeSkill", + app_id="", + skill_endpoint="https://someskill.com/api/messages", + ) + + async def _mock_post_content( + to_url: str, + token: str, # pylint: disable=unused-argument + activity: Activity, + ) -> (int, object): + nonlocal self + self.assertEqual(skill.skill_endpoint, to_url) + # Assert that the activity being sent has what we expect. + self.assertEqual(conversation_id, activity.conversation.id) + self.assertEqual("https://parentbot.com/api/messages", activity.service_url) + + # Create mock response. + return 200, None + + sut = await self._create_http_client_with_mock_handler( + _mock_post_content, conversation_id_factory + ) + + result = await sut.post_activity_to_skill( + "", + skill, + "https://parentbot.com/api/messages", + test_activity, + "someOriginatingAudience", + ) + + # Assert factory options + self.assertEqual("", conversation_id_factory.creation_options.from_bot_id) + self.assertEqual( + "someOriginatingAudience", + conversation_id_factory.creation_options.from_bot_oauth_scope, + ) + self.assertEqual( + test_activity, conversation_id_factory.creation_options.activity + ) + self.assertEqual( + skill, conversation_id_factory.creation_options.bot_framework_skill + ) + + # Assert result + self.assertIsInstance(result, InvokeResponse) + self.assertEqual(200, result.status) + + async def test_post_activity_using_invoke_response(self): + for is_gov in [True, False]: + with self.subTest(is_government=is_gov): + # pylint: disable=undefined-variable + # pylint: disable=cell-var-from-loop + conversation_id = str(uuid4()) + conversation_id_factory = SimpleConversationIdFactory(conversation_id) + test_activity = MessageFactory.text("some message") + test_activity.conversation = ConversationAccount() + expected_oauth_scope = ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + mock_channel_provider: ChannelProvider = Mock(spec=ChannelProvider) + + def is_government_mock(): + nonlocal expected_oauth_scope + if is_government: + expected_oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + return is_government + + mock_channel_provider.is_government = Mock( + side_effect=is_government_mock + ) + + skill = BotFrameworkSkill( + id="SomeSkill", + app_id="", + skill_endpoint="https://someskill.com/api/messages", + ) + + async def _mock_post_content( + to_url: str, + token: str, # pylint: disable=unused-argument + activity: Activity, + ) -> (int, object): + nonlocal self + + self.assertEqual(skill.skill_endpoint, to_url) + # Assert that the activity being sent has what we expect. + self.assertEqual(conversation_id, activity.conversation.id) + self.assertEqual( + "https://parentbot.com/api/messages", activity.service_url + ) + + # Create mock response. + return 200, None + + sut = await self._create_http_client_with_mock_handler( + _mock_post_content, conversation_id_factory + ) + result = await sut.post_activity_to_skill( + "", skill, "https://parentbot.com/api/messages", test_activity + ) + + # Assert factory options + self.assertEqual( + "", conversation_id_factory.creation_options.from_bot_id + ) + self.assertEqual( + expected_oauth_scope, + conversation_id_factory.creation_options.from_bot_oauth_scope, + ) + self.assertEqual( + test_activity, conversation_id_factory.creation_options.activity + ) + self.assertEqual( + skill, conversation_id_factory.creation_options.bot_framework_skill + ) + + # Assert result + self.assertIsInstance(result, InvokeResponse) + self.assertEqual(200, result.status) + + # Helper to create an HttpClient with a mock message handler that executes function argument to validate the request + # and mock a response. + async def _create_http_client_with_mock_handler( + self, + value_function: Callable[[object], Awaitable[object]], + id_factory: ConversationIdFactoryBase, + channel_provider: ChannelProvider = None, + ) -> SkillHttpClient: + # pylint: disable=protected-access + client = SkillHttpClient(Mock(), id_factory, channel_provider) + client._post_content = value_function + await client._session.close() + + return client From f2bbd0344b242389262aff14d02915df901d2526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 6 Oct 2020 20:06:58 -0700 Subject: [PATCH 579/616] delete unused dependency --- libraries/botbuilder-integration-aiohttp/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-integration-aiohttp/requirements.txt b/libraries/botbuilder-integration-aiohttp/requirements.txt index 5983ea638..d30921ea9 100644 --- a/libraries/botbuilder-integration-aiohttp/requirements.txt +++ b/libraries/botbuilder-integration-aiohttp/requirements.txt @@ -2,4 +2,3 @@ msrest==0.6.10 botframework-connector==4.10.0 botbuilder-schema==4.10.0 aiohttp==3.6.2 -ddt==1.2.1 \ No newline at end of file From 4822b98c8596a2174fb405e101aecefc758f0b1d Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 7 Oct 2020 11:40:24 -0500 Subject: [PATCH 580/616] Add EndOfConversationCodes to EndOfConversation activity from Skill (#1402) --- .../botbuilder/dialogs/dialog_extensions.py | 18 ++++++++++-------- .../tests/test_dialogextensions.py | 5 +++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py index fc8faead0..9f414e9cd 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py @@ -1,22 +1,21 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from botframework.connector.auth import ( + ClaimsIdentity, + SkillValidation, + AuthenticationConstants, + GovernmentConstants, +) from botbuilder.core import BotAdapter, StatePropertyAccessor, TurnContext from botbuilder.core.skills import SkillHandler, SkillConversationReference - from botbuilder.dialogs import ( Dialog, DialogEvents, DialogSet, DialogTurnStatus, ) -from botbuilder.schema import Activity, ActivityTypes -from botframework.connector.auth import ( - ClaimsIdentity, - SkillValidation, - AuthenticationConstants, - GovernmentConstants, -) +from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes class DialogExtensions: @@ -87,6 +86,9 @@ async def run_dialog( type=ActivityTypes.end_of_conversation, value=result.result, locale=turn_context.activity.locale, + code=EndOfConversationCodes.completed_successfully + if result.status == DialogTurnStatus.Complete + else EndOfConversationCodes.user_cancelled, ) await turn_context.send_activity(activity) diff --git a/libraries/botbuilder-dialogs/tests/test_dialogextensions.py b/libraries/botbuilder-dialogs/tests/test_dialogextensions.py index cdad45c31..3c3e8ecec 100644 --- a/libraries/botbuilder-dialogs/tests/test_dialogextensions.py +++ b/libraries/botbuilder-dialogs/tests/test_dialogextensions.py @@ -7,6 +7,7 @@ import aiounittest +from botframework.connector.auth import ClaimsIdentity, AuthenticationConstants from botbuilder.core import ( TurnContext, MessageFactory, @@ -28,8 +29,7 @@ TranscriptLoggerMiddleware, ConsoleTranscriptLogger, ) -from botbuilder.schema import ActivityTypes, Activity -from botframework.connector.auth import ClaimsIdentity, AuthenticationConstants +from botbuilder.schema import ActivityTypes, Activity, EndOfConversationCodes from botbuilder.dialogs import ( ComponentDialog, TextPrompt, @@ -111,6 +111,7 @@ async def handles_bot_and_skills_test_cases( self.eoc_sent, "Skills should send EndConversation to channel" ) assert ActivityTypes.end_of_conversation == self.eoc_sent.type + assert EndOfConversationCodes.completed_successfully == self.eoc_sent.code assert self.eoc_sent.value == "SomeName" else: self.assertIsNone( From 66ca8637594865a688e455560b16d899ccc6777f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 7 Oct 2020 14:32:29 -0500 Subject: [PATCH 581/616] Teams: Meeting notification --- .../botbuilder/core/teams/teams_activity_extensions.py | 6 +++++- .../botbuilder/schema/teams/_models_py3.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py index 23d907e09..e95d5d68a 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -27,7 +27,9 @@ def teams_get_team_info(activity: Activity) -> TeamInfo: return None -def teams_notify_user(activity: Activity): +def teams_notify_user( + activity: Activity, alert_in_meeting: bool = None, external_resource_url: str = None +): if not activity: return @@ -36,4 +38,6 @@ def teams_notify_user(activity: Activity): channel_data = TeamsChannelData().deserialize(activity.channel_data) channel_data.notification = NotificationInfo(alert=True) + channel_data.notification.alert_in_meeting = alert_in_meeting + channel_data.notification.external_resource_url = external_resource_url activity.channel_data = channel_data diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 3a27e5c51..03b063839 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -940,11 +940,15 @@ class NotificationInfo(Model): _attribute_map = { "alert": {"key": "alert", "type": "bool"}, + "alert_in_meeting": {"key": "alertInMeeting", "type": "bool"}, + "external_resource_url": {"key": "externalResourceUrl", "type": "str"}, } - def __init__(self, *, alert: bool = None, **kwargs) -> None: + def __init__(self, *, alert: bool = None, alert_in_meeting: bool = None, external_resource_url: str = None, **kwargs) -> None: super(NotificationInfo, self).__init__(**kwargs) self.alert = alert + self.alert_in_meeting = alert_in_meeting + self.external_resource_url = external_resource_url class O365ConnectorCard(Model): From 631920925f6cb3e2a502c0ca49c94cda699f81ee Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 7 Oct 2020 13:37:44 -0700 Subject: [PATCH 582/616] Remove _activity from add and remove installation update event names --- .../botbuilder/core/activity_handler.py | 8 ++++---- .../tests/test_activity_handler.py | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index bce2f0032..e207fa0d2 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -379,12 +379,12 @@ async def on_installation_update( # pylint: disable=unused-argument :returns: A task that represents the work queued to execute """ if turn_context.activity.action == "add": - return await self.on_installation_update_add_activity(turn_context) + return await self.on_installation_update_add(turn_context) if turn_context.activity.action == "remove": - return await self.on_installation_update_remove_activity(turn_context) + return await self.on_installation_update_remove(turn_context) return - async def on_installation_update_add_activity( # pylint: disable=unused-argument + async def on_installation_update_add( # pylint: disable=unused-argument self, turn_context: TurnContext ): """ @@ -397,7 +397,7 @@ async def on_installation_update_add_activity( # pylint: disable=unused-argumen """ return - async def on_installation_update_remove_activity( # pylint: disable=unused-argument + async def on_installation_update_remove( # pylint: disable=unused-argument self, turn_context: TurnContext ): """ diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 710e6c872..2f8b0daea 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -77,13 +77,13 @@ async def on_installation_update(self, turn_context: TurnContext): self.record.append("on_installation_update") return await super().on_installation_update(turn_context) - async def on_installation_update_add_activity(self, turn_context: TurnContext): - self.record.append("on_installation_update_add_activity") - return await super().on_installation_update_add_activity(turn_context) + async def on_installation_update_add(self, turn_context: TurnContext): + self.record.append("on_installation_update_add") + return await super().on_installation_update_add(turn_context) - async def on_installation_update_remove_activity(self, turn_context: TurnContext): - self.record.append("on_installation_update_remove_activity") - return await super().on_installation_update_remove_activity(turn_context) + async def on_installation_update_remove(self, turn_context: TurnContext): + self.record.append("on_installation_update_remove") + return await super().on_installation_update_remove(turn_context) async def on_unrecognized_activity_type(self, turn_context: TurnContext): self.record.append("on_unrecognized_activity_type") @@ -254,7 +254,7 @@ async def test_on_installation_update(self): assert len(bot.record) == 1 assert bot.record[0] == "on_installation_update" - async def test_on_installation_update_add_activity(self): + async def test_on_installation_update_add(self): activity = Activity(type=ActivityTypes.installation_update, action="add") adapter = TestInvokeAdapter() @@ -266,9 +266,9 @@ async def test_on_installation_update_add_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_installation_update" - assert bot.record[1] == "on_installation_update_add_activity" + assert bot.record[1] == "on_installation_update_add" - async def test_on_installation_update_add_remove_activity(self): + async def test_on_installation_update_add_remove(self): activity = Activity(type=ActivityTypes.installation_update, action="remove") adapter = TestInvokeAdapter() @@ -280,7 +280,7 @@ async def test_on_installation_update_add_remove_activity(self): assert len(bot.record) == 2 assert bot.record[0] == "on_installation_update" - assert bot.record[1] == "on_installation_update_remove_activity" + assert bot.record[1] == "on_installation_update_remove" async def test_healthcheck(self): activity = Activity(type=ActivityTypes.invoke, name="healthcheck",) From b787506dfc3280c1dffe7cb06c885bf44ba38ff4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 14 Oct 2020 13:19:38 -0500 Subject: [PATCH 583/616] SkillHandler doesn't return ResourceResponse when forwarding activities (#1404) Co-authored-by: Gabo Gilabert --- .../tests/skills/test_skill_handler.py | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index 77b8728af..f6a7649db 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -2,11 +2,16 @@ import json from uuid import uuid4 from asyncio import Future -from typing import Dict, List +from typing import Dict, List, Callable from unittest.mock import Mock, MagicMock import aiounittest +from botframework.connector.auth import ( + AuthenticationConfiguration, + AuthenticationConstants, + ClaimsIdentity, +) from botbuilder.core import ( TurnContext, BotActionNotImplementedError, @@ -28,11 +33,6 @@ Transcript, CallerIdConstants, ) -from botframework.connector.auth import ( - AuthenticationConfiguration, - AuthenticationConstants, - ClaimsIdentity, -) class ConversationIdFactoryForTest(ConversationIdFactoryBase): @@ -206,10 +206,30 @@ async def test_on_send_to_conversation(self): ) mock_adapter = Mock() - mock_adapter.continue_conversation = MagicMock(return_value=Future()) - mock_adapter.continue_conversation.return_value.set_result(Mock()) - mock_adapter.send_activities = MagicMock(return_value=Future()) - mock_adapter.send_activities.return_value.set_result([]) + + async def continue_conversation( + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, + audience: str = None, + ): # pylint: disable=unused-argument + await callback( + TurnContext( + mock_adapter, + conversation_reference_extension.get_continuation_activity( + self._conversation_reference + ), + ) + ) + + async def send_activities( + context: TurnContext, activities: List[Activity] + ): # pylint: disable=unused-argument + return [ResourceResponse(id="resourceId")] + + mock_adapter.continue_conversation = continue_conversation + mock_adapter.send_activities = send_activities sut = self.create_skill_handler_for_testing(mock_adapter) @@ -218,25 +238,12 @@ async def test_on_send_to_conversation(self): assert not activity.caller_id - await sut.test_on_send_to_conversation( + resource_response = await sut.test_on_send_to_conversation( self._claims_identity, self._conversation_id, activity ) - args, kwargs = mock_adapter.continue_conversation.call_args_list[0] - - assert isinstance(args[0], ConversationReference) - assert callable(args[1]) - assert isinstance(kwargs["claims_identity"], ClaimsIdentity) - - await args[1]( - TurnContext( - mock_adapter, - conversation_reference_extension.get_continuation_activity( - self._conversation_reference - ), - ) - ) assert activity.caller_id is None + assert resource_response.id == "resourceId" async def test_forwarding_on_send_to_conversation(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( From a30301b5d46d25bbd377b91097db915bed6c16f0 Mon Sep 17 00:00:00 2001 From: Michael Richardson <40401643+mdrichardson@users.noreply.github.com> Date: Fri, 16 Oct 2020 11:41:42 -0700 Subject: [PATCH 584/616] Allow skills with no appId or password (#1406) * add support for anon appid * add tests * cleanup * black fix * add skill RoleType * black fix * pylint fix Co-authored-by: Michael Richardson --- .../core/channel_service_handler.py | 23 +++++++--- .../tests/test_channel_service_handler.py | 46 +++++++++++++++++++ .../aiohttp/bot_framework_http_client.py | 5 +- .../tests/test_bot_framework_http_client.py | 3 +- .../schema/_connector_client_enums.py | 1 + .../botbuilder/schema/_models_py3.py | 4 +- .../connector/auth/app_credentials.py | 6 ++- .../auth/authentication_constants.py | 6 +++ .../connector/auth/claims_identity.py | 5 +- .../connector/auth/jwt_token_validation.py | 34 ++++++++++---- .../connector/auth/skill_validation.py | 20 ++++++++ .../tests/test_app_credentials.py | 30 ++++++++++++ .../botframework-connector/tests/test_auth.py | 38 +++++++++++++-- .../tests/test_skill_validation.py | 17 +++++++ libraries/swagger/ConnectorAPI.json | 9 ++-- 15 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 libraries/botbuilder-core/tests/test_channel_service_handler.py create mode 100644 libraries/botframework-connector/tests/test_app_credentials.py diff --git a/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py index 0cf90327c..9ed7104df 100644 --- a/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/channel_service_handler.py @@ -21,6 +21,7 @@ ClaimsIdentity, CredentialProvider, JwtTokenValidation, + SkillValidation, ) @@ -469,18 +470,28 @@ async def on_upload_attachment( raise BotActionNotImplementedError() async def _authenticate(self, auth_header: str) -> ClaimsIdentity: + """ + Helper to authenticate the header. + + This code is very similar to the code in JwtTokenValidation.authenticate_request, + we should move this code somewhere in that library when we refactor auth, + for now we keep it private to avoid adding more public static functions that we will need to deprecate later. + """ if not auth_header: is_auth_disabled = ( await self._credential_provider.is_authentication_disabled() ) - if is_auth_disabled: - # In the scenario where Auth is disabled, we still want to have the - # IsAuthenticated flag set in the ClaimsIdentity. To do this requires - # adding in an empty claim. - return ClaimsIdentity({}, True) + if not is_auth_disabled: + # No auth header. Auth is required. Request is not authorized. + raise PermissionError() - raise PermissionError() + # In the scenario where Auth is disabled, we still want to have the + # IsAuthenticated flag set in the ClaimsIdentity. To do this requires + # adding in an empty claim. + # Since ChannelServiceHandler calls are always a skill callback call, we set the skill claim too. + return SkillValidation.create_anonymous_skill_claim() + # Validate the header and extract claims. return await JwtTokenValidation.validate_auth_header( auth_header, self._credential_provider, diff --git a/libraries/botbuilder-core/tests/test_channel_service_handler.py b/libraries/botbuilder-core/tests/test_channel_service_handler.py new file mode 100644 index 000000000..8f0d9df12 --- /dev/null +++ b/libraries/botbuilder-core/tests/test_channel_service_handler.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest +from botbuilder.core import ChannelServiceHandler +from botframework.connector.auth import ( + AuthenticationConfiguration, + ClaimsIdentity, + SimpleCredentialProvider, + JwtTokenValidation, + AuthenticationConstants, +) +import botbuilder.schema + + +class TestChannelServiceHandler(ChannelServiceHandler): + def __init__(self): + self.claims_identity = None + ChannelServiceHandler.__init__( + self, SimpleCredentialProvider("", ""), AuthenticationConfiguration() + ) + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: botbuilder.schema.Activity, + ) -> botbuilder.schema.ResourceResponse: + self.claims_identity = claims_identity + return botbuilder.schema.ResourceResponse() + + +class ChannelServiceHandlerTests(aiounittest.AsyncTestCase): + async def test_should_authenticate_anonymous_skill_claim(self): + sut = TestChannelServiceHandler() + await sut.handle_reply_to_activity(None, "123", "456", {}) + + assert ( + sut.claims_identity.authentication_type + == AuthenticationConstants.ANONYMOUS_AUTH_TYPE + ) + assert ( + JwtTokenValidation.get_app_id_from_claims(sut.claims_identity.claims) + == AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + ) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 436f27c29..164818e87 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -15,6 +15,7 @@ ConversationReference, ConversationAccount, ChannelAccount, + RoleTypes, ) from botframework.connector.auth import ( ChannelProvider, @@ -97,7 +98,9 @@ async def post_activity( activity.conversation.id = conversation_id activity.service_url = service_url if not activity.recipient: - activity.recipient = ChannelAccount() + activity.recipient = ChannelAccount(role=RoleTypes.skill) + else: + activity.recipient.role = RoleTypes.skill status, content = await self._post_content(to_url, token, activity) diff --git a/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py index 0197b29b5..89ea01539 100644 --- a/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/tests/test_bot_framework_http_client.py @@ -1,7 +1,7 @@ from unittest.mock import Mock import aiounittest -from botbuilder.schema import ConversationAccount, ChannelAccount +from botbuilder.schema import ConversationAccount, ChannelAccount, RoleTypes from botbuilder.integration.aiohttp import BotFrameworkHttpClient from botframework.connector.auth import CredentialProvider, Activity @@ -69,3 +69,4 @@ async def _mock_post_content( ) assert activity.recipient.id == skill_recipient_id + assert activity.recipient.role is RoleTypes.skill diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index 46e7847e6..289944b5a 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -8,6 +8,7 @@ class RoleTypes(str, Enum): user = "user" bot = "bot" + skill = "skill" class ActivityTypes(str, Enum): diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index a206d7608..151b6feb2 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -1307,8 +1307,8 @@ class ConversationAccount(Model): :param aad_object_id: This account's object ID within Azure Active Directory (AAD) :type aad_object_id: str - :param role: Role of the entity behind the account (Example: User, Bot, - etc.). Possible values include: 'user', 'bot' + :param role: Role of the entity behind the account (Example: User, Bot, Skill + etc.). Possible values include: 'user', 'bot', 'skill' :type role: str or ~botframework.connector.models.RoleTypes :param tenant_id: This conversation's tenant ID :type tenant_id: str diff --git a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py index 148504c45..db657e25f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/app_credentials.py @@ -104,7 +104,11 @@ def signed_session(self, session: requests.Session = None) -> requests.Session: def _should_authorize( self, session: requests.Session # pylint: disable=unused-argument ) -> bool: - return True + # We don't set the token if the AppId is not set, since it means that we are in an un-authenticated scenario. + return ( + self.microsoft_app_id != AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + and self.microsoft_app_id is not None + ) def get_access_token(self, force_refresh: bool = False) -> str: """ diff --git a/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py b/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py index 429b7ccb6..7ccc8ab56 100644 --- a/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py +++ b/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py @@ -113,3 +113,9 @@ class AuthenticationConstants(ABC): # Service URL claim name. As used in Microsoft Bot Framework v3.1 auth. SERVICE_URL_CLAIM = "serviceurl" + + # AppId used for creating skill claims when there is no appId and password configured. + ANONYMOUS_SKILL_APP_ID = "AnonymousSkill" + + # Indicates that ClaimsIdentity.authentication_type is anonymous (no app Id and password were provided). + ANONYMOUS_AUTH_TYPE = "anonymous" diff --git a/libraries/botframework-connector/botframework/connector/auth/claims_identity.py b/libraries/botframework-connector/botframework/connector/auth/claims_identity.py index 9abdb6cb0..5bc29df62 100644 --- a/libraries/botframework-connector/botframework/connector/auth/claims_identity.py +++ b/libraries/botframework-connector/botframework/connector/auth/claims_identity.py @@ -5,9 +5,12 @@ def __init__(self, claim_type: str, value): class ClaimsIdentity: - def __init__(self, claims: dict, is_authenticated: bool): + def __init__( + self, claims: dict, is_authenticated: bool, authentication_type: str = None + ): self.claims = claims self.is_authenticated = is_authenticated + self.authentication_type = authentication_type def get_claim_value(self, claim_type: str): return self.claims.get(claim_type) diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index 737ba39ad..22d3e22ab 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -2,8 +2,9 @@ # Licensed under the MIT License. from typing import Dict, List, Union -from botbuilder.schema import Activity +from botbuilder.schema import Activity, RoleTypes +from ..channels import Channels from .authentication_configuration import AuthenticationConfiguration from .authentication_constants import AuthenticationConstants from .emulator_validation import EmulatorValidation @@ -43,14 +44,29 @@ async def authenticate_request( """ if not auth_header: # No auth header was sent. We might be on the anonymous code path. - is_auth_disabled = await credentials.is_authentication_disabled() - if is_auth_disabled: - # We are on the anonymous code path. - return ClaimsIdentity({}, True) - - # No Auth Header. Auth is required. Request is not authorized. - raise PermissionError("Unauthorized Access. Request is not authorized") - + auth_is_disabled = await credentials.is_authentication_disabled() + if not auth_is_disabled: + # No Auth Header. Auth is required. Request is not authorized. + raise PermissionError("Unauthorized Access. Request is not authorized") + + # Check if the activity is for a skill call and is coming from the Emulator. + try: + if ( + activity.channel_id == Channels.emulator + and activity.recipient.role == RoleTypes.skill + and activity.relates_to is not None + ): + # Return an anonymous claim with an anonymous skill AppId + return SkillValidation.create_anonymous_skill_claim() + except AttributeError: + pass + + # In the scenario where Auth is disabled, we still want to have the + # IsAuthenticated flag set in the ClaimsIdentity. To do this requires + # adding in an empty claim. + return ClaimsIdentity({}, True, AuthenticationConstants.ANONYMOUS_AUTH_TYPE) + + # Validate the header and extract claims. claims_identity = await JwtTokenValidation.validate_auth_header( auth_header, credentials, diff --git a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py index a9028b34e..71311c992 100644 --- a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py @@ -66,6 +66,12 @@ def is_skill_claim(claims: Dict[str, object]) -> bool: if AuthenticationConstants.VERSION_CLAIM not in claims: return False + if ( + claims.get(AuthenticationConstants.APP_ID_CLAIM, None) + == AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + ): + return True + audience = claims.get(AuthenticationConstants.AUDIENCE_CLAIM) # The audience is https://api.botframework.com and not an appId. @@ -124,6 +130,20 @@ async def authenticate_channel_token( return identity + @staticmethod + def create_anonymous_skill_claim(): + """ + Creates a ClaimsIdentity for an anonymous (unauthenticated) skill. + :return ClaimsIdentity: + """ + return ClaimsIdentity( + { + AuthenticationConstants.APP_ID_CLAIM: AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + }, + True, + AuthenticationConstants.ANONYMOUS_AUTH_TYPE, + ) + @staticmethod async def _validate_identity( identity: ClaimsIdentity, credentials: CredentialProvider diff --git a/libraries/botframework-connector/tests/test_app_credentials.py b/libraries/botframework-connector/tests/test_app_credentials.py new file mode 100644 index 000000000..d56981e92 --- /dev/null +++ b/libraries/botframework-connector/tests/test_app_credentials.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest +from botframework.connector.auth import AppCredentials, AuthenticationConstants + + +class AppCredentialsTests(aiounittest.AsyncTestCase): + @staticmethod + def test_should_not_send_token_for_anonymous(): + # AppID is None + app_creds_none = AppCredentials(app_id=None) + assert app_creds_none.signed_session().headers.get("Authorization") is None + + # AppID is anonymous skill + app_creds_anon = AppCredentials( + app_id=AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + ) + assert app_creds_anon.signed_session().headers.get("Authorization") is None + + +def test_constructor(): + should_default_to_channel_scope = AppCredentials() + assert ( + should_default_to_channel_scope.oauth_scope + == AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + should_default_to_custom_scope = AppCredentials(oauth_scope="customScope") + assert should_default_to_custom_scope.oauth_scope == "customScope" diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index e7371215c..24860c66f 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -6,7 +6,8 @@ import pytest -from botbuilder.schema import Activity +from botbuilder.schema import Activity, ConversationReference, ChannelAccount, RoleTypes +from botframework.connector import Channels from botframework.connector.auth import ( AuthenticationConfiguration, AuthenticationConstants, @@ -21,6 +22,7 @@ GovernmentChannelValidation, SimpleChannelProvider, ChannelProvider, + AppCredentials, ) @@ -262,7 +264,7 @@ async def test_channel_msa_header_valid_service_url_should_be_trusted(self): await JwtTokenValidation.authenticate_request(activity, header, credentials) - assert MicrosoftAppCredentials.is_trusted_service( + assert AppCredentials.is_trusted_service( "https://smba.trafficmanager.net/amer-client-ss.msg/" ) @@ -289,6 +291,32 @@ async def test_channel_msa_header_invalid_service_url_should_not_be_trusted(self "https://webchat.botframework.com/" ) + @pytest.mark.asyncio + # Tests with a valid Token and invalid service url and ensures that Service url is NOT added to + # Trusted service url list. + async def test_channel_authentication_disabled_and_skill_should_be_anonymous(self): + activity = Activity( + channel_id=Channels.emulator, + service_url="https://webchat.botframework.com/", + relates_to=ConversationReference(), + recipient=ChannelAccount(role=RoleTypes.skill), + ) + header = "" + credentials = SimpleCredentialProvider("", "") + + claims_principal = await JwtTokenValidation.authenticate_request( + activity, header, credentials + ) + + assert ( + claims_principal.authentication_type + == AuthenticationConstants.ANONYMOUS_AUTH_TYPE + ) + assert ( + JwtTokenValidation.get_app_id_from_claims(claims_principal.claims) + == AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + ) + @pytest.mark.asyncio async def test_channel_msa_header_from_user_specified_tenant(self): activity = Activity( @@ -318,8 +346,10 @@ async def test_channel_authentication_disabled_should_be_anonymous(self): activity, header, credentials ) - assert claims_principal.is_authenticated - assert not claims_principal.claims + assert ( + claims_principal.authentication_type + == AuthenticationConstants.ANONYMOUS_AUTH_TYPE + ) @pytest.mark.asyncio # Tests with no authentication header and makes sure the service URL is not added to the trusted list. diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index a32625050..66b22fc07 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -9,6 +9,7 @@ ClaimsIdentity, CredentialProvider, SkillValidation, + JwtTokenValidation, ) @@ -47,6 +48,13 @@ def test_is_skill_claim_test(self): claims[AuthenticationConstants.APP_ID_CLAIM] = audience assert not SkillValidation.is_skill_claim(claims) + # Anonymous skill app id + del claims[AuthenticationConstants.APP_ID_CLAIM] + claims[ + AuthenticationConstants.APP_ID_CLAIM + ] = AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + assert SkillValidation.is_skill_claim(claims) + # All checks pass, should be good now del claims[AuthenticationConstants.AUDIENCE_CLAIM] claims[AuthenticationConstants.AUDIENCE_CLAIM] = app_id @@ -157,3 +165,12 @@ def validate_appid(app_id: str): # All checks pass (no exception) claims[AuthenticationConstants.APP_ID_CLAIM] = app_id await SkillValidation._validate_identity(mock_identity, mock_credentials) + + @staticmethod + def test_create_anonymous_skill_claim(): + sut = SkillValidation.create_anonymous_skill_claim() + assert ( + JwtTokenValidation.get_app_id_from_claims(sut.claims) + == AuthenticationConstants.ANONYMOUS_SKILL_APP_ID + ) + assert sut.authentication_type == AuthenticationConstants.ANONYMOUS_AUTH_TYPE diff --git a/libraries/swagger/ConnectorAPI.json b/libraries/swagger/ConnectorAPI.json index bae96e716..af940d70d 100644 --- a/libraries/swagger/ConnectorAPI.json +++ b/libraries/swagger/ConnectorAPI.json @@ -919,7 +919,7 @@ }, "role": { "$ref": "#/definitions/RoleTypes", - "description": "Role of the entity behind the account (Example: User, Bot, etc.)" + "description": "Role of the entity behind the account (Example: User, Bot, Skill, etc.)" } } }, @@ -1154,7 +1154,7 @@ }, "role": { "$ref": "#/definitions/RoleTypes", - "description": "Role of the entity behind the account (Example: User, Bot, etc.)" + "description": "Role of the entity behind the account (Example: User, Bot, Skill, etc.)" } } }, @@ -2277,10 +2277,11 @@ } }, "RoleTypes": { - "description": "Role of the entity behind the account (Example: User, Bot, etc.)", + "description": "Role of the entity behind the account (Example: User, Bot, Skill, etc.)", "enum": [ "user", - "bot" + "bot", + "skill" ], "type": "string", "properties": {}, From d6cacc1768700b31929c51147282456e8e0a5f1f Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 19 Oct 2020 12:32:54 -0500 Subject: [PATCH 585/616] Support for meeting APIs --- .../core/teams/teams_activity_extensions.py | 23 ++++- .../botbuilder/core/teams/teams_info.py | 46 +++++++++ .../tests/teams/test_teams_extension.py | 11 +++ .../tests/teams/test_teams_info.py | 21 +++- .../botbuilder/schema/teams/__init__.py | 4 + .../botbuilder/schema/teams/_models_py3.py | 97 ++++++++++++++++++- .../teams/operations/teams_operations.py | 71 ++++++++++++++ 7 files changed, 270 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py index 23d907e09..cb4ba9cfc 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -2,7 +2,17 @@ # Licensed under the MIT License. from botbuilder.schema import Activity -from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo +from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo, TeamsMeetingInfo + + +def teams_get_channel_data(activity: Activity) -> TeamsChannelData: + if not activity: + return None + + if activity.channel_data: + return TeamsChannelData().deserialize(activity.channel_data) + + return None def teams_get_channel_id(activity: Activity) -> str: @@ -37,3 +47,14 @@ def teams_notify_user(activity: Activity): channel_data = TeamsChannelData().deserialize(activity.channel_data) channel_data.notification = NotificationInfo(alert=True) activity.channel_data = channel_data + + +def teams_get_meeting_info(activity: Activity) -> TeamsMeetingInfo: + if not activity: + return None + + if activity.channel_data: + channel_data = TeamsChannelData().deserialize(activity.channel_data) + return channel_data.meeting + + return None diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index e781f4696..6ec654a70 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -3,6 +3,11 @@ from typing import List, Tuple from botbuilder.schema import ConversationParameters, ConversationReference + +from botbuilder.core.teams.teams_activity_extensions import ( + teams_get_meeting_info, + teams_get_channel_data, +) from botbuilder.core.turn_context import Activity, TurnContext from botbuilder.schema.teams import ( ChannelInfo, @@ -10,6 +15,7 @@ TeamsChannelData, TeamsChannelAccount, TeamsPagedMembersResult, + TeamsParticipantChannelAccount, ) from botframework.connector.aio import ConnectorClient from botframework.connector.teams.teams_connector_client import TeamsConnectorClient @@ -177,6 +183,46 @@ async def get_member( return await TeamsInfo.get_team_member(turn_context, team_id, member_id) + @staticmethod + async def get_meeting_participant( + turn_context: TurnContext, + meeting_id: str = None, + participant_id: str = None, + tenant_id: str = None, + ) -> TeamsParticipantChannelAccount: + meeting_id = ( + meeting_id + if meeting_id + else teams_get_meeting_info(turn_context.activity).id + ) + if meeting_id is None: + raise TypeError( + "TeamsInfo._get_meeting_participant: method requires a meeting_id" + ) + + participant_id = ( + participant_id + if participant_id + else turn_context.activity.from_property.aad_object_id + ) + if participant_id is None: + raise TypeError( + "TeamsInfo._get_meeting_participant: method requires a participant_id" + ) + + tenant_id = ( + tenant_id + if tenant_id + else teams_get_channel_data(turn_context.activity).tenant.id + ) + if tenant_id is None: + raise TypeError( + "TeamsInfo._get_meeting_participant: method requires a tenant_id" + ) + + connector_client = await TeamsInfo.get_teams_connector_client(turn_context) + return connector_client.teams.fetch_participant(meeting_id, participant_id, tenant_id) + @staticmethod async def get_teams_connector_client( turn_context: TurnContext, diff --git a/libraries/botbuilder-core/tests/teams/test_teams_extension.py b/libraries/botbuilder-core/tests/teams/test_teams_extension.py index e4ebb4449..98c1ee829 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_extension.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_extension.py @@ -10,6 +10,7 @@ teams_get_team_info, teams_notify_user, ) +from botbuilder.core.teams.teams_activity_extensions import teams_get_meeting_info class TestTeamsActivityHandler(aiounittest.AsyncTestCase): @@ -149,3 +150,13 @@ def test_teams_notify_user_with_no_channel_data(self): # Assert assert activity.channel_data.notification.alert assert activity.id == "id123" + + def test_teams_meeting_info(self): + # Arrange + activity = Activity(channel_data={"meeting": {"id": "meeting123"}}) + + # Act + meeting_id = teams_get_meeting_info(activity).id + + # Assert + assert meeting_id == "meeting123" diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 9ddc5662c..d0693b930 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -2,7 +2,8 @@ # Licensed under the MIT License. import aiounittest - +from botbuilder.schema.teams import TeamsChannelData, TeamsMeetingInfo, TenantInfo +from botframework.connector import Channels from botbuilder.core import TurnContext, MessageFactory from botbuilder.core.teams import TeamsInfo, TeamsActivityHandler @@ -199,6 +200,24 @@ def create_conversation(): else: assert False, "should have raise TypeError" + async def test_get_participant(self): + adapter = SimpleAdapterWithCreateConversation() + + activity = Activity( + type="message", + text="Test-get_participant", + channel_id=Channels.ms_teams, + from_property=ChannelAccount( + aad_object_id="participantId-1" + ), + channel_data={"meeting": {"id": "meetingId-1"}, "tenant": {"id": "tenantId-1"}}, + service_url="https://test.coffee" + ) + + turn_context = TurnContext(adapter, activity) + handler = TeamsActivityHandler() + await handler.on_turn(turn_context) + class TestTeamsActivityHandler(TeamsActivityHandler): async def on_turn(self, turn_context: TurnContext): diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index a6d384feb..99e4771e7 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -58,6 +58,8 @@ from ._models_py3 import TeamsChannelData from ._models_py3 import TeamsPagedMembersResult from ._models_py3 import TenantInfo +from ._models_py3 import TeamsMeetingInfo +from ._models_py3 import TeamsParticipantChannelAccount __all__ = [ "AppBasedLinkQuery", @@ -117,4 +119,6 @@ "TeamsChannelData", "TeamsPagedMembersResult", "TenantInfo", + "TeamsMeetingInfo", + "TeamsParticipantChannelAccount", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 3a27e5c51..b75eb70fd 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from msrest.serialization import Model -from botbuilder.schema import Activity, Attachment, ChannelAccount, PagedMembersResult +from botbuilder.schema import Activity, Attachment, ChannelAccount, PagedMembersResult, ConversationAccount class TaskModuleRequest(Model): @@ -1887,6 +1887,8 @@ class TeamsChannelData(Model): :type notification: ~botframework.connector.teams.models.NotificationInfo :param tenant: Information about the tenant in which the message was sent :type tenant: ~botframework.connector.teams.models.TenantInfo + :param meeting: Information about the meeting in which the message was sent + :type meeting: ~botframework.connector.teams.models.TeamsMeetingInfo """ _attribute_map = { @@ -1895,6 +1897,7 @@ class TeamsChannelData(Model): "team": {"key": "team", "type": "TeamInfo"}, "notification": {"key": "notification", "type": "NotificationInfo"}, "tenant": {"key": "tenant", "type": "TenantInfo"}, + "meeting": {"key": "meeting", "type": "TeamsMeetingInfo"}, } def __init__( @@ -1905,6 +1908,7 @@ def __init__( team=None, notification=None, tenant=None, + meeting=None, **kwargs ) -> None: super(TeamsChannelData, self).__init__(**kwargs) @@ -1914,6 +1918,7 @@ def __init__( self.team = team self.notification = notification self.tenant = tenant + self.meeting = meeting class TenantInfo(Model): @@ -1930,3 +1935,93 @@ class TenantInfo(Model): def __init__(self, *, id: str = None, **kwargs) -> None: super(TenantInfo, self).__init__(**kwargs) self.id = id + + +class TeamsMeetingInfo(Model): + """Describes a Teams Meeting. + + :param id: Unique identifier representing a meeting + :type id: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + } + + def __init__(self, *, id: str = None, **kwargs) -> None: + super(TeamsMeetingInfo, self).__init__(**kwargs) + self.id = id + + +class TeamsParticipantChannelAccount(TeamsChannelAccount): + """Teams participant channel account detailing user Azure Active Directory and meeting participant details. + + :param id: Channel id for the user or bot on this channel. + :type id: str + :param name: Display friendly name. + :type name: str + :param given_name: Given name part of the user name. + :type given_name: str + :param surname: Surname part of the user name. + :type surname: str + :param email: Email of the user. + :type email: str + :param user_principal_name: Unique user principal name. + :type user_principal_name: str + :param tenant_id: TenantId of the user. + :type tenant_id: str + :param user_role: UserRole of the user. + :type user_role: str + :param meeting_role: Role of the participant in the current meeting. + :type meeting_role: str + :param in_meeting: True, if the participant is in the meeting. + :type in_meeting: str + :param conversation: Conversation Account for the meeting. + :type conversation: str + """ + + _attribute_map = { + "id": {"key": "id", "type": "str"}, + "name": {"key": "name", "type": "str"}, + "given_name": {"key": "givenName", "type": "str"}, + "surname": {"key": "surname", "type": "str"}, + "email": {"key": "email", "type": "str"}, + "aad_object_id": {"key": "objectId", "type": "str"}, + "user_principal_name": {"key": "userPrincipalName", "type": "str"}, + "tenant_id": {"key": "tenantId", "type": "str"}, + "user_role": {"key": "userRole", "type": "str"}, + "meeting_role": {"key": "meetingRole", "type": "str"}, + "in_meeting": {"key": "inMeeting", "type": "bool"}, + "conversation": {"key": "conversation", "type": "ConversationAccount"}, + } + + def __init__( + self, + *, + id: str = None, + name: str = None, + given_name: str = None, + surname: str = None, + email: str = None, + aad_object_id: str = None, + user_principal_name: str = None, + tenant_id: str = None, + user_role: str = None, + meeting_role: str = None, + in_meeting: bool = None, + conversation: ConversationAccount = None, + **kwargs + ) -> None: + super(TeamsParticipantChannelAccount, self).__init__(**kwargs) + self.id = id + self.name = name + self.given_name = given_name + self.surname = surname + self.email = email + self.aad_object_id = aad_object_id + self.user_principal_name = user_principal_name + self.tenant_id = tenant_id + self.user_role = user_role + self.meeting_role = meeting_role + self.in_meeting = in_meeting + self.conversation = conversation diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py index e6a2d909d..29238a6fd 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -145,3 +145,74 @@ def get_team_details( return deserialized get_team_details.metadata = {"url": "/v3/teams/{teamId}"} + + def fetch_participant( + self, + meeting_id: str, + participant_id: str, + tenant_id: str, + custom_headers=None, + raw=False, + **operation_config + ): + """Fetches Teams meeting participant details. + + :param meeting_id: Teams meeting id + :type meeting_id: str + :param participant_id: Teams meeting participant id + :type participant_id: str + :param tenant_id: Teams meeting tenant id + :type tenant_id: str + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: TeamsParticipantChannelAccount or ClientRawResponse if raw=true + :rtype: ~botframework.connector.teams.models.TeamsParticipantChannelAccount or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + + # Construct URL + url = self.fetch_participant.metadata["url"] + path_format_arguments = { + "meetingId": self._serialize.url("meeting_id", meeting_id, "str"), + "participantId": self._serialize.url( + "participant_id", participant_id, "str" + ), + "tenantId": self._serialize.url("tenant_id", tenant_id, "str"), + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct and send request + request = self._client.get(url, query_parameters, header_parameters) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200]: + raise HttpOperationError(self._deserialize, response) + + deserialized = None + + if response.status_code == 200: + deserialized = self._deserialize("TeamsParticipantChannelAccount", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + fetch_participant.metadata = { + "url": "/v1/meetings/{meetingId}/participants/{participantId}?tenantId={tenantId}" + } From 9e89dd4f907dde3687fdd4418343c7f754f950bb Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 19 Oct 2020 12:40:36 -0500 Subject: [PATCH 586/616] black --- .../botbuilder/schema/teams/_models_py3.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 03b063839..150f69d5e 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -944,7 +944,14 @@ class NotificationInfo(Model): "external_resource_url": {"key": "externalResourceUrl", "type": "str"}, } - def __init__(self, *, alert: bool = None, alert_in_meeting: bool = None, external_resource_url: str = None, **kwargs) -> None: + def __init__( + self, + *, + alert: bool = None, + alert_in_meeting: bool = None, + external_resource_url: str = None, + **kwargs + ) -> None: super(NotificationInfo, self).__init__(**kwargs) self.alert = alert self.alert_in_meeting = alert_in_meeting From 6c4e1dfe463e21341023510ee045c902af314b57 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 19 Oct 2020 12:44:44 -0500 Subject: [PATCH 587/616] black --- .../core/teams/teams_activity_extensions.py | 7 ++++++- .../botbuilder/core/teams/teams_info.py | 4 +++- .../botbuilder-core/tests/teams/test_teams_info.py | 11 ++++++----- .../botbuilder/schema/teams/_models_py3.py | 8 +++++++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py index cb4ba9cfc..54aac62be 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_extensions.py @@ -2,7 +2,12 @@ # Licensed under the MIT License. from botbuilder.schema import Activity -from botbuilder.schema.teams import NotificationInfo, TeamsChannelData, TeamInfo, TeamsMeetingInfo +from botbuilder.schema.teams import ( + NotificationInfo, + TeamsChannelData, + TeamInfo, + TeamsMeetingInfo, +) def teams_get_channel_data(activity: Activity) -> TeamsChannelData: diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 6ec654a70..280e817b2 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -221,7 +221,9 @@ async def get_meeting_participant( ) connector_client = await TeamsInfo.get_teams_connector_client(turn_context) - return connector_client.teams.fetch_participant(meeting_id, participant_id, tenant_id) + return connector_client.teams.fetch_participant( + meeting_id, participant_id, tenant_id + ) @staticmethod async def get_teams_connector_client( diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index d0693b930..bcf59f1c6 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -207,11 +207,12 @@ async def test_get_participant(self): type="message", text="Test-get_participant", channel_id=Channels.ms_teams, - from_property=ChannelAccount( - aad_object_id="participantId-1" - ), - channel_data={"meeting": {"id": "meetingId-1"}, "tenant": {"id": "tenantId-1"}}, - service_url="https://test.coffee" + from_property=ChannelAccount(aad_object_id="participantId-1"), + channel_data={ + "meeting": {"id": "meetingId-1"}, + "tenant": {"id": "tenantId-1"}, + }, + service_url="https://test.coffee", ) turn_context = TurnContext(adapter, activity) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index b75eb70fd..2c6bedc1f 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -2,7 +2,13 @@ # Licensed under the MIT License. from msrest.serialization import Model -from botbuilder.schema import Activity, Attachment, ChannelAccount, PagedMembersResult, ConversationAccount +from botbuilder.schema import ( + Activity, + Attachment, + ChannelAccount, + PagedMembersResult, + ConversationAccount, +) class TaskModuleRequest(Model): From 899a325e0c301a73f5819a74fdc547a8692ca5d9 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 19 Oct 2020 13:16:23 -0500 Subject: [PATCH 588/616] pylint --- .../botbuilder-core/botbuilder/core/teams/teams_info.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 280e817b2..3bbfd60af 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -2,8 +2,10 @@ # Licensed under the MIT License. from typing import List, Tuple -from botbuilder.schema import ConversationParameters, ConversationReference +from botframework.connector.aio import ConnectorClient +from botframework.connector.teams.teams_connector_client import TeamsConnectorClient +from botbuilder.schema import ConversationParameters, ConversationReference from botbuilder.core.teams.teams_activity_extensions import ( teams_get_meeting_info, teams_get_channel_data, @@ -17,8 +19,6 @@ TeamsPagedMembersResult, TeamsParticipantChannelAccount, ) -from botframework.connector.aio import ConnectorClient -from botframework.connector.teams.teams_connector_client import TeamsConnectorClient class TeamsInfo: From 07948f4bf7ebd6edf5beeb0ed7b9dcb62450caf4 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 19 Oct 2020 13:30:11 -0500 Subject: [PATCH 589/616] More pylint --- libraries/botbuilder-core/tests/teams/test_teams_info.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index bcf59f1c6..5c044e6ca 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. import aiounittest -from botbuilder.schema.teams import TeamsChannelData, TeamsMeetingInfo, TenantInfo from botframework.connector import Channels from botbuilder.core import TurnContext, MessageFactory From 4c5a34139e10a3520f427628470413bc183cdb88 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 19 Oct 2020 17:32:51 -0700 Subject: [PATCH 590/616] Support update and delete from skills --- .../botbuilder/core/skills/skill_handler.py | 85 +++++- .../tests/skills/test_skill_handler.py | 245 ++++++++++++++---- 2 files changed, 268 insertions(+), 62 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 0735e0d84..be417b046 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -62,9 +62,9 @@ async def on_send_to_conversation( This method allows you to send an activity to the end of a conversation. This is slightly different from ReplyToActivity(). - * SendToConversation(conversationId) - will append the activity to the end + * SendToConversation(conversation_id) - will append the activity to the end of the conversation according to the timestamp or semantics of the channel. - * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + * ReplyToActivity(conversation_id,ActivityId) - adds the activity as a reply to another activity, if the channel supports it. If the channel does not support nested replies, ReplyToActivity falls back to SendToConversation. @@ -97,9 +97,9 @@ async def on_reply_to_activity( This method allows you to reply to an activity. This is slightly different from SendToConversation(). - * SendToConversation(conversationId) - will append the activity to the end + * SendToConversation(conversation_id) - will append the activity to the end of the conversation according to the timestamp or semantics of the channel. - * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + * ReplyToActivity(conversation_id,ActivityId) - adds the activity as a reply to another activity, if the channel supports it. If the channel does not support nested replies, ReplyToActivity falls back to SendToConversation. @@ -111,6 +111,8 @@ async def on_reply_to_activity( :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` :param conversation_id:The conversation ID. :type conversation_id: str + :param activity_id: Activity ID to send. + :type activity_id: str :param activity: Activity to send. :type activity: Activity :return: @@ -119,13 +121,66 @@ async def on_reply_to_activity( claims_identity, conversation_id, activity_id, activity, ) - async def _process_activity( + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str + ): + skill_conversation_reference = await self._get_skill_conversation_reference( + conversation_id + ) + + async def callback(turn_context: TurnContext): + turn_context.turn_state[ + self.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference + await turn_context.delete_activity(activity_id) + + await self._adapter.continue_conversation( + skill_conversation_reference.conversation_reference, + callback, + claims_identity=claims_identity, + audience=skill_conversation_reference.oauth_scope, + ) + + async def on_update_activity( self, claims_identity: ClaimsIdentity, conversation_id: str, - reply_to_activity_id: str, + activity_id: str, activity: Activity, ) -> ResourceResponse: + skill_conversation_reference = await self._get_skill_conversation_reference( + conversation_id + ) + + resource_response: ResourceResponse = None + + async def callback(turn_context: TurnContext): + nonlocal resource_response + turn_context.turn_state[ + self.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference + activity.apply_conversation_reference( + skill_conversation_reference.conversation_reference + ) + turn_context.activity.id = activity_id + turn_context.activity.caller_id = ( + f"{CallerIdConstants.bot_to_bot_prefix}" + f"{JwtTokenValidation.get_app_id_from_claims(claims_identity.claims)}" + ) + resource_response = await turn_context.update_activity(activity) + + await self._adapter.continue_conversation( + skill_conversation_reference.conversation_reference, + callback, + claims_identity=claims_identity, + audience=skill_conversation_reference.oauth_scope, + ) + + return resource_response or ResourceResponse(id=str(uuid4()).replace("-", "")) + + async def _get_skill_conversation_reference( + self, conversation_id: str + ) -> SkillConversationReference: # Get the SkillsConversationReference conversation_reference_result = await self._conversation_id_factory.get_conversation_reference( conversation_id @@ -135,11 +190,10 @@ async def _process_activity( # or a ConversationReference (the old way, but still here for compatibility). If a # ConversationReference is returned, build a new SkillConversationReference to simplify # the remainder of this method. - skill_conversation_reference: SkillConversationReference = None if isinstance(conversation_reference_result, SkillConversationReference): - skill_conversation_reference = conversation_reference_result + skill_conversation_reference: SkillConversationReference = conversation_reference_result else: - skill_conversation_reference = SkillConversationReference( + skill_conversation_reference: SkillConversationReference = SkillConversationReference( conversation_reference=conversation_reference_result, oauth_scope=( GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE @@ -154,6 +208,19 @@ async def _process_activity( if not skill_conversation_reference.conversation_reference: raise KeyError("conversationReference not found") + return skill_conversation_reference + + async def _process_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + reply_to_activity_id: str, + activity: Activity, + ) -> ResourceResponse: + skill_conversation_reference = await self._get_skill_conversation_reference( + conversation_id + ) + # If an activity is sent, return the ResourceResponse resource_response: ResourceResponse = None diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py index f6a7649db..73997cdff 100644 --- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py +++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py @@ -1,5 +1,6 @@ import hashlib import json +from datetime import datetime from uuid import uuid4 from asyncio import Future from typing import Dict, List, Callable @@ -204,6 +205,8 @@ async def test_on_send_to_conversation(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( self._conversation_reference ) + # python 3.7 doesn't support AsyncMock, change this when min ver is 3.8 + send_activities_called = False mock_adapter = Mock() @@ -214,36 +217,55 @@ async def continue_conversation( claims_identity: ClaimsIdentity = None, audience: str = None, ): # pylint: disable=unused-argument - await callback( - TurnContext( - mock_adapter, - conversation_reference_extension.get_continuation_activity( - self._conversation_reference - ), - ) + # Invoke the callback created by the handler so we can assert the rest of the execution. + turn_context = TurnContext( + mock_adapter, + conversation_reference_extension.get_continuation_activity( + self._conversation_reference + ), ) + await callback(turn_context) + + # Assert the callback set the right properties. + assert ( + f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ), turn_context.activity.caller_id async def send_activities( context: TurnContext, activities: List[Activity] ): # pylint: disable=unused-argument + # Messages should not have a caller id set when sent back to the caller. + nonlocal send_activities_called + assert activities[0].caller_id is None + assert activities[0].reply_to_id is None + send_activities_called = True return [ResourceResponse(id="resourceId")] mock_adapter.continue_conversation = continue_conversation mock_adapter.send_activities = send_activities - sut = self.create_skill_handler_for_testing(mock_adapter) - - activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) - TurnContext.apply_conversation_reference(activity, self._conversation_reference) - - assert not activity.caller_id + types_to_test = [ + ActivityTypes.end_of_conversation, + ActivityTypes.event, + ActivityTypes.message, + ] + + for activity_type in types_to_test: + with self.subTest(act_type=activity_type): + send_activities_called = False + activity = Activity(type=activity_type, attachments=[], entities=[]) + TurnContext.apply_conversation_reference( + activity, self._conversation_reference + ) + sut = self.create_skill_handler_for_testing(mock_adapter) - resource_response = await sut.test_on_send_to_conversation( - self._claims_identity, self._conversation_id, activity - ) + resource_response = await sut.test_on_send_to_conversation( + self._claims_identity, self._conversation_id, activity + ) - assert activity.caller_id is None - assert resource_response.id == "resourceId" + if activity_type == ActivityTypes.message: + assert send_activities_called + assert resource_response.id == "resourceId" async def test_forwarding_on_send_to_conversation(self): self._conversation_id = await self._test_id_factory.create_skill_conversation_id( @@ -282,69 +304,186 @@ async def side_effect( assert response.id is resource_response_id async def test_on_reply_to_activity(self): + resource_response_id = "resourceId" self._conversation_id = await self._test_id_factory.create_skill_conversation_id( self._conversation_reference ) - mock_adapter = Mock() - mock_adapter.continue_conversation = MagicMock(return_value=Future()) - mock_adapter.continue_conversation.return_value.set_result(Mock()) - mock_adapter.send_activities = MagicMock(return_value=Future()) - mock_adapter.send_activities.return_value.set_result([]) + types_to_test = [ + ActivityTypes.end_of_conversation, + ActivityTypes.event, + ActivityTypes.message, + ] + + for activity_type in types_to_test: + with self.subTest(act_type=activity_type): + mock_adapter = Mock() + mock_adapter.continue_conversation = MagicMock(return_value=Future()) + mock_adapter.continue_conversation.return_value.set_result(Mock()) + mock_adapter.send_activities = MagicMock(return_value=Future()) + mock_adapter.send_activities.return_value.set_result( + [ResourceResponse(id=resource_response_id)] + ) - sut = self.create_skill_handler_for_testing(mock_adapter) + sut = self.create_skill_handler_for_testing(mock_adapter) - activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) - activity_id = str(uuid4()) - TurnContext.apply_conversation_reference(activity, self._conversation_reference) + activity = Activity(type=activity_type, attachments=[], entities=[]) + activity_id = str(uuid4()) + TurnContext.apply_conversation_reference( + activity, self._conversation_reference + ) - await sut.test_on_reply_to_activity( - self._claims_identity, self._conversation_id, activity_id, activity - ) + resource_response = await sut.test_on_reply_to_activity( + self._claims_identity, self._conversation_id, activity_id, activity + ) - args, kwargs = mock_adapter.continue_conversation.call_args_list[0] + # continue_conversation validation + ( + args_continue, + kwargs_continue, + ) = mock_adapter.continue_conversation.call_args_list[0] + mock_adapter.continue_conversation.assert_called_once() - assert isinstance(args[0], ConversationReference) - assert callable(args[1]) - assert isinstance(kwargs["claims_identity"], ClaimsIdentity) + assert isinstance(args_continue[0], ConversationReference) + assert callable(args_continue[1]) + assert isinstance(kwargs_continue["claims_identity"], ClaimsIdentity) + + turn_context = TurnContext( + mock_adapter, + conversation_reference_extension.get_continuation_activity( + self._conversation_reference + ), + ) + await args_continue[1](turn_context) + # assert the callback set the right properties. + assert ( + f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ), turn_context.activity.caller_id + + if activity_type == ActivityTypes.message: + # send_activities validation + (args_send, _,) = mock_adapter.send_activities.call_args_list[0] + activity_from_send = args_send[1][0] + assert activity_from_send.caller_id is None + assert activity_from_send.reply_to_id, activity_id + assert resource_response.id, resource_response_id + else: + # Assert mock SendActivitiesAsync wasn't called + mock_adapter.send_activities.assert_not_called() + + async def test_on_update_activity(self): + self._conversation_id = await self._test_id_factory.create_skill_conversation_id( + self._conversation_reference + ) + resource_response_id = "resourceId" + called_continue = False + called_update = False - await args[1]( - TurnContext( + mock_adapter = Mock() + activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) + activity_id = str(uuid4()) + message = activity.text = f"TestUpdate {datetime.now()}." + + async def continue_conversation( + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, + audience: str = None, + ): # pylint: disable=unused-argument + # Invoke the callback created by the handler so we can assert the rest of the execution. + nonlocal called_continue + turn_context = TurnContext( mock_adapter, conversation_reference_extension.get_continuation_activity( self._conversation_reference ), ) + await callback(turn_context) + + # Assert the callback set the right properties. + assert ( + f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}" + ), turn_context.activity.caller_id + called_continue = True + + async def update_activity( + context: TurnContext, # pylint: disable=unused-argument + new_activity: Activity, + ) -> ResourceResponse: + # Assert the activity being sent. + nonlocal called_update + assert activity_id, new_activity.reply_to_id + assert message, new_activity.text + called_update = True + + return ResourceResponse(id=resource_response_id) + + mock_adapter.continue_conversation = continue_conversation + mock_adapter.update_activity = update_activity + + sut = self.create_skill_handler_for_testing(mock_adapter) + resource_response = await sut.test_on_update_activity( + self._claims_identity, self._conversation_id, activity_id, activity ) - assert activity.caller_id is None - async def test_on_update_activity(self): - self._conversation_id = "" + assert called_continue + assert called_update + assert resource_response, resource_response_id - mock_adapter = Mock() + async def test_on_delete_activity(self): + self._conversation_id = await self._test_id_factory.create_skill_conversation_id( + self._conversation_reference + ) - sut = self.create_skill_handler_for_testing(mock_adapter) + resource_response_id = "resourceId" + called_continue = False + called_delete = False - activity = Activity(type=ActivityTypes.message, attachments=[], entities=[]) + mock_adapter = Mock() activity_id = str(uuid4()) - with self.assertRaises(BotActionNotImplementedError): - await sut.test_on_update_activity( - self._claims_identity, self._conversation_id, activity_id, activity + async def continue_conversation( + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, + audience: str = None, + ): # pylint: disable=unused-argument + # Invoke the callback created by the handler so we can assert the rest of the execution. + nonlocal called_continue + turn_context = TurnContext( + mock_adapter, + conversation_reference_extension.get_continuation_activity( + self._conversation_reference + ), ) + await callback(turn_context) + called_continue = True - async def test_on_delete_activity(self): - self._conversation_id = "" + async def delete_activity( + context: TurnContext, # pylint: disable=unused-argument + conversation_reference: ConversationReference, + ) -> ResourceResponse: + # Assert the activity being sent. + nonlocal called_delete + # Assert the activity_id being deleted. + assert activity_id, conversation_reference.activity_id + called_delete = True - mock_adapter = Mock() + return ResourceResponse(id=resource_response_id) + + mock_adapter.continue_conversation = continue_conversation + mock_adapter.delete_activity = delete_activity sut = self.create_skill_handler_for_testing(mock_adapter) - activity_id = str(uuid4()) - with self.assertRaises(BotActionNotImplementedError): - await sut.test_on_delete_activity( - self._claims_identity, self._conversation_id, activity_id - ) + await sut.test_on_delete_activity( + self._claims_identity, self._conversation_id, activity_id + ) + + assert called_continue + assert called_delete async def test_on_get_activity_members(self): self._conversation_id = "" From 4897c721796c7e15094410c7dd6de2677ad14cef Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 20 Oct 2020 15:28:23 -0700 Subject: [PATCH 591/616] TranscriptLogger should not log continue conversation --- .../botbuilder/core/__init__.py | 3 ++ .../botbuilder/core/adapters/test_adapter.py | 1 + .../botbuilder/core/bot_framework_adapter.py | 3 +- .../core/conversation_reference_extension.py | 9 +++- .../core/memory_transcript_store.py | 11 +++-- .../botbuilder/core/transcript_logger.py | 18 +++++++- .../test_transcript_logger_middleware.py | 44 +++++++++++++++++++ .../botbuilder/schema/__init__.py | 2 + .../botbuilder/schema/_models_py3.py | 6 +++ 9 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 libraries/botbuilder-core/tests/test_transcript_logger_middleware.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index ec7b45807..0a9a218fa 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -39,6 +39,7 @@ from .telemetry_logger_constants import TelemetryLoggerConstants from .telemetry_logger_middleware import TelemetryLoggerMiddleware from .turn_context import TurnContext +from .transcript_logger import TranscriptLogger, TranscriptLoggerMiddleware from .user_state import UserState from .register_class_middleware import RegisterClassMiddleware from .adapter_extensions import AdapterExtensions @@ -87,6 +88,8 @@ "TelemetryLoggerConstants", "TelemetryLoggerMiddleware", "TopIntent", + "TranscriptLogger", + "TranscriptLoggerMiddleware", "TurnContext", "UserState", "UserTokenProvider", diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 09ffb3e76..6488a2726 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -225,6 +225,7 @@ async def create_conversation( type=ActivityTypes.conversation_update, members_added=[], members_removed=[], + channel_id=channel_id, conversation=ConversationAccount(id=str(uuid.uuid4())), ) context = self.create_turn_context(update) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 31540ccec..930f29d44 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -39,6 +39,7 @@ ) from botbuilder.schema import ( Activity, + ActivityEventNames, ActivityTypes, ChannelAccount, ConversationAccount, @@ -390,7 +391,7 @@ async def create_conversation( event_activity = Activity( type=ActivityTypes.event, - name="CreateConversation", + name=ActivityEventNames.create_conversation, channel_id=channel_id, service_url=service_url, id=resource_response.activity_id diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_reference_extension.py b/libraries/botbuilder-core/botbuilder/core/conversation_reference_extension.py index 6dd4172e9..a04ce237d 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_reference_extension.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_reference_extension.py @@ -1,13 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import uuid -from botbuilder.schema import Activity, ActivityTypes, ConversationReference +from botbuilder.schema import ( + Activity, + ActivityEventNames, + ActivityTypes, + ConversationReference, +) def get_continuation_activity(reference: ConversationReference) -> Activity: return Activity( type=ActivityTypes.event, - name="ContinueConversation", + name=ActivityEventNames.continue_conversation, id=str(uuid.uuid1()), channel_id=reference.channel_id, service_url=reference.service_url, diff --git a/libraries/botbuilder-core/botbuilder/core/memory_transcript_store.py b/libraries/botbuilder-core/botbuilder/core/memory_transcript_store.py index e8953e0ae..325cf32f6 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_transcript_store.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_transcript_store.py @@ -6,6 +6,7 @@ from botbuilder.schema import Activity from .transcript_logger import PagedResult, TranscriptInfo, TranscriptStore + # pylint: disable=line-too-long class MemoryTranscriptStore(TranscriptStore): """This provider is most useful for simulating production storage when running locally against the @@ -59,7 +60,9 @@ async def get_transcript_activities( [ x for x in sorted( - transcript, key=lambda x: x.timestamp, reverse=False + transcript, + key=lambda x: x.timestamp or str(datetime.datetime.min), + reverse=False, ) if x.timestamp >= start_date ] @@ -72,9 +75,11 @@ async def get_transcript_activities( paged_result.items = [ x for x in sorted( - transcript, key=lambda x: x.timestamp, reverse=False + transcript, + key=lambda x: x.timestamp or datetime.datetime.min, + reverse=False, ) - if x.timestamp >= start_date + if (x.timestamp or datetime.datetime.min) >= start_date ][:20] if paged_result.items.count == 20: paged_result.continuation_token = paged_result.items[-1].id diff --git a/libraries/botbuilder-core/botbuilder/core/transcript_logger.py b/libraries/botbuilder-core/botbuilder/core/transcript_logger.py index 91aba2ac1..bfd838f24 100644 --- a/libraries/botbuilder-core/botbuilder/core/transcript_logger.py +++ b/libraries/botbuilder-core/botbuilder/core/transcript_logger.py @@ -9,7 +9,13 @@ from queue import Queue from abc import ABC, abstractmethod from typing import Awaitable, Callable, List -from botbuilder.schema import Activity, ActivityTypes, ConversationReference +from botbuilder.schema import ( + Activity, + ActivityEventNames, + ActivityTypes, + ChannelAccount, + ConversationReference, +) from .middleware_set import Middleware from .turn_context import TurnContext @@ -46,9 +52,17 @@ async def on_turn( activity = context.activity # Log incoming activity at beginning of turn if activity: + if not activity.from_property: + activity.from_property = ChannelAccount() if not activity.from_property.role: activity.from_property.role = "user" - await self.log_activity(transcript, copy.copy(activity)) + + # We should not log ContinueConversation events used by skills to initialize the middleware. + if not ( + context.activity.type == ActivityTypes.event + and context.activity.name == ActivityEventNames.continue_conversation + ): + await self.log_activity(transcript, copy.copy(activity)) # hook up onSend pipeline # pylint: disable=unused-argument diff --git a/libraries/botbuilder-core/tests/test_transcript_logger_middleware.py b/libraries/botbuilder-core/tests/test_transcript_logger_middleware.py new file mode 100644 index 000000000..2752043e5 --- /dev/null +++ b/libraries/botbuilder-core/tests/test_transcript_logger_middleware.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest + +from botbuilder.core import ( + MemoryTranscriptStore, + TranscriptLoggerMiddleware, + TurnContext, +) +from botbuilder.core.adapters import TestAdapter, TestFlow +from botbuilder.schema import Activity, ActivityEventNames, ActivityTypes + + +class TestTranscriptLoggerMiddleware(aiounittest.AsyncTestCase): + async def test_should_not_log_continue_conversation(self): + transcript_store = MemoryTranscriptStore() + conversation_id = "" + sut = TranscriptLoggerMiddleware(transcript_store) + + async def aux_logic(context: TurnContext): + nonlocal conversation_id + conversation_id = context.activity.conversation.id + + adapter = TestAdapter(aux_logic) + adapter.use(sut) + + continue_conversation_activity = Activity( + type=ActivityTypes.event, name=ActivityEventNames.continue_conversation + ) + + test_flow = TestFlow(None, adapter) + step1 = await test_flow.send("foo") + step2 = await step1.send("bar") + await step2.send(continue_conversation_activity) + + paged_result = await transcript_store.get_transcript_activities( + "test", conversation_id + ) + self.assertEqual( + len(paged_result.items), + 2, + "only the two message activities should be logged", + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index d2183f6eb..734d6d91c 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from ._models_py3 import Activity +from ._models_py3 import ActivityEventNames from ._models_py3 import AnimationCard from ._models_py3 import Attachment from ._models_py3 import AttachmentData @@ -74,6 +75,7 @@ __all__ = [ "Activity", + "ActivityEventNames", "AnimationCard", "Attachment", "AttachmentData", diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 151b6feb2..472ff51ce 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -3,10 +3,16 @@ from botbuilder.schema._connector_client_enums import ActivityTypes from datetime import datetime +from enum import Enum from msrest.serialization import Model from msrest.exceptions import HttpOperationError +class ActivityEventNames(str, Enum): + continue_conversation = "ContinueConversation" + create_conversation = "CreateConversation" + + class ConversationReference(Model): """An object relating to a particular point in a conversation. From 7f482393ca25eb2658c551d4275cf91dc7036e63 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 21 Oct 2020 13:36:25 -0500 Subject: [PATCH 592/616] Latest meeting participant API --- .../botbuilder/core/teams/teams_info.py | 4 +- .../botbuilder/schema/teams/__init__.py | 6 +- .../botbuilder/schema/teams/_models_py3.py | 90 +++++++------------ .../teams/operations/teams_operations.py | 4 +- 4 files changed, 41 insertions(+), 63 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index 3bbfd60af..6533f38d6 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -17,7 +17,7 @@ TeamsChannelData, TeamsChannelAccount, TeamsPagedMembersResult, - TeamsParticipantChannelAccount, + TeamsMeetingParticipant, ) @@ -189,7 +189,7 @@ async def get_meeting_participant( meeting_id: str = None, participant_id: str = None, tenant_id: str = None, - ) -> TeamsParticipantChannelAccount: + ) -> TeamsMeetingParticipant: meeting_id = ( meeting_id if meeting_id diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index 99e4771e7..75a454851 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -59,7 +59,8 @@ from ._models_py3 import TeamsPagedMembersResult from ._models_py3 import TenantInfo from ._models_py3 import TeamsMeetingInfo -from ._models_py3 import TeamsParticipantChannelAccount +from ._models_py3 import TeamsMeetingParticipant +from ._models_py3 import MeetingParticipantInfo __all__ = [ "AppBasedLinkQuery", @@ -120,5 +121,6 @@ "TeamsPagedMembersResult", "TenantInfo", "TeamsMeetingInfo", - "TeamsParticipantChannelAccount", + "TeamsMeetingParticipant", + "MeetingParticipantInfo", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 0ad27d5ba..98214bbd6 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -3,7 +3,6 @@ from msrest.serialization import Model from botbuilder.schema import ( - Activity, Attachment, ChannelAccount, PagedMembersResult, @@ -1970,75 +1969,52 @@ def __init__(self, *, id: str = None, **kwargs) -> None: self.id = id -class TeamsParticipantChannelAccount(TeamsChannelAccount): - """Teams participant channel account detailing user Azure Active Directory and meeting participant details. +class MeetingParticipantInfo(Model): + """Teams meeting participant details. - :param id: Channel id for the user or bot on this channel. - :type id: str - :param name: Display friendly name. - :type name: str - :param given_name: Given name part of the user name. - :type given_name: str - :param surname: Surname part of the user name. - :type surname: str - :param email: Email of the user. - :type email: str - :param user_principal_name: Unique user principal name. - :type user_principal_name: str - :param tenant_id: TenantId of the user. - :type tenant_id: str - :param user_role: UserRole of the user. - :type user_role: str - :param meeting_role: Role of the participant in the current meeting. - :type meeting_role: str + :param role: Role of the participant in the current meeting. + :type role: str :param in_meeting: True, if the participant is in the meeting. - :type in_meeting: str - :param conversation: Conversation Account for the meeting. - :type conversation: str + :type in_meeting: bool """ _attribute_map = { - "id": {"key": "id", "type": "str"}, - "name": {"key": "name", "type": "str"}, - "given_name": {"key": "givenName", "type": "str"}, - "surname": {"key": "surname", "type": "str"}, - "email": {"key": "email", "type": "str"}, - "aad_object_id": {"key": "objectId", "type": "str"}, - "user_principal_name": {"key": "userPrincipalName", "type": "str"}, - "tenant_id": {"key": "tenantId", "type": "str"}, - "user_role": {"key": "userRole", "type": "str"}, - "meeting_role": {"key": "meetingRole", "type": "str"}, + "role": {"key": "role", "type": "str"}, "in_meeting": {"key": "inMeeting", "type": "bool"}, + } + + def __init__(self, *, role: str = None, in_meeting: bool = None, **kwargs) -> None: + super(MeetingParticipantInfo, self).__init__(**kwargs) + self.role = role + self.in_meeting = in_meeting + + +class TeamsMeetingParticipant(Model): + """Teams participant channel account detailing user Azure Active Directory and meeting participant details. + + :param user: Teams Channel Account information for this meeting participant + :type user: TeamsChannelAccount + :param meeting: >Information specific to this participant in the specific meeting. + :type meeting: MeetingParticipantInfo + :param conversation: Conversation Account for the meeting. + :type conversation: ConversationAccount + """ + + _attribute_map = { + "user": {"key": "user", "type": "TeamsChannelAccount"}, + "meeting": {"key": "meeting", "type": "MeetingParticipantInfo"}, "conversation": {"key": "conversation", "type": "ConversationAccount"}, } def __init__( self, *, - id: str = None, - name: str = None, - given_name: str = None, - surname: str = None, - email: str = None, - aad_object_id: str = None, - user_principal_name: str = None, - tenant_id: str = None, - user_role: str = None, - meeting_role: str = None, - in_meeting: bool = None, + user: TeamsChannelAccount = None, + meeting: MeetingParticipantInfo = None, conversation: ConversationAccount = None, **kwargs ) -> None: - super(TeamsParticipantChannelAccount, self).__init__(**kwargs) - self.id = id - self.name = name - self.given_name = given_name - self.surname = surname - self.email = email - self.aad_object_id = aad_object_id - self.user_principal_name = user_principal_name - self.tenant_id = tenant_id - self.user_role = user_role - self.meeting_role = meeting_role - self.in_meeting = in_meeting + super(TeamsMeetingParticipant, self).__init__(**kwargs) + self.user = user + self.meeting = meeting self.conversation = conversation diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py index 29238a6fd..5c61086b0 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -168,7 +168,7 @@ def fetch_participant( deserialized response :param operation_config: :ref:`Operation configuration overrides`. - :return: TeamsParticipantChannelAccount or ClientRawResponse if raw=true + :return: TeamsMeetingParticipant or ClientRawResponse if raw=true :rtype: ~botframework.connector.teams.models.TeamsParticipantChannelAccount or ~msrest.pipeline.ClientRawResponse :raises: @@ -205,7 +205,7 @@ def fetch_participant( deserialized = None if response.status_code == 200: - deserialized = self._deserialize("TeamsParticipantChannelAccount", response) + deserialized = self._deserialize("TeamsMeetingParticipant", response) if raw: client_raw_response = ClientRawResponse(deserialized, response) From 63fd1735452501636af4680e210e2f398a79368d Mon Sep 17 00:00:00 2001 From: Michael Richardson Date: Thu, 22 Oct 2020 09:28:31 -0700 Subject: [PATCH 593/616] add on_teams_team_renamed "overload" --- .../core/teams/teams_activity_handler.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py index c96c60483..5b2673a22 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py @@ -529,7 +529,7 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): channel_data.channel, channel_data.team, turn_context ) if channel_data.event_type == "teamRenamed": - return await self.on_teams_team_renamed_activity( + return await self.on_teams_team_renamed( channel_data.team, turn_context ) if channel_data.event_type == "teamRestored": @@ -600,10 +600,27 @@ async def on_teams_team_hard_deleted( # pylint: disable=unused-argument """ return + async def on_teams_team_renamed( # pylint: disable=unused-argument + self, team_info: TeamInfo, turn_context: TurnContext + ): + """ + Invoked when a Team Renamed event activity is received from the connector. + Team Renamed correspond to the user renaming an existing team. + + :param team_info: The team info object representing the team. + :param turn_context: A context object for this turn. + + :returns: A task that represents the work queued to execute. + """ + return await self.on_teams_team_renamed_activity(team_info, turn_context) + async def on_teams_team_renamed_activity( # pylint: disable=unused-argument self, team_info: TeamInfo, turn_context: TurnContext ): """ + DEPRECATED. Please use on_teams_team_renamed(). This method will remain in place throughout + v4 so as not to break existing bots. + Invoked when a Team Renamed event activity is received from the connector. Team Renamed correspond to the user renaming an existing team. From 80438ef1d512db331d665edfa94d2035026b8927 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Thu, 22 Oct 2020 13:23:03 -0500 Subject: [PATCH 594/616] Teams CacheInfo (#1417) * Teams CacheInfo * CacheInfo on MessageExtensionActionResponse is now optional * black --- .../botbuilder/schema/teams/__init__.py | 2 + .../botbuilder/schema/teams/_models_py3.py | 50 +++++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index 75a454851..b6116a3ec 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -61,6 +61,7 @@ from ._models_py3 import TeamsMeetingInfo from ._models_py3 import TeamsMeetingParticipant from ._models_py3 import MeetingParticipantInfo +from ._models_py3 import CacheInfo __all__ = [ "AppBasedLinkQuery", @@ -123,4 +124,5 @@ "TeamsMeetingInfo", "TeamsMeetingParticipant", "MeetingParticipantInfo", + "CacheInfo", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 98214bbd6..e4d16baf8 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -71,6 +71,28 @@ def __init__(self, *, id: str = None, name: str = None, **kwargs) -> None: self.name = name +class CacheInfo(Model): + """A cache info object which notifies Teams how long an object should be cached for. + + :param cache_type: Type of Cache Info + :type cache_type: str + :param cache_duration: Duration of the Cached Info. + :type cache_duration: int + """ + + _attribute_map = { + "cache_type": {"key": "cacheType", "type": "str"}, + "cache_duration": {"key": "cacheDuration", "type": "int"}, + } + + def __init__( + self, *, cache_type: str = None, cache_duration: int = None, **kwargs + ) -> None: + super(CacheInfo, self).__init__(**kwargs) + self.cache_type = cache_type + self.cache_duration = cache_duration + + class ConversationList(Model): """List of channels under a team. @@ -699,6 +721,8 @@ class MessagingExtensionActionResponse(Model): :param compose_extension: :type compose_extension: ~botframework.connector.teams.models.MessagingExtensionResult + :param cache_info: CacheInfo for this MessagingExtensionActionResponse. + :type cache_info: ~botframework.connector.teams.models.CacheInfo """ _attribute_map = { @@ -707,12 +731,21 @@ class MessagingExtensionActionResponse(Model): "key": "composeExtension", "type": "MessagingExtensionResult", }, + "cache_info": {"key": "cacheInfo", "type": "CacheInfo"}, } - def __init__(self, *, task=None, compose_extension=None, **kwargs) -> None: + def __init__( + self, + *, + task=None, + compose_extension=None, + cache_info: CacheInfo = None, + **kwargs + ) -> None: super(MessagingExtensionActionResponse, self).__init__(**kwargs) self.task = task self.compose_extension = compose_extension + self.cache_info = cache_info class MessagingExtensionAttachment(Attachment): @@ -849,8 +882,9 @@ class MessagingExtensionResponse(Model): """Messaging extension response. :param compose_extension: - :type compose_extension: - ~botframework.connector.teams.models.MessagingExtensionResult + :type compose_extension: ~botframework.connector.teams.models.MessagingExtensionResult + :param cache_info: CacheInfo for this MessagingExtensionResponse. + :type cache_info: ~botframework.connector.teams.models.CacheInfo """ _attribute_map = { @@ -858,11 +892,13 @@ class MessagingExtensionResponse(Model): "key": "composeExtension", "type": "MessagingExtensionResult", }, + "cache_info": {"key": "cacheInfo", "type": CacheInfo}, } - def __init__(self, *, compose_extension=None, **kwargs) -> None: + def __init__(self, *, compose_extension=None, cache_info=None, **kwargs) -> None: super(MessagingExtensionResponse, self).__init__(**kwargs) self.compose_extension = compose_extension + self.cache_info = cache_info class MessagingExtensionResult(Model): @@ -1671,15 +1707,19 @@ class TaskModuleResponse(Model): :param task: The JSON for the Adaptive card to appear in the task module. :type task: ~botframework.connector.teams.models.TaskModuleResponseBase + :param cache_info: CacheInfo for this TaskModuleResponse. + :type cache_info: ~botframework.connector.teams.models.CacheInfo """ _attribute_map = { "task": {"key": "task", "type": "TaskModuleResponseBase"}, + "cache_info": {"key": "cacheInfo", "type": "CacheInfo"}, } - def __init__(self, *, task=None, **kwargs) -> None: + def __init__(self, *, task=None, cache_info=None, **kwargs) -> None: super(TaskModuleResponse, self).__init__(**kwargs) self.task = task + self.cache_info = cache_info class TaskModuleTaskInfo(Model): From c491636b2df31e074d211586b0204192fcaf58e1 Mon Sep 17 00:00:00 2001 From: johnataylor Date: Fri, 23 Oct 2020 15:35:33 -0700 Subject: [PATCH 595/616] update token validation issues (#1419) * update issues * black formatting --- .../botframework/connector/auth/emulator_validation.py | 4 ++++ .../botframework/connector/auth/skill_validation.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 0e2d7fcaa..b00b8e1cc 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -35,6 +35,10 @@ class EmulatorValidation: "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", # Auth for US Gov, 2.0 token "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", + # Auth for US Gov, 1.0 token + "https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + # Auth for US Gov, 2.0 token + "https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", ], audience=None, clock_tolerance=5 * 60, diff --git a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py index 71311c992..c868d6f62 100644 --- a/libraries/botframework-connector/botframework/connector/auth/skill_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/skill_validation.py @@ -32,6 +32,8 @@ class SkillValidation: "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", # Auth v3.2, 2.0 token "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", # Auth for US Gov, 1.0 token "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", # Auth for US Gov, 2.0 token + "https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", # Auth for US Gov, 1.0 token + "https://login.microsoftonline.us/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", # Auth for US Gov, 2.0 token ], audience=None, clock_tolerance=timedelta(minutes=5), From 23315efbbef9858e95a99fa3b64666fe5c9209fe Mon Sep 17 00:00:00 2001 From: BruceHaley Date: Mon, 26 Oct 2020 17:56:55 -0700 Subject: [PATCH 596/616] Add conditional on push to coveralls task to avoid forks (#1420) --- pipelines/botbuilder-python-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index a55083ff1..6388af8b3 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -91,6 +91,7 @@ jobs: - script: 'COVERALLS_REPO_TOKEN=$(PythonCoverallsToken) coveralls' displayName: 'Push test results to coveralls https://coveralls.io/github/microsoft/botbuilder-python' continueOnError: true + condition: and(succeeded(), eq(variables['System.PullRequest.IsFork'], 'false')) - powershell: | Set-Location .. From bfc48605d07a07f4a949194b19b68f7735de2f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Wed, 28 Oct 2020 10:55:26 -0700 Subject: [PATCH 597/616] Invoke with expected replies (#1427) --- .../botbuilder/core/turn_context.py | 9 +++++++++ .../botbuilder/dialogs/skills/skill_dialog.py | 14 +++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 9f719363e..b8799a02b 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -18,6 +18,10 @@ class TurnContext: + + # Same constant as in the BF Adapter, duplicating here to avoid circular dependency + _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" + def __init__(self, adapter_or_context, request: Activity = None): """ Creates a new TurnContext instance. @@ -202,6 +206,11 @@ async def logic(): responses = [] for activity in output: self.buffered_reply_activities.append(activity) + # Ensure the TurnState has the InvokeResponseKey, since this activity + # is not being sent through the adapter, where it would be added to TurnState. + if activity.type == ActivityTypes.invoke_response: + self.turn_state[TurnContext._INVOKE_RESPONSE_KEY] = activity + responses.append(ResourceResponse()) if sent_non_trace_activity: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py index 62fee1ace..119d1d62a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py @@ -244,6 +244,8 @@ async def _send_to_skill( # Process replies in the response.Body. response.body: List[Activity] response.body = ExpectedReplies().deserialize(response.body).activities + # Track sent invoke responses, so more than one is not sent. + sent_invoke_response = False for from_skill_activity in response.body: if from_skill_activity.type == ActivityTypes.end_of_conversation: @@ -254,12 +256,18 @@ async def _send_to_skill( await self.dialog_options.conversation_id_factory.delete_conversation_reference( skill_conversation_id ) - elif await self._intercept_oauth_cards( + elif not sent_invoke_response and await self._intercept_oauth_cards( context, from_skill_activity, self.dialog_options.connection_name ): - # do nothing. Token exchange succeeded, so no oauthcard needs to be shown to the user - pass + # Token exchange succeeded, so no oauthcard needs to be shown to the user + sent_invoke_response = True else: + # If an invoke response has already been sent we should ignore future invoke responses as this + # represents a bug in the skill. + if from_skill_activity.type == ActivityTypes.invoke_response: + if sent_invoke_response: + continue + sent_invoke_response = True # Send the response back to the channel. await context.send_activity(from_skill_activity) From 3c78b2b066e9782ff2ae0ee5ae226536cac82ada Mon Sep 17 00:00:00 2001 From: Emily Olshefski Date: Wed, 2 Dec 2020 06:52:27 -0800 Subject: [PATCH 598/616] Issue 1428 fix (#1435) * Update cosmosdb_partitioned_storage.py * Update cosmosdb_storage.py --- .../botbuilder/azure/cosmosdb_partitioned_storage.py | 7 ++----- .../botbuilder-azure/botbuilder/azure/cosmosdb_storage.py | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py index 93657bbed..db5ae1685 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_partitioned_storage.py @@ -1,7 +1,4 @@ -"""CosmosDB Middleware for Python Bot Framework. - -This is middleware to store items in CosmosDB. -Part of the Azure Bot Framework in Python. +"""Implements a CosmosDB based storage provider using partitioning for a bot. """ # Copyright (c) Microsoft Corporation. All rights reserved. @@ -67,7 +64,7 @@ def __init__( class CosmosDbPartitionedStorage(Storage): - """The class for partitioned CosmosDB middleware for the Azure Bot Framework.""" + """A CosmosDB based storage provider using partitioning for a bot.""" def __init__(self, config: CosmosDbPartitionedConfig): """Create the storage object. diff --git a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py index a5d01eea5..9a1c89d2e 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py +++ b/libraries/botbuilder-azure/botbuilder/azure/cosmosdb_storage.py @@ -1,7 +1,4 @@ -"""CosmosDB Middleware for Python Bot Framework. - -This is middleware to store items in CosmosDB. -Part of the Azure Bot Framework in Python. +"""Implements a CosmosDB based storage provider. """ # Copyright (c) Microsoft Corporation. All rights reserved. @@ -100,7 +97,7 @@ def truncate_key(key: str, compatibility_mode: bool = True) -> str: class CosmosDbStorage(Storage): - """The class for CosmosDB middleware for the Azure Bot Framework.""" + """A CosmosDB based storage provider for a bot.""" def __init__( self, config: CosmosDbConfig, client: cosmos_client.CosmosClient = None From 8493dc8860a0aebc51571a329a3eafaa185c8474 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Wed, 2 Dec 2020 09:08:26 -0600 Subject: [PATCH 599/616] Version bump to 4.12.0 (#1422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Version bump to 4.12.0 * Version bump in README Co-authored-by: Axel Suárez --- README.md | 2 +- .../botbuilder/adapters/slack/about.py | 2 +- libraries/botbuilder-adapters-slack/requirements.txt | 2 +- libraries/botbuilder-adapters-slack/setup.py | 6 +++--- libraries/botbuilder-ai/botbuilder/ai/about.py | 2 +- libraries/botbuilder-ai/requirements.txt | 4 ++-- libraries/botbuilder-ai/setup.py | 4 ++-- .../botbuilder/applicationinsights/about.py | 2 +- libraries/botbuilder-applicationinsights/requirements.txt | 2 +- libraries/botbuilder-applicationinsights/setup.py | 6 +++--- libraries/botbuilder-azure/botbuilder/azure/about.py | 2 +- libraries/botbuilder-azure/setup.py | 4 ++-- libraries/botbuilder-core/botbuilder/core/about.py | 2 +- libraries/botbuilder-core/requirements.txt | 4 ++-- libraries/botbuilder-core/setup.py | 6 +++--- libraries/botbuilder-dialogs/botbuilder/dialogs/about.py | 2 +- libraries/botbuilder-dialogs/requirements.txt | 6 +++--- libraries/botbuilder-dialogs/setup.py | 6 +++--- .../botbuilder/integration/aiohttp/about.py | 2 +- libraries/botbuilder-integration-aiohttp/requirements.txt | 4 ++-- libraries/botbuilder-integration-aiohttp/setup.py | 8 ++++---- .../integration/applicationinsights/aiohttp/about.py | 2 +- .../setup.py | 8 ++++---- libraries/botbuilder-schema/setup.py | 2 +- libraries/botbuilder-testing/botbuilder/testing/about.py | 2 +- libraries/botbuilder-testing/requirements.txt | 6 +++--- libraries/botbuilder-testing/setup.py | 6 +++--- libraries/botframework-connector/requirements.txt | 2 +- libraries/botframework-connector/setup.py | 4 ++-- 29 files changed, 55 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index cd32b704e..cbbb66577 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For more information jump to a section below. | Branch | Description | Build Status | Coverage Status | Code Style | |----|---------------|--------------|-----------------|--| -| Main | 4.11.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=main)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=main) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | +| Main | 4.12.* Preview Builds | [![Build Status](https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/Python-CI-PR-yaml?branchName=main)](https://fuselabs.visualstudio.com/SDK_v4/_build/latest?definitionId=771&branchName=main) | [![Coverage Status](https://coveralls.io/repos/github/microsoft/botbuilder-python/badge.svg?branch=HEAD)](https://coveralls.io/github/microsoft/botbuilder-python?branch=HEAD) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | ## Packages diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py index 3d082bf1e..405dd97ef 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-adapters-slack" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-adapters-slack/requirements.txt b/libraries/botbuilder-adapters-slack/requirements.txt index 21f25976c..69aba26a6 100644 --- a/libraries/botbuilder-adapters-slack/requirements.txt +++ b/libraries/botbuilder-adapters-slack/requirements.txt @@ -1,4 +1,4 @@ aiohttp==3.6.2 pyslack -botbuilder-core==4.10.0 +botbuilder-core==4.12.0 slackclient \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py index 42990d15b..0f121de69 100644 --- a/libraries/botbuilder-adapters-slack/setup.py +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", - "botbuilder-core==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", + "botbuilder-core==4.12.0", "pyslack", "slackclient", ] diff --git a/libraries/botbuilder-ai/botbuilder/ai/about.py b/libraries/botbuilder-ai/botbuilder/ai/about.py index dacddbf78..5439d7f89 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/about.py +++ b/libraries/botbuilder-ai/botbuilder/ai/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-ai" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index 3fc0566e9..2d1b061e8 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botbuilder-schema==4.10.0 -botbuilder-core==4.10.0 +botbuilder-schema==4.12.0 +botbuilder-core==4.12.0 requests==2.23.0 aiounittest==1.3.0 azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index 11cf15a35..79f39a121 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -6,8 +6,8 @@ REQUIRES = [ "azure-cognitiveservices-language-luis==0.2.0", - "botbuilder-schema==4.10.0", - "botbuilder-core==4.10.0", + "botbuilder-schema==4.12.0", + "botbuilder-core==4.12.0", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py index a23f9b305..841b3ba9a 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-applicationinsights" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-applicationinsights/requirements.txt b/libraries/botbuilder-applicationinsights/requirements.txt index ace87c47c..6c59cce95 100644 --- a/libraries/botbuilder-applicationinsights/requirements.txt +++ b/libraries/botbuilder-applicationinsights/requirements.txt @@ -1,3 +1,3 @@ msrest==0.6.10 -botbuilder-core==4.10.0 +botbuilder-core==4.12.0 aiounittest==1.3.0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 91de6d1bb..0f2706e9f 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -6,9 +6,9 @@ REQUIRES = [ "applicationinsights==0.11.9", - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", - "botbuilder-core==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", + "botbuilder-core==4.12.0", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-azure/botbuilder/azure/about.py b/libraries/botbuilder-azure/botbuilder/azure/about.py index bd82fa9c9..9052a0c03 100644 --- a/libraries/botbuilder-azure/botbuilder/azure/about.py +++ b/libraries/botbuilder-azure/botbuilder/azure/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-azure" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index 50ae09a60..48333f8d3 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -7,8 +7,8 @@ REQUIRES = [ "azure-cosmos==3.2.0", "azure-storage-blob==2.1.0", - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", "jsonpickle==1.2", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-core/botbuilder/core/about.py b/libraries/botbuilder-core/botbuilder/core/about.py index cff5f77f6..d8fbaf9f3 100644 --- a/libraries/botbuilder-core/botbuilder/core/about.py +++ b/libraries/botbuilder-core/botbuilder/core/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-core" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-core/requirements.txt b/libraries/botbuilder-core/requirements.txt index 06e8b3261..7395b26cf 100644 --- a/libraries/botbuilder-core/requirements.txt +++ b/libraries/botbuilder-core/requirements.txt @@ -1,6 +1,6 @@ msrest==0.6.10 -botframework-connector==4.10.0 -botbuilder-schema==4.10.0 +botframework-connector==4.12.0 +botbuilder-schema==4.12.0 requests==2.23.0 PyJWT==1.5.3 cryptography==2.8.0 diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index fd6e62a24..5a144f90b 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -4,10 +4,10 @@ import os from setuptools import setup -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" REQUIRES = [ - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", "jsonpickle==1.2", ] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py index e4a8063ac..7aa7b0a4f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-dialogs" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index afa25c24e..66a03f761 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -1,7 +1,7 @@ msrest==0.6.10 -botframework-connector==4.10.0 -botbuilder-schema==4.10.0 -botbuilder-core==4.10.0 +botframework-connector==4.12.0 +botbuilder-schema==4.12.0 +botbuilder-core==4.12.0 requests==2.23.0 PyJWT==1.5.3 cryptography==2.8 diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index a0719ef81..0525ca6f5 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -12,9 +12,9 @@ "recognizers-text>=1.0.2a1", "recognizers-text-choice>=1.0.2a1", "babel==2.7.0", - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", - "botbuilder-core==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", + "botbuilder-core==4.12.0", ] TEST_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py index eedd1bfdc..9ba957138 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/about.py @@ -5,7 +5,7 @@ __title__ = "botbuilder-integration-aiohttp" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-integration-aiohttp/requirements.txt b/libraries/botbuilder-integration-aiohttp/requirements.txt index d30921ea9..2d93ed698 100644 --- a/libraries/botbuilder-integration-aiohttp/requirements.txt +++ b/libraries/botbuilder-integration-aiohttp/requirements.txt @@ -1,4 +1,4 @@ msrest==0.6.10 -botframework-connector==4.10.0 -botbuilder-schema==4.10.0 +botframework-connector==4.12.0 +botbuilder-schema==4.12.0 aiohttp==3.6.2 diff --git a/libraries/botbuilder-integration-aiohttp/setup.py b/libraries/botbuilder-integration-aiohttp/setup.py index 7e8376ff3..f45e67ec8 100644 --- a/libraries/botbuilder-integration-aiohttp/setup.py +++ b/libraries/botbuilder-integration-aiohttp/setup.py @@ -4,11 +4,11 @@ import os from setuptools import setup -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" REQUIRES = [ - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", - "botbuilder-core==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", + "botbuilder-core==4.12.0", "aiohttp==3.6.2", ] diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py index 51c0f5598..0365e66df 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/botbuilder/integration/applicationinsights/aiohttp/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-integration-applicationinsights-aiohttp" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py index 979e3684a..33f018439 100644 --- a/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py +++ b/libraries/botbuilder-integration-applicationinsights-aiohttp/setup.py @@ -7,10 +7,10 @@ REQUIRES = [ "applicationinsights>=0.11.9", "aiohttp==3.6.2", - "botbuilder-schema==4.10.0", - "botframework-connector==4.10.0", - "botbuilder-core==4.10.0", - "botbuilder-applicationinsights==4.10.0", + "botbuilder-schema==4.12.0", + "botframework-connector==4.12.0", + "botbuilder-core==4.12.0", + "botbuilder-applicationinsights==4.12.0", ] TESTS_REQUIRES = [ "aiounittest==1.3.0", diff --git a/libraries/botbuilder-schema/setup.py b/libraries/botbuilder-schema/setup.py index 98cd8d7d9..06dc7339e 100644 --- a/libraries/botbuilder-schema/setup.py +++ b/libraries/botbuilder-schema/setup.py @@ -5,7 +5,7 @@ from setuptools import setup NAME = "botbuilder-schema" -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" REQUIRES = ["msrest==0.6.10"] root = os.path.abspath(os.path.dirname(__file__)) diff --git a/libraries/botbuilder-testing/botbuilder/testing/about.py b/libraries/botbuilder-testing/botbuilder/testing/about.py index 9688528e4..4817b2d45 100644 --- a/libraries/botbuilder-testing/botbuilder/testing/about.py +++ b/libraries/botbuilder-testing/botbuilder/testing/about.py @@ -6,7 +6,7 @@ __title__ = "botbuilder-testing" __version__ = ( - os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" ) __uri__ = "https://www.github.com/Microsoft/botbuilder-python" __author__ = "Microsoft" diff --git a/libraries/botbuilder-testing/requirements.txt b/libraries/botbuilder-testing/requirements.txt index d6350bd0c..18a973dc1 100644 --- a/libraries/botbuilder-testing/requirements.txt +++ b/libraries/botbuilder-testing/requirements.txt @@ -1,4 +1,4 @@ -botbuilder-schema==4.10.0 -botbuilder-core==4.10.0 -botbuilder-dialogs==4.10.0 +botbuilder-schema==4.12.0 +botbuilder-core==4.12.0 +botbuilder-dialogs==4.12.0 aiounittest==1.3.0 diff --git a/libraries/botbuilder-testing/setup.py b/libraries/botbuilder-testing/setup.py index 21cb2f684..a10601cf9 100644 --- a/libraries/botbuilder-testing/setup.py +++ b/libraries/botbuilder-testing/setup.py @@ -5,9 +5,9 @@ from setuptools import setup REQUIRES = [ - "botbuilder-schema==4.10.0", - "botbuilder-core==4.10.0", - "botbuilder-dialogs==4.10.0", + "botbuilder-schema==4.12.0", + "botbuilder-core==4.12.0", + "botbuilder-dialogs==4.12.0", ] TESTS_REQUIRES = ["aiounittest==1.3.0"] diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 1d47eebff..88950fa3b 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -1,5 +1,5 @@ msrest==0.6.10 -botbuilder-schema==4.10.0 +botbuilder-schema==4.12.0 requests==2.23.0 PyJWT==1.5.3 cryptography==2.8.0 diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index fc3fc82e1..d046e8dd6 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -4,13 +4,13 @@ from setuptools import setup NAME = "botframework-connector" -VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.10.0" +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.12.0" REQUIRES = [ "msrest==0.6.10", "requests==2.23.0", "cryptography==2.8.0", "PyJWT==1.5.3", - "botbuilder-schema==4.10.0", + "botbuilder-schema==4.12.0", "adal==1.2.1", "msal==1.2.0", ] From 1ce420b7a15bc6464cac333c2bb938d6ed1a2752 Mon Sep 17 00:00:00 2001 From: Ashley Finafrock <35248895+Zerryth@users.noreply.github.com> Date: Wed, 2 Dec 2020 07:32:48 -0800 Subject: [PATCH 600/616] Only reassign waterfall step name only if the step doesn't have a name (#1431) * Only reassign step name if waterfall step has no name + unit test * run black and pylint Co-authored-by: tracyboehrer --- .../tests/test_telemetry_waterfall.py | 51 ++++++++++++++++--- .../botbuilder/dialogs/waterfall_dialog.py | 2 +- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index c1ab6e261..31f10527c 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from unittest.mock import MagicMock +from unittest.mock import create_autospec, MagicMock from typing import Dict import aiounittest from botbuilder.core.adapters import TestAdapter, TestFlow @@ -14,6 +14,8 @@ ) from botbuilder.dialogs import ( Dialog, + DialogInstance, + DialogReason, DialogSet, WaterfallDialog, DialogTurnResult, @@ -83,11 +85,10 @@ async def exec_test(turn_context: TurnContext) -> None: await tf4.assert_reply("ending WaterfallDialog.") # assert - telemetry_calls = [ ("WaterfallStart", {"DialogId": "test"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step2of2"}), + ("WaterfallStep", {"DialogId": "test", "StepName": step1.__qualname__}), + ("WaterfallStep", {"DialogId": "test", "StepName": step2.__qualname__}), ] self.assert_telemetry_calls(telemetry, telemetry_calls) @@ -138,15 +139,49 @@ async def exec_test(turn_context: TurnContext) -> None: # assert telemetry_calls = [ ("WaterfallStart", {"DialogId": "test"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step2of2"}), + ("WaterfallStep", {"DialogId": "test", "StepName": step1.__qualname__}), + ("WaterfallStep", {"DialogId": "test", "StepName": step2.__qualname__}), ("WaterfallComplete", {"DialogId": "test"}), ("WaterfallStart", {"DialogId": "test"}), - ("WaterfallStep", {"DialogId": "test", "StepName": "Step1of2"}), + ("WaterfallStep", {"DialogId": "test", "StepName": step1.__qualname__}), ] - print(str(telemetry.track_event.call_args_list)) self.assert_telemetry_calls(telemetry, telemetry_calls) + async def test_cancelling_waterfall_telemetry(self): + # Arrange + dialog_id = "waterfall" + index = 0 + guid = "(guid)" + + async def my_waterfall_step(step) -> DialogTurnResult: + await step.context.send_activity("step1 response") + return Dialog.end_of_turn + + dialog = WaterfallDialog(dialog_id, [my_waterfall_step]) + + telemetry_client = create_autospec(NullTelemetryClient) + dialog.telemetry_client = telemetry_client + + dialog_instance = DialogInstance() + dialog_instance.id = dialog_id + dialog_instance.state = {"instanceId": guid, "stepIndex": index} + + # Act + await dialog.end_dialog( + TurnContext(TestAdapter(), Activity()), + dialog_instance, + DialogReason.CancelCalled, + ) + + # Assert + telemetry_props = telemetry_client.track_event.call_args_list[0][0][1] + + self.assertEqual(3, len(telemetry_props)) + self.assertEqual(dialog_id, telemetry_props["DialogId"]) + self.assertEqual(my_waterfall_step.__qualname__, telemetry_props["StepName"]) + self.assertEqual(guid, telemetry_props["InstanceId"]) + telemetry_client.track_event.assert_called_once() + def assert_telemetry_call( self, telemetry_mock, index: int, event_name: str, props: Dict[str, str] ) -> None: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index bced214fb..570b5b340 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -164,7 +164,7 @@ def get_step_name(self, index: int) -> str: """ step_name = self._steps[index].__qualname__ - if not step_name or ">" in step_name: + if not step_name or step_name.endswith(""): step_name = f"Step{index + 1}of{len(self._steps)}" return step_name From 2474e6e5155f8d887277cf56752470b39fa7ea84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Dec 2020 08:59:17 -0800 Subject: [PATCH 601/616] Bump cryptography from 2.8 to 3.2 in /libraries/botbuilder-dialogs (#1426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [cryptography](https://github.com/pyca/cryptography) from 2.8 to 3.2. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/2.8...3.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: tracyboehrer Co-authored-by: Axel Suárez --- libraries/botbuilder-dialogs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index 66a03f761..fb56a55b1 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -4,5 +4,5 @@ botbuilder-schema==4.12.0 botbuilder-core==4.12.0 requests==2.23.0 PyJWT==1.5.3 -cryptography==2.8 +cryptography==3.2 aiounittest==1.3.0 From 468ea935cf55c8fe38ee9f60b8bf01c8f9f57d64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Dec 2020 10:00:07 -0800 Subject: [PATCH 602/616] Bump cryptography from 2.8.0 to 3.2 in /libraries/botbuilder-core (#1425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [cryptography](https://github.com/pyca/cryptography) from 2.8.0 to 3.2. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/2.8...3.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: tracyboehrer Co-authored-by: Axel Suárez --- libraries/botbuilder-core/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/requirements.txt b/libraries/botbuilder-core/requirements.txt index 7395b26cf..04934948c 100644 --- a/libraries/botbuilder-core/requirements.txt +++ b/libraries/botbuilder-core/requirements.txt @@ -3,5 +3,5 @@ botframework-connector==4.12.0 botbuilder-schema==4.12.0 requests==2.23.0 PyJWT==1.5.3 -cryptography==2.8.0 +cryptography==3.2 aiounittest==1.3.0 \ No newline at end of file From eaa65f8c1f17d43b0691f1093617df01d47f01d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Dec 2020 10:19:01 -0800 Subject: [PATCH 603/616] Bump cryptography from 2.8.0 to 3.2 in /libraries/botframework-connector (#1424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [cryptography](https://github.com/pyca/cryptography) from 2.8.0 to 3.2. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/2.8...3.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: tracyboehrer Co-authored-by: Axel Suárez --- libraries/botframework-connector/requirements.txt | 2 +- libraries/botframework-connector/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botframework-connector/requirements.txt b/libraries/botframework-connector/requirements.txt index 88950fa3b..d6fa1a0d1 100644 --- a/libraries/botframework-connector/requirements.txt +++ b/libraries/botframework-connector/requirements.txt @@ -2,5 +2,5 @@ msrest==0.6.10 botbuilder-schema==4.12.0 requests==2.23.0 PyJWT==1.5.3 -cryptography==2.8.0 +cryptography==3.2 msal==1.2.0 diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index d046e8dd6..04bf09257 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -8,7 +8,7 @@ REQUIRES = [ "msrest==0.6.10", "requests==2.23.0", - "cryptography==2.8.0", + "cryptography==3.2", "PyJWT==1.5.3", "botbuilder-schema==4.12.0", "adal==1.2.1", From c33faad9a328340934c5388a0aa0a08fb1e0f9da Mon Sep 17 00:00:00 2001 From: Josh Gummersall <1235378+joshgummersall@users.noreply.github.com> Date: Wed, 9 Dec 2020 15:41:50 -0800 Subject: [PATCH 604/616] feat: create parity issue workflow (#1439) --- .github/workflows/create-parity-issue.yml | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/create-parity-issue.yml diff --git a/.github/workflows/create-parity-issue.yml b/.github/workflows/create-parity-issue.yml new file mode 100644 index 000000000..51f47a190 --- /dev/null +++ b/.github/workflows/create-parity-issue.yml @@ -0,0 +1,43 @@ +name: create-parity-issue.yml + +on: + workflow_dispatch: + inputs: + prDescription: + description: PR description + default: 'No description provided' + required: true + prNumber: + description: PR number + required: true + prTitle: + description: PR title + required: true + sourceRepo: + description: repository PR is sourced from + required: true + +jobs: + createIssue: + name: create issue + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: joshgummersall/create-issue@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + title: | + port: ${{ github.event.inputs.prTitle }} (#${{ github.event.inputs.prNumber }}) + labels: | + ["parity", "needs-triage", "ExemptFromDailyDRIReport"] + body: | + The changes in [${{ github.event.inputs.prTitle }} (#${{ github.event.inputs.prNumber }})](https://github.com/${{ github.event.inputs.sourceRepo }}/pull/${{ github.event.inputs.prNumber }}) may need to be ported to maintain parity with `${{ github.event.inputs.sourceRepo }}`. + +
+ ${{ github.event.inputs.prDescription }} +
+ + Please review and, if necessary, port the changes. From d00c0e11544538c2b8f1f48caa2ec238c9c31202 Mon Sep 17 00:00:00 2001 From: Ashley Finafrock <35248895+Zerryth@users.noreply.github.com> Date: Thu, 10 Dec 2020 14:35:38 -0800 Subject: [PATCH 605/616] Add OR Operation to QnAMaker GenerateAnswer for StrictFilter Behavioral Option (#1429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added JoinOperator and documentation on QnA options * Finished writing unit tests * Added newline at end of file * Updated file name * Snake case method name in tests * Access call_arg values as tuple in unit tests Co-authored-by: tracyboehrer Co-authored-by: Axel Suárez --- .../botbuilder/ai/qna/models/__init__.py | 2 + .../models/generate_answer_request_body.py | 7 ++ .../botbuilder/ai/qna/models/join_operator.py | 21 +++++ .../botbuilder/ai/qna/qnamaker_options.py | 37 ++++++++ .../ai/qna/utils/generate_answer_utils.py | 4 + ...sAnswer_WithStrictFilter_And_Operator.json | 29 ++++++ ...nsAnswer_WithStrictFilter_Or_Operator.json | 76 ++++++++++++++++ libraries/botbuilder-ai/tests/qna/test_qna.py | 91 +++++++++++++++++++ 8 files changed, 267 insertions(+) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py index 018d40c95..608ffeef1 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py @@ -8,6 +8,7 @@ from .feedback_record import FeedbackRecord from .feedback_records import FeedbackRecords from .generate_answer_request_body import GenerateAnswerRequestBody +from .join_operator import JoinOperator from .metadata import Metadata from .prompt import Prompt from .qnamaker_trace_info import QnAMakerTraceInfo @@ -21,6 +22,7 @@ "FeedbackRecord", "FeedbackRecords", "GenerateAnswerRequestBody", + "JoinOperator", "Metadata", "Prompt", "QnAMakerTraceInfo", diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py index 4a4e9fdd7..dd4104185 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py @@ -16,6 +16,10 @@ class GenerateAnswerRequestBody(Model): "qna_id": {"key": "qnaId", "type": "int"}, "is_test": {"key": "isTest", "type": "bool"}, "ranker_type": {"key": "rankerType", "type": "RankerTypes"}, + "strict_filters_join_operator": { + "key": "strictFiltersCompoundOperationType", + "type": "str", + }, } def __init__(self, **kwargs): @@ -28,3 +32,6 @@ def __init__(self, **kwargs): self.qna_id = kwargs.get("qna_id", None) self.is_test = kwargs.get("is_test", None) self.ranker_type = kwargs.get("ranker_type", None) + self.strict_filters_join_operator = kwargs.get( + "strict_filters_join_operator", None + ) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py new file mode 100644 index 000000000..a454afa81 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class JoinOperator(str, Enum): + """ + Join Operator for Strict Filters. + + remarks: + -------- + For example, when using multiple filters in a query, if you want results that + have metadata that matches all filters, then use `AND` operator. + + If instead you only wish that the results from knowledge base match + at least one of the filters, then use `OR` operator. + """ + + AND = "AND" + OR = "OR" diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py index 95ae70b81..af4a4ad1c 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -3,9 +3,18 @@ from .models import Metadata, QnARequestContext from .models.ranker_types import RankerTypes +from .models.join_operator import JoinOperator class QnAMakerOptions: + """ + Defines options used to configure a `QnAMaker` instance. + + remarks: + -------- + All parameters are optional. + """ + def __init__( self, score_threshold: float = 0.0, @@ -16,7 +25,34 @@ def __init__( qna_id: int = None, is_test: bool = False, ranker_type: str = RankerTypes.DEFAULT, + strict_filters_join_operator: str = JoinOperator.AND, ): + """ + Parameters: + ----------- + score_threshold (float): + The minimum score threshold, used to filter returned results. + Values range from score of 0.0 to 1.0. + timeout (int): + The time in milliseconds to wait before the request times out. + top (int): + The number of ranked results to return. + strict_filters ([Metadata]): + Filters to use on queries to a QnA knowledge base, based on a + QnA pair's metadata. + context ([QnARequestContext]): + The context of the previous turn. + qna_id (int): + Id of the current question asked (if available). + is_test (bool): + A value indicating whether to call test or prod environment of a knowledge base. + ranker_type (str): + The QnA ranker type to use. + strict_filters_join_operator (str): + A value indicating how strictly you want to apply strict_filters on QnA pairs' metadata. + For example, when combining several metadata filters, you can determine if you are + concerned with all filters matching or just at least one filter matching. + """ self.score_threshold = score_threshold self.timeout = timeout self.top = top @@ -25,3 +61,4 @@ def __init__( self.qna_id = qna_id self.is_test = is_test self.ranker_type = ranker_type + self.strict_filters_join_operator = strict_filters_join_operator diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py index b12c492c7..1f335f9e6 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py @@ -144,6 +144,9 @@ def _hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: hydrated_options.qna_id = query_options.qna_id hydrated_options.is_test = query_options.is_test hydrated_options.ranker_type = query_options.ranker_type + hydrated_options.strict_filters_join_operator = ( + query_options.strict_filters_join_operator + ) return hydrated_options @@ -161,6 +164,7 @@ async def _query_qna_service( qna_id=options.qna_id, is_test=options.is_test, ranker_type=options.ranker_type, + strict_filters_join_operator=options.strict_filters_join_operator, ) http_request_helper = HttpRequestUtils(self._http_client) diff --git a/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json new file mode 100644 index 000000000..1bb54754a --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json @@ -0,0 +1,29 @@ +{ + "answers": [ + { + "questions": [ + "Where can you find Misty", + "Misty" + ], + "answer": "Wherever people are having a swimming good time", + "score": 74.51, + "id": 27, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "human" + }, + { + "name": "type", + "value": "water" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + } + ], + "activeLearningEnabled": true +} diff --git a/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json new file mode 100644 index 000000000..3346464fc --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json @@ -0,0 +1,76 @@ +{ + "answers": [ + { + "questions": [ + "Where can you find Squirtle" + ], + "answer": "Did you not see him in the first three balls?", + "score": 80.22, + "id": 28, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "turtle" + }, + { + "name": "type", + "value": "water" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + }, + { + "questions": [ + "Where can you find Ash", + "Ash" + ], + "answer": "I don't know. Maybe ask your little electric mouse friend?", + "score": 63.74, + "id": 26, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "human" + }, + { + "name": "type", + "value": "miscellaneous" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + }, + { + "questions": [ + "Where can you find Misty", + "Misty" + ], + "answer": "Wherever people are having a swimming good time", + "score": 31.13, + "id": 27, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "human" + }, + { + "name": "type", + "value": "water" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + } + ], + "activeLearningEnabled": true +} diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index e733b6564..236594ac0 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -17,6 +17,7 @@ from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions from botbuilder.ai.qna.models import ( FeedbackRecord, + JoinOperator, Metadata, QueryResult, QnARequestContext, @@ -167,6 +168,96 @@ async def test_active_learning_enabled_status(self): self.assertEqual(1, len(result.answers)) self.assertFalse(result.active_learning_enabled) + async def test_returns_answer_with_strict_filters_with_or_operator(self): + # Arrange + question: str = "Where can you find" + response_path: str = "RetrunsAnswer_WithStrictFilter_Or_Operator.json" + response_json = QnaApplicationTest._get_json_for_file(response_path) + + strict_filters = [ + Metadata(name="species", value="human"), + Metadata(name="type", value="water"), + ] + options = QnAMakerOptions( + top=5, + strict_filters=strict_filters, + strict_filters_join_operator=JoinOperator.OR, + ) + qna = QnAMaker(endpoint=QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(question, TestAdapter()) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ) as mock_http_client: + result = await qna.get_answers_raw(context, options) + + serialized_http_req_args = mock_http_client.call_args[1]["data"] + req_args = json.loads(serialized_http_req_args) + + # Assert + self.assertIsNotNone(result) + self.assertEqual(3, len(result.answers)) + self.assertEqual( + JoinOperator.OR, req_args["strictFiltersCompoundOperationType"] + ) + + req_args_strict_filters = req_args["strictFilters"] + + first_filter = strict_filters[0] + self.assertEqual(first_filter.name, req_args_strict_filters[0]["name"]) + self.assertEqual(first_filter.value, req_args_strict_filters[0]["value"]) + + second_filter = strict_filters[1] + self.assertEqual(second_filter.name, req_args_strict_filters[1]["name"]) + self.assertEqual(second_filter.value, req_args_strict_filters[1]["value"]) + + async def test_returns_answer_with_strict_filters_with_and_operator(self): + # Arrange + question: str = "Where can you find" + response_path: str = "RetrunsAnswer_WithStrictFilter_And_Operator.json" + response_json = QnaApplicationTest._get_json_for_file(response_path) + + strict_filters = [ + Metadata(name="species", value="human"), + Metadata(name="type", value="water"), + ] + options = QnAMakerOptions( + top=5, + strict_filters=strict_filters, + strict_filters_join_operator=JoinOperator.AND, + ) + qna = QnAMaker(endpoint=QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(question, TestAdapter()) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ) as mock_http_client: + result = await qna.get_answers_raw(context, options) + + serialized_http_req_args = mock_http_client.call_args[1]["data"] + req_args = json.loads(serialized_http_req_args) + + # Assert + self.assertIsNotNone(result) + self.assertEqual(1, len(result.answers)) + self.assertEqual( + JoinOperator.AND, req_args["strictFiltersCompoundOperationType"] + ) + + req_args_strict_filters = req_args["strictFilters"] + + first_filter = strict_filters[0] + self.assertEqual(first_filter.name, req_args_strict_filters[0]["name"]) + self.assertEqual(first_filter.value, req_args_strict_filters[0]["value"]) + + second_filter = strict_filters[1] + self.assertEqual(second_filter.name, req_args_strict_filters[1]["name"]) + self.assertEqual(second_filter.value, req_args_strict_filters[1]["value"]) + async def test_returns_answer_using_requests_module(self): question: str = "how do I clean the stove?" response_path: str = "ReturnsAnswer.json" From 5f5804574f457cf8c13af2b58c9d2acf7c4ec8a2 Mon Sep 17 00:00:00 2001 From: Denise Scollo Date: Mon, 14 Dec 2020 13:47:00 -0300 Subject: [PATCH 606/616] [#1218] [PORT] Emit better error messages for all dialogs (#1433) * [PORT] Emit better error messages for all dialogs * Add pylint: disable=no-member for Exception.data property * Remove unused trailing-whitespace * Fix black formating Co-authored-by: Joel Mut Co-authored-by: Cecilia Avila <44245136+ceciliaavila@users.noreply.github.com> --- .../botbuilder/dialogs/dialog_context.py | 214 +++++++++++------- .../tests/test_activity_prompt.py | 88 +++++++ 2 files changed, 222 insertions(+), 80 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index b081cdea5..f79ef8e3c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -69,26 +69,30 @@ async def begin_dialog(self, dialog_id: str, options: object = None): :param dialog_id: ID of the dialog to start :param options: (Optional) additional argument(s) to pass to the dialog being started. """ - if not dialog_id: - raise TypeError("Dialog(): dialogId cannot be None.") - # Look up dialog - dialog = await self.find_dialog(dialog_id) - if dialog is None: - raise Exception( - "'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found." - " The dialog must be included in the current or parent DialogSet." - " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." - % dialog_id - ) - # Push new instance onto stack - instance = DialogInstance() - instance.id = dialog_id - instance.state = {} - - self._stack.insert(0, (instance)) - - # Call dialog's begin_dialog() method - return await dialog.begin_dialog(self, options) + try: + if not dialog_id: + raise TypeError("Dialog(): dialogId cannot be None.") + # Look up dialog + dialog = await self.find_dialog(dialog_id) + if dialog is None: + raise Exception( + "'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found." + " The dialog must be included in the current or parent DialogSet." + " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." + % dialog_id + ) + # Push new instance onto stack + instance = DialogInstance() + instance.id = dialog_id + instance.state = {} + + self._stack.insert(0, (instance)) + + # Call dialog's begin_dialog() method + return await dialog.begin_dialog(self, options) + except Exception as err: + self.__set_exception_context_data(err) + raise # TODO: Fix options: PromptOptions instead of object async def prompt(self, dialog_id: str, options) -> DialogTurnResult: @@ -99,13 +103,17 @@ async def prompt(self, dialog_id: str, options) -> DialogTurnResult: :param options: Contains a Prompt, potentially a RetryPrompt and if using ChoicePrompt, Choices. :return: """ - if not dialog_id: - raise TypeError("DialogContext.prompt(): dialogId cannot be None.") + try: + if not dialog_id: + raise TypeError("DialogContext.prompt(): dialogId cannot be None.") - if not options: - raise TypeError("DialogContext.prompt(): options cannot be None.") + if not options: + raise TypeError("DialogContext.prompt(): options cannot be None.") - return await self.begin_dialog(dialog_id, options) + return await self.begin_dialog(dialog_id, options) + except Exception as err: + self.__set_exception_context_data(err) + raise async def continue_dialog(self): """ @@ -114,20 +122,25 @@ async def continue_dialog(self): to determine if a dialog was run and a reply was sent to the user. :return: """ - # Check for a dialog on the stack - if self.active_dialog is not None: - # Look up dialog - dialog = await self.find_dialog(self.active_dialog.id) - if not dialog: - raise Exception( - "DialogContext.continue_dialog(): Can't continue dialog. A dialog with an id of '%s' wasn't found." - % self.active_dialog.id - ) - - # Continue execution of dialog - return await dialog.continue_dialog(self) - - return DialogTurnResult(DialogTurnStatus.Empty) + try: + # Check for a dialog on the stack + if self.active_dialog is not None: + # Look up dialog + dialog = await self.find_dialog(self.active_dialog.id) + if not dialog: + raise Exception( + "DialogContext.continue_dialog(): Can't continue dialog. " + "A dialog with an id of '%s' wasn't found." + % self.active_dialog.id + ) + + # Continue execution of dialog + return await dialog.continue_dialog(self) + + return DialogTurnResult(DialogTurnStatus.Empty) + except Exception as err: + self.__set_exception_context_data(err) + raise # TODO: instance is DialogInstance async def end_dialog(self, result: object = None): @@ -142,22 +155,27 @@ async def end_dialog(self, result: object = None): :param result: (Optional) result to pass to the parent dialogs. :return: """ - await self.end_active_dialog(DialogReason.EndCalled) - - # Resume previous dialog - if self.active_dialog is not None: - # Look up dialog - dialog = await self.find_dialog(self.active_dialog.id) - if not dialog: - raise Exception( - "DialogContext.EndDialogAsync(): Can't resume previous dialog." - " A dialog with an id of '%s' wasn't found." % self.active_dialog.id - ) - - # Return result to previous dialog - return await dialog.resume_dialog(self, DialogReason.EndCalled, result) - - return DialogTurnResult(DialogTurnStatus.Complete, result) + try: + await self.end_active_dialog(DialogReason.EndCalled) + + # Resume previous dialog + if self.active_dialog is not None: + # Look up dialog + dialog = await self.find_dialog(self.active_dialog.id) + if not dialog: + raise Exception( + "DialogContext.EndDialogAsync(): Can't resume previous dialog." + " A dialog with an id of '%s' wasn't found." + % self.active_dialog.id + ) + + # Return result to previous dialog + return await dialog.resume_dialog(self, DialogReason.EndCalled, result) + + return DialogTurnResult(DialogTurnStatus.Complete, result) + except Exception as err: + self.__set_exception_context_data(err) + raise async def cancel_all_dialogs(self): """ @@ -165,12 +183,16 @@ async def cancel_all_dialogs(self): :param result: (Optional) result to pass to the parent dialogs. :return: """ - if self.stack: - while self.stack: - await self.end_active_dialog(DialogReason.CancelCalled) - return DialogTurnResult(DialogTurnStatus.Cancelled) + try: + if self.stack: + while self.stack: + await self.end_active_dialog(DialogReason.CancelCalled) + return DialogTurnResult(DialogTurnStatus.Cancelled) - return DialogTurnResult(DialogTurnStatus.Empty) + return DialogTurnResult(DialogTurnStatus.Empty) + except Exception as err: + self.__set_exception_context_data(err) + raise async def find_dialog(self, dialog_id: str) -> Dialog: """ @@ -179,11 +201,15 @@ async def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: ID of the dialog to search for. :return: """ - dialog = await self.dialogs.find(dialog_id) + try: + dialog = await self.dialogs.find(dialog_id) - if dialog is None and self.parent is not None: - dialog = await self.parent.find_dialog(dialog_id) - return dialog + if dialog is None and self.parent is not None: + dialog = await self.parent.find_dialog(dialog_id) + return dialog + except Exception as err: + self.__set_exception_context_data(err) + raise async def replace_dialog( self, dialog_id: str, options: object = None @@ -195,29 +221,37 @@ async def replace_dialog( :param options: (Optional) additional argument(s) to pass to the new dialog. :return: """ - # End the current dialog and giving the reason. - await self.end_active_dialog(DialogReason.ReplaceCalled) + try: + # End the current dialog and giving the reason. + await self.end_active_dialog(DialogReason.ReplaceCalled) - # Start replacement dialog - return await self.begin_dialog(dialog_id, options) + # Start replacement dialog + return await self.begin_dialog(dialog_id, options) + except Exception as err: + self.__set_exception_context_data(err) + raise async def reprompt_dialog(self): """ Calls reprompt on the currently active dialog, if there is one. Used with Prompts that have a reprompt behavior. :return: """ - # Check for a dialog on the stack - if self.active_dialog is not None: - # Look up dialog - dialog = await self.find_dialog(self.active_dialog.id) - if not dialog: - raise Exception( - "DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." - % self.active_dialog.id - ) - - # Ask dialog to re-prompt if supported - await dialog.reprompt_dialog(self.context, self.active_dialog) + try: + # Check for a dialog on the stack + if self.active_dialog is not None: + # Look up dialog + dialog = await self.find_dialog(self.active_dialog.id) + if not dialog: + raise Exception( + "DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." + % self.active_dialog.id + ) + + # Ask dialog to re-prompt if supported + await dialog.reprompt_dialog(self.context, self.active_dialog) + except Exception as err: + self.__set_exception_context_data(err) + raise async def end_active_dialog(self, reason: DialogReason): instance = self.active_dialog @@ -230,3 +264,23 @@ async def end_active_dialog(self, reason: DialogReason): # Pop dialog off stack self._stack.pop(0) + + def __set_exception_context_data(self, exception: Exception): + if not hasattr(exception, "data"): + exception.data = {} + + if not type(self).__name__ in exception.data: + stack = [] + current_dc = self + + while current_dc is not None: + stack = stack + [x.id for x in current_dc.stack] + current_dc = current_dc.parent + + exception.data[type(self).__name__] = { + "active_dialog": None + if self.active_dialog is None + else self.active_dialog.id, + "parent": None if self.parent is None else self.parent.active_dialog.id, + "stack": self.stack, + } diff --git a/libraries/botbuilder-dialogs/tests/test_activity_prompt.py b/libraries/botbuilder-dialogs/tests/test_activity_prompt.py index ab8fa4971..2f2019c91 100644 --- a/libraries/botbuilder-dialogs/tests/test_activity_prompt.py +++ b/libraries/botbuilder-dialogs/tests/test_activity_prompt.py @@ -215,3 +215,91 @@ async def aux_validator(prompt_context: PromptValidatorContext): step1 = await adapter.send("hello") step2 = await step1.assert_reply("please send an event.") await step2.assert_reply("please send an event.") + + async def test_activity_prompt_onerror_should_return_dialogcontext(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and AttachmentPrompt. + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + + try: + await dialog_context.prompt("EventActivityPrompt", options) + await dialog_context.prompt("Non existent id", options) + except Exception as err: + self.assertIsNotNone( + err.data["DialogContext"] # pylint: disable=no-member + ) + self.assertEqual( + err.data["DialogContext"][ # pylint: disable=no-member + "active_dialog" + ], + "EventActivityPrompt", + ) + else: + raise Exception("Should have thrown an error.") + + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save_changes(turn_context) + + # Initialize TestAdapter. + adapter = TestAdapter(exec_test) + + await adapter.send("hello") + + async def test_activity_replace_dialog_onerror_should_return_dialogcontext(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and AttachmentPrompt. + dialog_state = convo_state.create_property("dialog_state") + dialogs = DialogSet(dialog_state) + dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator)) + + async def exec_test(turn_context: TurnContext): + dialog_context = await dialogs.create_context(turn_context) + + results = await dialog_context.continue_dialog() + + if results.status == DialogTurnStatus.Empty: + options = PromptOptions( + prompt=Activity( + type=ActivityTypes.message, text="please send an event." + ) + ) + + try: + await dialog_context.prompt("EventActivityPrompt", options) + await dialog_context.replace_dialog("Non existent id", options) + except Exception as err: + self.assertIsNotNone( + err.data["DialogContext"] # pylint: disable=no-member + ) + else: + raise Exception("Should have thrown an error.") + + elif results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + + await convo_state.save_changes(turn_context) + + # Initialize TestAdapter. + adapter = TestAdapter(exec_test) + + await adapter.send("hello") From a8c4452b99e1d4c0cd9430e81701f367216e7bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 14 Dec 2020 09:13:40 -0800 Subject: [PATCH 607/616] ActivityHandler inheriting from Bot (#1443) Co-authored-by: tracyboehrer --- .../botbuilder-core/botbuilder/core/activity_handler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index e207fa0d2..c5afb5e08 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -12,6 +12,7 @@ HealthCheckResponse, ) +from .bot import Bot from .bot_adapter import BotAdapter from .healthcheck import HealthCheck from .serializer_helper import serializer_helper @@ -20,7 +21,7 @@ from .turn_context import TurnContext -class ActivityHandler: +class ActivityHandler(Bot): """ Handles activities and should be subclassed. @@ -30,7 +31,9 @@ class ActivityHandler: in the derived class. """ - async def on_turn(self, turn_context: TurnContext): + async def on_turn( + self, turn_context: TurnContext + ): # pylint: disable=arguments-differ """ Called by the adapter (for example, :class:`BotFrameworkAdapter`) at runtime in order to process an inbound :class:`botbuilder.schema.Activity`. From 63b9e9261fe2ed8527b9957d1f519a52e66807f9 Mon Sep 17 00:00:00 2001 From: Kyle Delaney Date: Mon, 14 Dec 2020 09:21:35 -0800 Subject: [PATCH 608/616] [PORT] Update channel.py to make it clear that Telegram supports card actions (#1437) Port of https://github.com/microsoft/botbuilder-dotnet/pull/5024 Co-authored-by: tracyboehrer --- .../botbuilder-dialogs/botbuilder/dialogs/choices/channel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py index 41c313047..d3d532b22 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py @@ -61,6 +61,7 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: Channels.ms_teams: 3, Channels.line: 99, Channels.slack: 100, + Channels.telegram: 100, Channels.emulator: 100, Channels.direct_line: 100, Channels.webchat: 100, From 19b8fb0844814272bafa2690664dcebbb311924d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 5 Jan 2021 11:47:14 -0800 Subject: [PATCH 609/616] Updating msal dependency (#1451) --- libraries/botframework-connector/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 04bf09257..09a82d646 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -12,7 +12,7 @@ "PyJWT==1.5.3", "botbuilder-schema==4.12.0", "adal==1.2.1", - "msal==1.2.0", + "msal==1.6.0", ] root = os.path.abspath(os.path.dirname(__file__)) From 86903ab93ed19ed94fcc3393f99f04b9de60d1b4 Mon Sep 17 00:00:00 2001 From: Ashley Finafrock <35248895+Zerryth@users.noreply.github.com> Date: Tue, 19 Jan 2021 12:16:03 -0800 Subject: [PATCH 610/616] (py) Remove comments pertaining to auto-generation in Schema and Connector (#1464) * Removed comments pertaining to auto-gen code in schema & connector * Pushing unsaved changes --- .../botframework-connector/botframework/connector/__init__.py | 4 ---- .../botframework/connector/_configuration.py | 4 ---- .../botframework/connector/aio/__init__.py | 4 ---- .../botframework/connector/aio/_connector_client_async.py | 4 ---- .../botframework/connector/aio/operations_async/__init__.py | 4 ---- .../aio/operations_async/_attachments_operations_async.py | 4 ---- .../aio/operations_async/_conversations_operations_async.py | 4 ---- .../botframework/connector/async_mixin/__init__.py | 3 +++ .../botframework/connector/auth/__init__.py | 4 ---- .../botframework/connector/auth/authentication_constants.py | 1 + .../botframework/connector/auth/channel_validation.py | 3 +++ .../botframework/connector/auth/claims_identity.py | 4 ++++ .../botframework/connector/auth/credential_provider.py | 4 ++++ .../botframework/connector/auth/government_constants.py | 1 + .../botframework/connector/auth/jwt_token_validation.py | 1 + .../botframework/connector/connector_client.py | 4 ---- .../botframework/connector/models/__init__.py | 4 ---- .../botframework/connector/operations/__init__.py | 4 ---- .../connector/operations/_attachments_operations.py | 4 ---- .../connector/operations/_conversations_operations.py | 4 ---- .../botframework/connector/teams/__init__.py | 4 ---- .../botframework/connector/teams/operations/__init__.py | 4 ---- .../connector/teams/operations/teams_operations.py | 4 ---- .../botframework/connector/teams/teams_connector_client.py | 4 ---- .../botframework/connector/teams/version.py | 4 ---- .../botframework/connector/token_api/__init__.py | 4 ---- .../botframework/connector/token_api/_configuration.py | 4 ---- .../botframework/connector/token_api/_token_api_client.py | 4 ---- .../botframework/connector/token_api/aio/__init__.py | 4 ---- .../connector/token_api/aio/_token_api_client_async.py | 4 ---- .../connector/token_api/aio/operations_async/__init__.py | 4 ---- .../aio/operations_async/_bot_sign_in_operations_async.py | 4 ---- .../aio/operations_async/_user_token_operations_async.py | 4 ---- .../botframework/connector/token_api/models/__init__.py | 4 ---- .../botframework/connector/token_api/models/_models.py | 4 ---- .../botframework/connector/token_api/models/_models_py3.py | 4 ---- .../botframework/connector/token_api/operations/__init__.py | 4 ---- .../connector/token_api/operations/_bot_sign_in_operations.py | 4 ---- .../connector/token_api/operations/_user_token_operations.py | 4 ---- .../botframework/connector/token_api/version.py | 4 ---- .../botframework-connector/botframework/connector/version.py | 4 ---- libraries/botframework-connector/setup.py | 1 + libraries/botframework-connector/tests/test_auth.py | 1 + .../tests/test_endorsements_validator.py | 3 +++ .../tests/test_microsoft_app_credentials.py | 3 +++ .../botframework-connector/tests/test_skill_validation.py | 3 +++ 46 files changed, 28 insertions(+), 136 deletions(-) diff --git a/libraries/botframework-connector/botframework/connector/__init__.py b/libraries/botframework-connector/botframework/connector/__init__.py index 47e6ad952..519f0ab2e 100644 --- a/libraries/botframework-connector/botframework/connector/__init__.py +++ b/libraries/botframework-connector/botframework/connector/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from .channels import Channels diff --git a/libraries/botframework-connector/botframework/connector/_configuration.py b/libraries/botframework-connector/botframework/connector/_configuration.py index 33f23fd21..ce9a8c1d7 100644 --- a/libraries/botframework-connector/botframework/connector/_configuration.py +++ b/libraries/botframework-connector/botframework/connector/_configuration.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest import Configuration diff --git a/libraries/botframework-connector/botframework/connector/aio/__init__.py b/libraries/botframework-connector/botframework/connector/aio/__init__.py index 04c1b91a5..e8f4fa483 100644 --- a/libraries/botframework-connector/botframework/connector/aio/__init__.py +++ b/libraries/botframework-connector/botframework/connector/aio/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._connector_client_async import ConnectorClient diff --git a/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py b/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py index ff6b9b314..73cebfb07 100644 --- a/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py +++ b/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.async_client import SDKClientAsync diff --git a/libraries/botframework-connector/botframework/connector/aio/operations_async/__init__.py b/libraries/botframework-connector/botframework/connector/aio/operations_async/__init__.py index 6adc13e41..ca019f8e4 100644 --- a/libraries/botframework-connector/botframework/connector/aio/operations_async/__init__.py +++ b/libraries/botframework-connector/botframework/connector/aio/operations_async/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._attachments_operations_async import AttachmentsOperations diff --git a/libraries/botframework-connector/botframework/connector/aio/operations_async/_attachments_operations_async.py b/libraries/botframework-connector/botframework/connector/aio/operations_async/_attachments_operations_async.py index a46fa7da5..1bb926cfa 100644 --- a/libraries/botframework-connector/botframework/connector/aio/operations_async/_attachments_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/aio/operations_async/_attachments_operations_async.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py b/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py index db5e00ae0..a982ec673 100644 --- a/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/aio/operations_async/_conversations_operations_async.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/async_mixin/__init__.py b/libraries/botframework-connector/botframework/connector/async_mixin/__init__.py index 796bf96fe..76ba66e7a 100644 --- a/libraries/botframework-connector/botframework/connector/async_mixin/__init__.py +++ b/libraries/botframework-connector/botframework/connector/async_mixin/__init__.py @@ -1 +1,4 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .async_mixin import AsyncServiceClientMixin diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index e1f08743f..d5f273e0f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- # pylint: disable=missing-docstring from .authentication_constants import * diff --git a/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py b/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py index 7ccc8ab56..294223f18 100644 --- a/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py +++ b/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from abc import ABC diff --git a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py index fde7f1144..0acaeea8f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import asyncio from .authentication_configuration import AuthenticationConfiguration diff --git a/libraries/botframework-connector/botframework/connector/auth/claims_identity.py b/libraries/botframework-connector/botframework/connector/auth/claims_identity.py index 5bc29df62..211f7b241 100644 --- a/libraries/botframework-connector/botframework/connector/auth/claims_identity.py +++ b/libraries/botframework-connector/botframework/connector/auth/claims_identity.py @@ -1,3 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + class Claim: def __init__(self, claim_type: str, value): self.type = claim_type diff --git a/libraries/botframework-connector/botframework/connector/auth/credential_provider.py b/libraries/botframework-connector/botframework/connector/auth/credential_provider.py index b95cff120..7d41c2464 100644 --- a/libraries/botframework-connector/botframework/connector/auth/credential_provider.py +++ b/libraries/botframework-connector/botframework/connector/auth/credential_provider.py @@ -1,3 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + class CredentialProvider: """CredentialProvider. This class allows Bots to provide their own implemention diff --git a/libraries/botframework-connector/botframework/connector/auth/government_constants.py b/libraries/botframework-connector/botframework/connector/auth/government_constants.py index 0d768397a..550eb3e3f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/government_constants.py +++ b/libraries/botframework-connector/botframework/connector/auth/government_constants.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from abc import ABC diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index 22d3e22ab..e83d6ccf6 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Dict, List, Union from botbuilder.schema import Activity, RoleTypes diff --git a/libraries/botframework-connector/botframework/connector/connector_client.py b/libraries/botframework-connector/botframework/connector/connector_client.py index ab88ac9ae..db503016d 100644 --- a/libraries/botframework-connector/botframework/connector/connector_client.py +++ b/libraries/botframework-connector/botframework/connector/connector_client.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.service_client import SDKClient diff --git a/libraries/botframework-connector/botframework/connector/models/__init__.py b/libraries/botframework-connector/botframework/connector/models/__init__.py index c03adc0f5..54eea3e77 100644 --- a/libraries/botframework-connector/botframework/connector/models/__init__.py +++ b/libraries/botframework-connector/botframework/connector/models/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from botbuilder.schema import * diff --git a/libraries/botframework-connector/botframework/connector/operations/__init__.py b/libraries/botframework-connector/botframework/connector/operations/__init__.py index b2bc000ca..2476fcd20 100644 --- a/libraries/botframework-connector/botframework/connector/operations/__init__.py +++ b/libraries/botframework-connector/botframework/connector/operations/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._attachments_operations import AttachmentsOperations diff --git a/libraries/botframework-connector/botframework/connector/operations/_attachments_operations.py b/libraries/botframework-connector/botframework/connector/operations/_attachments_operations.py index 03cce075d..d7d6287eb 100644 --- a/libraries/botframework-connector/botframework/connector/operations/_attachments_operations.py +++ b/libraries/botframework-connector/botframework/connector/operations/_attachments_operations.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py b/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py index 5fab0cc22..a4c37f6f4 100644 --- a/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py +++ b/libraries/botframework-connector/botframework/connector/operations/_conversations_operations.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/teams/__init__.py b/libraries/botframework-connector/botframework/connector/teams/__init__.py index df0cf0a57..48125ad74 100644 --- a/libraries/botframework-connector/botframework/connector/teams/__init__.py +++ b/libraries/botframework-connector/botframework/connector/teams/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from .teams_connector_client import TeamsConnectorClient diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py b/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py index 3e46b2dc2..326ddcf8d 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from .teams_operations import TeamsOperations diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py index 5c61086b0..c53e2045f 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py index ccf935032..73c3fec66 100644 --- a/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py +++ b/libraries/botframework-connector/botframework/connector/teams/teams_connector_client.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.service_client import SDKClient diff --git a/libraries/botframework-connector/botframework/connector/teams/version.py b/libraries/botframework-connector/botframework/connector/teams/version.py index e36069e74..059dc8b92 100644 --- a/libraries/botframework-connector/botframework/connector/teams/version.py +++ b/libraries/botframework-connector/botframework/connector/teams/version.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- VERSION = "v3" diff --git a/libraries/botframework-connector/botframework/connector/token_api/__init__.py b/libraries/botframework-connector/botframework/connector/token_api/__init__.py index e15b7c0d4..284737f97 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/__init__.py +++ b/libraries/botframework-connector/botframework/connector/token_api/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._configuration import TokenApiClientConfiguration diff --git a/libraries/botframework-connector/botframework/connector/token_api/_configuration.py b/libraries/botframework-connector/botframework/connector/token_api/_configuration.py index ff26db8d8..dd94bf968 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/_configuration.py +++ b/libraries/botframework-connector/botframework/connector/token_api/_configuration.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest import Configuration diff --git a/libraries/botframework-connector/botframework/connector/token_api/_token_api_client.py b/libraries/botframework-connector/botframework/connector/token_api/_token_api_client.py index 863dcb2e5..f4d34c744 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/_token_api_client.py +++ b/libraries/botframework-connector/botframework/connector/token_api/_token_api_client.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.service_client import SDKClient diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/__init__.py b/libraries/botframework-connector/botframework/connector/token_api/aio/__init__.py index 967abe5f8..eb69ef863 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/__init__.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._token_api_client_async import TokenApiClient diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/_token_api_client_async.py b/libraries/botframework-connector/botframework/connector/token_api/aio/_token_api_client_async.py index a72fed429..80eba06be 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/_token_api_client_async.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/_token_api_client_async.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.async_client import SDKClientAsync diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/__init__.py b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/__init__.py index 0c30a7ed3..8194c77fd 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/__init__.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._bot_sign_in_operations_async import BotSignInOperations diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py index 8798b13e1..385f14466 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_bot_sign_in_operations_async.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py index b4fda1b37..5ac397d66 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py +++ b/libraries/botframework-connector/botframework/connector/token_api/aio/operations_async/_user_token_operations_async.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py b/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py index be368b2f2..f4593e21a 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py +++ b/libraries/botframework-connector/botframework/connector/token_api/models/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- try: diff --git a/libraries/botframework-connector/botframework/connector/token_api/models/_models.py b/libraries/botframework-connector/botframework/connector/token_api/models/_models.py index e100013a7..63c1eedae 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/models/_models.py +++ b/libraries/botframework-connector/botframework/connector/token_api/models/_models.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.serialization import Model diff --git a/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py b/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py index bc2602eab..271c532dc 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py +++ b/libraries/botframework-connector/botframework/connector/token_api/models/_models_py3.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.serialization import Model diff --git a/libraries/botframework-connector/botframework/connector/token_api/operations/__init__.py b/libraries/botframework-connector/botframework/connector/token_api/operations/__init__.py index d860b4524..76df7af4e 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/operations/__init__.py +++ b/libraries/botframework-connector/botframework/connector/token_api/operations/__init__.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from ._bot_sign_in_operations import BotSignInOperations diff --git a/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py b/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py index a768a3afc..83f128b15 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py +++ b/libraries/botframework-connector/botframework/connector/token_api/operations/_bot_sign_in_operations.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py b/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py index 0d0a66ad7..f63952571 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py +++ b/libraries/botframework-connector/botframework/connector/token_api/operations/_user_token_operations.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- from msrest.pipeline import ClientRawResponse diff --git a/libraries/botframework-connector/botframework/connector/token_api/version.py b/libraries/botframework-connector/botframework/connector/token_api/version.py index c184fa4a9..1ca57ef7f 100644 --- a/libraries/botframework-connector/botframework/connector/token_api/version.py +++ b/libraries/botframework-connector/botframework/connector/token_api/version.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- VERSION = "token" diff --git a/libraries/botframework-connector/botframework/connector/version.py b/libraries/botframework-connector/botframework/connector/version.py index e36069e74..059dc8b92 100644 --- a/libraries/botframework-connector/botframework/connector/version.py +++ b/libraries/botframework-connector/botframework/connector/version.py @@ -3,10 +3,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. -# -# Code generated by Microsoft (R) AutoRest Code Generator. -# Changes may cause incorrect behavior and will be lost if the code is -# regenerated. # -------------------------------------------------------------------------- VERSION = "v3" diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 09a82d646..59631e4cd 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + import os from setuptools import setup diff --git a/libraries/botframework-connector/tests/test_auth.py b/libraries/botframework-connector/tests/test_auth.py index 24860c66f..4e5c94745 100644 --- a/libraries/botframework-connector/tests/test_auth.py +++ b/libraries/botframework-connector/tests/test_auth.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + import uuid from typing import Dict, List, Union from unittest.mock import Mock diff --git a/libraries/botframework-connector/tests/test_endorsements_validator.py b/libraries/botframework-connector/tests/test_endorsements_validator.py index 18dee4c31..9d4fad0fa 100644 --- a/libraries/botframework-connector/tests/test_endorsements_validator.py +++ b/libraries/botframework-connector/tests/test_endorsements_validator.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import pytest from botframework.connector.auth import EndorsementsValidator diff --git a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py index f4cb2516d..e1beff8bf 100644 --- a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py +++ b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import aiounittest from botframework.connector.auth import AuthenticationConstants, MicrosoftAppCredentials diff --git a/libraries/botframework-connector/tests/test_skill_validation.py b/libraries/botframework-connector/tests/test_skill_validation.py index 66b22fc07..a7667c3d7 100644 --- a/libraries/botframework-connector/tests/test_skill_validation.py +++ b/libraries/botframework-connector/tests/test_skill_validation.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import uuid from asyncio import Future from unittest.mock import Mock, DEFAULT From 1e6fa28b759517ddbe3d4823fa3b014e28339021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Tue, 26 Jan 2021 17:11:47 -0800 Subject: [PATCH 611/616] Adding add-upgrade and remove-upgrade activity types in ActivityHandler (#1476) --- .../botbuilder/core/activity_handler.py | 4 +-- .../tests/test_activity_handler.py | 34 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index c5afb5e08..28c924e0f 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -381,9 +381,9 @@ async def on_installation_update( # pylint: disable=unused-argument :type turn_context: :class:`botbuilder.core.TurnContext` :returns: A task that represents the work queued to execute """ - if turn_context.activity.action == "add": + if turn_context.activity.action in ("add", "add-upgrade"): return await self.on_installation_update_add(turn_context) - if turn_context.activity.action == "remove": + if turn_context.activity.action in ("remove", "remove-upgrade"): return await self.on_installation_update_remove(turn_context) return diff --git a/libraries/botbuilder-core/tests/test_activity_handler.py b/libraries/botbuilder-core/tests/test_activity_handler.py index 2f8b0daea..69ccfa830 100644 --- a/libraries/botbuilder-core/tests/test_activity_handler.py +++ b/libraries/botbuilder-core/tests/test_activity_handler.py @@ -268,7 +268,23 @@ async def test_on_installation_update_add(self): assert bot.record[0] == "on_installation_update" assert bot.record[1] == "on_installation_update_add" - async def test_on_installation_update_add_remove(self): + async def test_on_installation_update_add_upgrade(self): + activity = Activity( + type=ActivityTypes.installation_update, action="add-upgrade" + ) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 2 + assert bot.record[0] == "on_installation_update" + assert bot.record[1] == "on_installation_update_add" + + async def test_on_installation_update_remove(self): activity = Activity(type=ActivityTypes.installation_update, action="remove") adapter = TestInvokeAdapter() @@ -282,6 +298,22 @@ async def test_on_installation_update_add_remove(self): assert bot.record[0] == "on_installation_update" assert bot.record[1] == "on_installation_update_remove" + async def test_on_installation_update_remove_upgrade(self): + activity = Activity( + type=ActivityTypes.installation_update, action="remove-upgrade" + ) + + adapter = TestInvokeAdapter() + turn_context = TurnContext(adapter, activity) + + # Act + bot = TestingActivityHandler() + await bot.on_turn(turn_context) + + assert len(bot.record) == 2 + assert bot.record[0] == "on_installation_update" + assert bot.record[1] == "on_installation_update_remove" + async def test_healthcheck(self): activity = Activity(type=ActivityTypes.invoke, name="healthcheck",) From 34e61096d4d6843190fbb512cf129b12e004f0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 1 Feb 2021 06:08:50 -0800 Subject: [PATCH 612/616] DialogManager (#1409) * Initial commit for dialog manager **WIP state** * Adding more memory classes * memory scopes and path resolvers added * Updates on try_get_value * DialogStateManager code complete * Dialog manager code complete (tests pending) * Solved circular dependency issues, bugfix in DialogCOmponentRegistration * Pylint compliance and bugfixing * Reverting regression in DialogManager * Compatibility with 3.6 typing * General DialogManager testing added. Several bugfixes * Added tests for Dialog Manager * Fixing ClassMemoryScope binding, adding tests for scopes classes * ConversationState scope test * Adding more scopes tests * Added all scopes tests * Fixing printing because of merge conflict * PR comments fixes Co-authored-by: tracyboehrer --- .../botbuilder/core/__init__.py | 2 + .../botbuilder/core/bot_state.py | 2 +- .../botbuilder/core/component_registration.py | 17 + .../botbuilder/core/conversation_state.py | 2 +- .../botbuilder/core/user_state.py | 2 +- .../botbuilder/dialogs/__init__.py | 14 + .../botbuilder/dialogs/dialog.py | 81 +++ .../botbuilder/dialogs/dialog_container.py | 83 +++ .../botbuilder/dialogs/dialog_context.py | 159 ++++- .../botbuilder/dialogs/dialog_event.py | 9 + .../botbuilder/dialogs/dialog_events.py | 2 + .../botbuilder/dialogs/dialog_instance.py | 8 +- .../botbuilder/dialogs/dialog_manager.py | 381 ++++++++++ .../dialogs/dialog_manager_result.py | 21 + .../botbuilder/dialogs/dialog_set.py | 53 +- .../dialogs/dialogs_component_registration.py | 53 ++ .../botbuilder/dialogs/memory/__init__.py | 24 + .../memory/component_memory_scopes_base.py | 14 + .../memory/component_path_resolvers_base.py | 14 + .../botbuilder/dialogs/memory/dialog_path.py | 32 + .../dialogs/memory/dialog_state_manager.py | 660 ++++++++++++++++++ .../dialog_state_manager_configuration.py | 10 + .../dialogs/memory/path_resolver_base.py | 7 + .../dialogs/memory/path_resolvers/__init__.py | 19 + .../path_resolvers/alias_path_resolver.py | 53 ++ .../path_resolvers/at_at_path_resolver.py | 9 + .../memory/path_resolvers/at_path_resolver.py | 43 ++ .../path_resolvers/dollar_path_resolver.py | 9 + .../path_resolvers/hash_path_resolver.py | 9 + .../path_resolvers/percent_path_resolver.py | 9 + .../botbuilder/dialogs/memory/scope_path.py | 35 + .../dialogs/memory/scopes/__init__.py | 32 + .../memory/scopes/bot_state_memory_scope.py | 43 ++ .../memory/scopes/class_memory_scope.py | 57 ++ .../scopes/conversation_memory_scope.py | 12 + .../scopes/dialog_class_memory_scope.py | 45 ++ .../scopes/dialog_context_memory_scope.py | 65 ++ .../memory/scopes/dialog_memory_scope.py | 68 ++ .../dialogs/memory/scopes/memory_scope.py | 84 +++ .../memory/scopes/settings_memory_scope.py | 31 + .../memory/scopes/this_memory_scope.py | 28 + .../memory/scopes/turn_memory_scope.py | 79 +++ .../memory/scopes/user_memory_scope.py | 12 + .../botbuilder/dialogs/object_path.py | 9 + .../botbuilder/dialogs/persisted_state.py | 20 + .../dialogs/persisted_state_keys.py | 8 + .../tests/memory/scopes/test_memory_scopes.py | 566 +++++++++++++++ .../tests/memory/scopes/test_settings.py | 14 + .../tests/test_dialog_manager.py | 352 ++++++++++ 49 files changed, 3337 insertions(+), 24 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/component_registration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py create mode 100644 libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py create mode 100644 libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py create mode 100644 libraries/botbuilder-dialogs/tests/test_dialog_manager.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 0a9a218fa..a596a2325 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -18,6 +18,7 @@ from .bot_telemetry_client import BotTelemetryClient, Severity from .card_factory import CardFactory from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler +from .component_registration import ComponentRegistration from .conversation_state import ConversationState from .oauth.extended_user_token_provider import ExtendedUserTokenProvider from .oauth.user_token_provider import UserTokenProvider @@ -62,6 +63,7 @@ "calculate_change_hash", "CardFactory", "ChannelServiceHandler", + "ComponentRegistration", "ConversationState", "conversation_reference_extension", "ExtendedUserTokenProvider", diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 0e38e9af0..867fb07e0 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -14,7 +14,7 @@ class CachedBotState: """ - Internal cached bot state. + Internal cached bot state. """ def __init__(self, state: Dict[str, object] = None): diff --git a/libraries/botbuilder-core/botbuilder/core/component_registration.py b/libraries/botbuilder-core/botbuilder/core/component_registration.py new file mode 100644 index 000000000..03023abbf --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/component_registration.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict, Iterable, Type + + +class ComponentRegistration: + @staticmethod + def get_components() -> Iterable["ComponentRegistration"]: + return _components.values() + + @staticmethod + def add(component_registration: "ComponentRegistration"): + _components[component_registration.__class__] = component_registration + + +_components: Dict[Type, ComponentRegistration] = {} diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 4605700f6..174ca0883 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -25,7 +25,7 @@ def __init__(self, storage: Storage): :param storage: The storage containing the conversation state. :type storage: :class:`Storage` """ - super(ConversationState, self).__init__(storage, "ConversationState") + super(ConversationState, self).__init__(storage, "Internal.ConversationState") def get_storage_key(self, turn_context: TurnContext) -> object: """ diff --git a/libraries/botbuilder-core/botbuilder/core/user_state.py b/libraries/botbuilder-core/botbuilder/core/user_state.py index ab4b3f676..7cd23f8b1 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_state.py +++ b/libraries/botbuilder-core/botbuilder/core/user_state.py @@ -23,7 +23,7 @@ def __init__(self, storage: Storage, namespace=""): """ self.namespace = namespace - super(UserState, self).__init__(storage, "UserState") + super(UserState, self).__init__(storage, "Internal.UserState") def get_storage_key(self, turn_context: TurnContext) -> str: """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index fd2a74a76..37c305536 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -7,7 +7,9 @@ from .about import __version__ from .component_dialog import ComponentDialog +from .dialog_container import DialogContainer from .dialog_context import DialogContext +from .dialog_event import DialogEvent from .dialog_events import DialogEvents from .dialog_instance import DialogInstance from .dialog_reason import DialogReason @@ -15,7 +17,12 @@ from .dialog_state import DialogState from .dialog_turn_result import DialogTurnResult from .dialog_turn_status import DialogTurnStatus +from .dialog_manager import DialogManager +from .dialog_manager_result import DialogManagerResult from .dialog import Dialog +from .dialogs_component_registration import DialogsComponentRegistration +from .persisted_state_keys import PersistedStateKeys +from .persisted_state import PersistedState from .waterfall_dialog import WaterfallDialog from .waterfall_step_context import WaterfallStepContext from .dialog_extensions import DialogExtensions @@ -26,7 +33,9 @@ __all__ = [ "ComponentDialog", + "DialogContainer", "DialogContext", + "DialogEvent", "DialogEvents", "DialogInstance", "DialogReason", @@ -34,7 +43,10 @@ "DialogState", "DialogTurnResult", "DialogTurnStatus", + "DialogManager", + "DialogManagerResult", "Dialog", + "DialogsComponentRegistration", "WaterfallDialog", "WaterfallStepContext", "ConfirmPrompt", @@ -43,6 +55,8 @@ "NumberPrompt", "OAuthPrompt", "OAuthPromptSettings", + "PersistedStateKeys", + "PersistedState", "PromptRecognizerResult", "PromptValidatorContext", "Prompt", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index 63d816b94..22dfe342b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -4,6 +4,7 @@ from botbuilder.core import TurnContext, NullTelemetryClient, BotTelemetryClient from .dialog_reason import DialogReason +from .dialog_event import DialogEvent from .dialog_turn_status import DialogTurnStatus from .dialog_turn_result import DialogTurnResult from .dialog_instance import DialogInstance @@ -105,3 +106,83 @@ async def end_dialog( # pylint: disable=unused-argument """ # No-op by default return + + def get_version(self) -> str: + return self.id + + async def on_dialog_event( + self, dialog_context: "DialogContext", dialog_event: DialogEvent + ) -> bool: + """ + Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a + dialog that the current dialog started. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: True if the event is handled by the current dialog and bubbling should stop. + """ + # Before bubble + handled = await self._on_pre_bubble_event(dialog_context, dialog_event) + + # Bubble as needed + if (not handled) and dialog_event.bubble and dialog_context.parent: + handled = await dialog_context.parent.emit( + dialog_event.name, dialog_event.value, True, False + ) + + # Post bubble + if not handled: + handled = await self._on_post_bubble_event(dialog_context, dialog_event) + + return handled + + async def _on_pre_bubble_event( # pylint: disable=unused-argument + self, dialog_context: "DialogContext", dialog_event: DialogEvent + ) -> bool: + """ + Called before an event is bubbled to its parent. + This is a good place to perform interception of an event as returning `true` will prevent + any further bubbling of the event to the dialogs parents and will also prevent any child + dialogs from performing their default processing. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: Whether the event is handled by the current dialog and further processing should stop. + """ + return False + + async def _on_post_bubble_event( # pylint: disable=unused-argument + self, dialog_context: "DialogContext", dialog_event: DialogEvent + ) -> bool: + """ + Called after an event was bubbled to all parents and wasn't handled. + This is a good place to perform default processing logic for an event. Returning `true` will + prevent any processing of the event by child dialogs. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: Whether the event is handled by the current dialog and further processing should stop. + """ + return False + + def _on_compute_id(self) -> str: + """ + Computes an unique ID for a dialog. + :return: An unique ID for a dialog + """ + return self.__class__.__name__ + + def _register_source_location( + self, path: str, line_number: int + ): # pylint: disable=unused-argument + """ + Registers a SourceRange in the provided location. + :param path: The path to the source file. + :param line_number: The line number where the source will be located on the file. + :return: + """ + if path: + # This will be added when debbuging support is ported. + # DebugSupport.source_map.add(self, SourceRange( + # path = path, + # start_point = SourcePoint(line_index = line_number, char_index = 0 ), + # end_point = SourcePoint(line_index = line_number + 1, char_index = 0 ), + # ) + return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py new file mode 100644 index 000000000..ad2326419 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + + +from .dialog import Dialog +from .dialog_context import DialogContext +from .dialog_event import DialogEvent +from .dialog_events import DialogEvents +from .dialog_set import DialogSet + + +class DialogContainer(Dialog, ABC): + def __init__(self, dialog_id: str = None): + super().__init__(dialog_id) + + self.dialogs = DialogSet() + + @abstractmethod + def create_child_context(self, dialog_context: DialogContext) -> DialogContext: + raise NotImplementedError() + + def find_dialog(self, dialog_id: str) -> Dialog: + # TODO: deprecate DialogSet.find + return self.dialogs.find_dialog(dialog_id) + + async def on_dialog_event( + self, dialog_context: DialogContext, dialog_event: DialogEvent + ) -> bool: + """ + Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a + dialog that the current dialog started. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: True if the event is handled by the current dialog and bubbling should stop. + """ + handled = await super().on_dialog_event(dialog_context, dialog_event) + + # Trace unhandled "versionChanged" events. + if not handled and dialog_event.name == DialogEvents.version_changed: + + trace_message = ( + f"Unhandled dialog event: {dialog_event.name}. Active Dialog: " + f"{dialog_context.active_dialog.id}" + ) + + await dialog_context.context.send_trace_activity(trace_message) + + return handled + + def get_internal_version(self) -> str: + """ + GetInternalVersion - Returns internal version identifier for this container. + DialogContainers detect changes of all sub-components in the container and map that to an DialogChanged event. + Because they do this, DialogContainers "hide" the internal changes and just have the .id. This isolates changes + to the container level unless a container doesn't handle it. To support this DialogContainers define a + protected virtual method GetInternalVersion() which computes if this dialog or child dialogs have changed + which is then examined via calls to check_for_version_change_async(). + :return: version which represents the change of the internals of this container. + """ + return self.dialogs.get_version() + + async def check_for_version_change_async(self, dialog_context: DialogContext): + """ + :param dialog_context: dialog context. + :return: task. + Checks to see if a containers child dialogs have changed since the current dialog instance + was started. + + This should be called at the start of `beginDialog()`, `continueDialog()`, and `resumeDialog()`. + """ + current = dialog_context.active_dialog.version + dialog_context.active_dialog.version = self.get_internal_version() + + # Check for change of previously stored hash + if current and current != dialog_context.active_dialog.version: + # Give bot an opportunity to handle the change. + # - If bot handles it the changeHash will have been updated as to avoid triggering the + # change again. + await dialog_context.emit_event( + DialogEvents.version_changed, self.id, True, False + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index f79ef8e3c..b10a63978 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -1,7 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List, Optional + from botbuilder.core.turn_context import TurnContext +from botbuilder.dialogs.memory import DialogStateManager + +from .dialog_event import DialogEvent +from .dialog_events import DialogEvents +from .dialog_set import DialogSet from .dialog_state import DialogState from .dialog_turn_status import DialogTurnStatus from .dialog_turn_result import DialogTurnResult @@ -12,7 +19,7 @@ class DialogContext: def __init__( - self, dialog_set: object, turn_context: TurnContext, state: DialogState + self, dialog_set: DialogSet, turn_context: TurnContext, state: DialogState ): if dialog_set is None: raise TypeError("DialogContext(): dialog_set cannot be None.") @@ -21,16 +28,17 @@ def __init__( raise TypeError("DialogContext(): turn_context cannot be None.") self._turn_context = turn_context self._dialogs = dialog_set - # self._id = dialog_id; self._stack = state.dialog_stack - self.parent = None + self.services = {} + self.parent: DialogContext = None + self.state = DialogStateManager(self) @property - def dialogs(self): + def dialogs(self) -> DialogSet: """Gets the set of dialogs that can be called from this context. :param: - :return str: + :return DialogSet: """ return self._dialogs @@ -39,16 +47,16 @@ def context(self) -> TurnContext: """Gets the context for the current turn of conversation. :param: - :return str: + :return TurnContext: """ return self._turn_context @property - def stack(self): + def stack(self) -> List: """Gets the current dialog stack. :param: - :return str: + :return list: """ return self._stack @@ -57,12 +65,33 @@ def active_dialog(self): """Return the container link in the database. :param: - :return str: + :return: """ if self._stack: return self._stack[0] return None + @property + def child(self) -> Optional["DialogContext"]: + """Return the container link in the database. + + :param: + :return DialogContext: + """ + # pylint: disable=import-outside-toplevel + instance = self.active_dialog + + if instance: + dialog = self.find_dialog_sync(instance.id) + + # This import prevents circular dependency issues + from .dialog_container import DialogContainer + + if isinstance(dialog, DialogContainer): + return dialog.create_child_context(self) + + return None + async def begin_dialog(self, dialog_id: str, options: object = None): """ Pushes a new dialog onto the dialog stack. @@ -71,7 +100,7 @@ async def begin_dialog(self, dialog_id: str, options: object = None): """ try: if not dialog_id: - raise TypeError("Dialog(): dialogId cannot be None.") + raise TypeError("Dialog(): dialog_id cannot be None.") # Look up dialog dialog = await self.find_dialog(dialog_id) if dialog is None: @@ -177,13 +206,58 @@ async def end_dialog(self, result: object = None): self.__set_exception_context_data(err) raise - async def cancel_all_dialogs(self): + async def cancel_all_dialogs( + self, + cancel_parents: bool = None, + event_name: str = None, + event_value: object = None, + ): """ Deletes any existing dialog stack thus cancelling all dialogs on the stack. - :param result: (Optional) result to pass to the parent dialogs. + :param cancel_parents: + :param event_name: + :param event_value: :return: """ + # pylint: disable=too-many-nested-blocks try: + if cancel_parents is None: + event_name = event_name or DialogEvents.cancel_dialog + + if self.stack or self.parent: + # Cancel all local and parent dialogs while checking for interception + notify = False + dialog_context = self + + while dialog_context: + if dialog_context.stack: + # Check to see if the dialog wants to handle the event + if notify: + event_handled = await dialog_context.emit_event( + event_name, + event_value, + bubble=False, + from_leaf=False, + ) + + if event_handled: + break + + # End the active dialog + await dialog_context.end_active_dialog( + DialogReason.CancelCalled + ) + else: + dialog_context = ( + dialog_context.parent if cancel_parents else None + ) + + notify = True + + return DialogTurnResult(DialogTurnStatus.Cancelled) + # Stack was empty and no parent + return DialogTurnResult(DialogTurnStatus.Empty) + if self.stack: while self.stack: await self.end_active_dialog(DialogReason.CancelCalled) @@ -211,6 +285,19 @@ async def find_dialog(self, dialog_id: str) -> Dialog: self.__set_exception_context_data(err) raise + def find_dialog_sync(self, dialog_id: str) -> Dialog: + """ + If the dialog cannot be found within the current `DialogSet`, the parent `DialogContext` + will be searched if there is one. + :param dialog_id: ID of the dialog to search for. + :return: + """ + dialog = self.dialogs.find_dialog(dialog_id) + + if dialog is None and self.parent is not None: + dialog = self.parent.find_dialog_sync(dialog_id) + return dialog + async def replace_dialog( self, dialog_id: str, options: object = None ) -> DialogTurnResult: @@ -265,6 +352,54 @@ async def end_active_dialog(self, reason: DialogReason): # Pop dialog off stack self._stack.pop(0) + async def emit_event( + self, + name: str, + value: object = None, + bubble: bool = True, + from_leaf: bool = False, + ) -> bool: + """ + Searches for a dialog with a given ID. + Emits a named event for the current dialog, or someone who started it, to handle. + :param name: Name of the event to raise. + :param value: Value to send along with the event. + :param bubble: Flag to control whether the event should be bubbled to its parent if not handled locally. + Defaults to a value of `True`. + :param from_leaf: Whether the event is emitted from a leaf node. + :param cancellationToken: The cancellation token. + :return: True if the event was handled. + """ + try: + # Initialize event + dialog_event = DialogEvent(bubble=bubble, name=name, value=value,) + + dialog_context = self + + # Find starting dialog + if from_leaf: + while True: + child_dc = dialog_context.child + + if child_dc: + dialog_context = child_dc + else: + break + + # Dispatch to active dialog first + instance = dialog_context.active_dialog + + if instance: + dialog = await dialog_context.find_dialog(instance.id) + + if dialog: + return await dialog.on_dialog_event(dialog_context, dialog_event) + + return False + except Exception as err: + self.__set_exception_context_data(err) + raise + def __set_exception_context_data(self, exception: Exception): if not hasattr(exception, "data"): exception.data = {} diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py new file mode 100644 index 000000000..64753e824 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class DialogEvent: + def __init__(self, bubble: bool = False, name: str = "", value: object = None): + self.bubble = bubble + self.name = name + self.value: object = value diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py index 0c28a7e02..d3d0cb4a1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py @@ -10,4 +10,6 @@ class DialogEvents(str, Enum): reprompt_dialog = "repromptDialog" cancel_dialog = "cancelDialog" activity_received = "activityReceived" + version_changed = "versionChanged" error = "error" + custom = "custom" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index add9e2dc6..0d4e3400b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -9,7 +9,9 @@ class DialogInstance: Tracking information for a dialog on the stack. """ - def __init__(self): + def __init__( + self, id: str = None, state: Dict[str, object] = None + ): # pylint: disable=invalid-name """ Gets or sets the ID of the dialog and gets or sets the instance's persisted state. @@ -18,9 +20,9 @@ def __init__(self): :var self.state: The instance's persisted state. :vartype self.state: :class:`typing.Dict[str, object]` """ - self.id: str = None # pylint: disable=invalid-name + self.id = id # pylint: disable=invalid-name - self.state: Dict[str, object] = {} + self.state = state or {} def __str__(self): """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py new file mode 100644 index 000000000..28dbe6e74 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -0,0 +1,381 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timedelta +from threading import Lock + +from botbuilder.core import ( + BotAdapter, + BotStateSet, + ConversationState, + UserState, + TurnContext, +) +from botbuilder.core.skills import SkillConversationReference, SkillHandler +from botbuilder.dialogs.memory import ( + DialogStateManager, + DialogStateManagerConfiguration, +) +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ( + AuthenticationConstants, + ClaimsIdentity, + GovernmentConstants, + SkillValidation, +) + +from .dialog import Dialog +from .dialog_context import DialogContext +from .dialog_events import DialogEvents +from .dialog_set import DialogSet +from .dialog_state import DialogState +from .dialog_manager_result import DialogManagerResult +from .dialog_turn_status import DialogTurnStatus +from .dialog_turn_result import DialogTurnResult + + +class DialogManager: + """ + Class which runs the dialog system. + """ + + def __init__(self, root_dialog: Dialog = None, dialog_state_property: str = None): + """ + Initializes a instance of the class. + :param root_dialog: Root dialog to use. + :param dialog_state_property: alternate name for the dialog_state property. (Default is "DialogState"). + """ + self.last_access = "_lastAccess" + self._root_dialog_id = "" + self._dialog_state_property = dialog_state_property or "DialogState" + self._lock = Lock() + + # Gets or sets root dialog to use to start conversation. + self.root_dialog = root_dialog + + # Gets or sets the ConversationState. + self.conversation_state: ConversationState = None + + # Gets or sets the UserState. + self.user_state: UserState = None + + # Gets InitialTurnState collection to copy into the TurnState on every turn. + self.initial_turn_state = {} + + # Gets or sets global dialogs that you want to have be callable. + self.dialogs = DialogSet() + + # Gets or sets the DialogStateManagerConfiguration. + self.state_configuration: DialogStateManagerConfiguration = None + + # Gets or sets (optional) number of milliseconds to expire the bot's state after. + self.expire_after: int = None + + async def on_turn(self, context: TurnContext) -> DialogManagerResult: + """ + Runs dialog system in the context of an ITurnContext. + :param context: turn context. + :return: + """ + # pylint: disable=too-many-statements + # Lazy initialize RootDialog so it can refer to assets like LG function templates + if not self._root_dialog_id: + with self._lock: + if not self._root_dialog_id: + self._root_dialog_id = self.root_dialog.id + # self.dialogs = self.root_dialog.telemetry_client + self.dialogs.add(self.root_dialog) + + bot_state_set = BotStateSet([]) + + # Preload TurnState with DM TurnState. + for key, val in self.initial_turn_state.items(): + context.turn_state[key] = val + + # register DialogManager with TurnState. + context.turn_state[DialogManager.__name__] = self + conversation_state_name = ConversationState.__name__ + if self.conversation_state is None: + if conversation_state_name not in context.turn_state: + raise Exception( + f"Unable to get an instance of {conversation_state_name} from turn_context." + ) + self.conversation_state: ConversationState = context.turn_state[ + conversation_state_name + ] + else: + context.turn_state[conversation_state_name] = self.conversation_state + + bot_state_set.add(self.conversation_state) + + user_state_name = UserState.__name__ + if self.user_state is None: + self.user_state = context.turn_state.get(user_state_name, None) + else: + context.turn_state[user_state_name] = self.user_state + + if self.user_state is not None: + self.user_state: UserState = self.user_state + bot_state_set.add(self.user_state) + + # create property accessors + # DateTime(last_access) + last_access_property = self.conversation_state.create_property(self.last_access) + last_access: datetime = await last_access_property.get(context, datetime.now) + + # Check for expired conversation + if self.expire_after is not None and ( + datetime.now() - last_access + ) >= timedelta(milliseconds=float(self.expire_after)): + # Clear conversation state + await self.conversation_state.clear_state(context) + + last_access = datetime.now() + await last_access_property.set(context, last_access) + + # get dialog stack + dialogs_property = self.conversation_state.create_property( + self._dialog_state_property + ) + dialog_state: DialogState = await dialogs_property.get(context, DialogState) + + # Create DialogContext + dialog_context = DialogContext(self.dialogs, context, dialog_state) + + # promote initial TurnState into dialog_context.services for contextual services + for key, service in dialog_context.services.items(): + dialog_context.services[key] = service + + # map TurnState into root dialog context.services + for key, service in context.turn_state.items(): + dialog_context.services[key] = service + + # get the DialogStateManager configuration + dialog_state_manager = DialogStateManager( + dialog_context, self.state_configuration + ) + await dialog_state_manager.load_all_scopes() + dialog_context.context.turn_state[ + dialog_state_manager.__class__.__name__ + ] = dialog_state_manager + + turn_result: DialogTurnResult = None + + # Loop as long as we are getting valid OnError handled we should continue executing the actions for the turn. + + # NOTE: We loop around this block because each pass through we either complete the turn and break out of the + # loop or we have had an exception AND there was an OnError action which captured the error. We need to + # continue the turn based on the actions the OnError handler introduced. + end_of_turn = False + while not end_of_turn: + try: + claims_identity: ClaimsIdentity = context.turn_state.get( + BotAdapter.BOT_IDENTITY_KEY, None + ) + if isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims): + # The bot is running as a skill. + turn_result = await self.handle_skill_on_turn(dialog_context) + else: + # The bot is running as root bot. + turn_result = await self.handle_bot_on_turn(dialog_context) + + # turn successfully completed, break the loop + end_of_turn = True + except Exception as err: + # fire error event, bubbling from the leaf. + handled = await dialog_context.emit_event( + DialogEvents.error, err, bubble=True, from_leaf=True + ) + + if not handled: + # error was NOT handled, throw the exception and end the turn. (This will trigger the + # Adapter.OnError handler and end the entire dialog stack) + raise + + # save all state scopes to their respective botState locations. + await dialog_state_manager.save_all_changes() + + # save BotState changes + await bot_state_set.save_all_changes(dialog_context.context, False) + + return DialogManagerResult(turn_result=turn_result) + + @staticmethod + async def send_state_snapshot_trace( + dialog_context: DialogContext, trace_label: str + ): + """ + Helper to send a trace activity with a memory snapshot of the active dialog DC. + :param dialog_context: + :param trace_label: + :return: + """ + # send trace of memory + snapshot = DialogManager.get_active_dialog_context( + dialog_context + ).state.get_memory_snapshot() + trace_activity = Activity.create_trace_activity( + "BotState", + "https://www.botframework.com/schemas/botState", + snapshot, + trace_label, + ) + await dialog_context.context.send_activity(trace_activity) + + @staticmethod + def is_from_parent_to_skill(turn_context: TurnContext) -> bool: + if turn_context.turn_state.get( + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY, None + ): + return False + + claims_identity: ClaimsIdentity = turn_context.turn_state.get( + BotAdapter.BOT_IDENTITY_KEY, None + ) + return isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims) + + # Recursively walk up the DC stack to find the active DC. + @staticmethod + def get_active_dialog_context(dialog_context: DialogContext) -> DialogContext: + """ + Recursively walk up the DC stack to find the active DC. + :param dialog_context: + :return: + """ + child = dialog_context.child + if not child: + return dialog_context + + return DialogManager.get_active_dialog_context(child) + + @staticmethod + def should_send_end_of_conversation_to_parent( + context: TurnContext, turn_result: DialogTurnResult + ) -> bool: + """ + Helper to determine if we should send an EndOfConversation to the parent or not. + :param context: + :param turn_result: + :return: + """ + if not ( + turn_result.status == DialogTurnStatus.Complete + or turn_result.status == DialogTurnStatus.Cancelled + ): + # The dialog is still going, don't return EoC. + return False + claims_identity: ClaimsIdentity = context.turn_state.get( + BotAdapter.BOT_IDENTITY_KEY, None + ) + if isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims): + # EoC Activities returned by skills are bounced back to the bot by SkillHandler. + # In those cases we will have a SkillConversationReference instance in state. + skill_conversation_reference: SkillConversationReference = context.turn_state.get( + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ) + if skill_conversation_reference: + # If the skill_conversation_reference.OAuthScope is for one of the supported channels, we are at the + # root and we should not send an EoC. + return skill_conversation_reference.oauth_scope not in ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ) + + return True + + return False + + async def handle_skill_on_turn( + self, dialog_context: DialogContext + ) -> DialogTurnResult: + # the bot is running as a skill. + turn_context = dialog_context.context + + # Process remote cancellation + if ( + turn_context.activity.type == ActivityTypes.end_of_conversation + and dialog_context.active_dialog is not None + and self.is_from_parent_to_skill(turn_context) + ): + # Handle remote cancellation request from parent. + active_dialog_context = self.get_active_dialog_context(dialog_context) + + remote_cancel_text = "Skill was canceled through an EndOfConversation activity from the parent." + await turn_context.send_trace_activity( + f"{self.__class__.__name__}.on_turn_async()", + label=f"{remote_cancel_text}", + ) + + # Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the + # right order. + return await active_dialog_context.cancel_all_dialogs(True) + + # Handle reprompt + # Process a reprompt event sent from the parent. + if ( + turn_context.activity.type == ActivityTypes.event + and turn_context.activity.name == DialogEvents.reprompt_dialog + ): + if not dialog_context.active_dialog: + return DialogTurnResult(DialogTurnStatus.Empty) + + await dialog_context.reprompt_dialog() + return DialogTurnResult(DialogTurnStatus.Waiting) + + # Continue execution + # - This will apply any queued up interruptions and execute the current/next step(s). + turn_result = await dialog_context.continue_dialog() + if turn_result.status == DialogTurnStatus.Empty: + # restart root dialog + start_message_text = f"Starting {self._root_dialog_id}." + await turn_context.send_trace_activity( + f"{self.__class__.__name__}.handle_skill_on_turn_async()", + label=f"{start_message_text}", + ) + turn_result = await dialog_context.begin_dialog(self._root_dialog_id) + + await DialogManager.send_state_snapshot_trace(dialog_context, "Skill State") + + if self.should_send_end_of_conversation_to_parent(turn_context, turn_result): + end_message_text = f"Dialog {self._root_dialog_id} has **completed**. Sending EndOfConversation." + await turn_context.send_trace_activity( + f"{self.__class__.__name__}.handle_skill_on_turn_async()", + label=f"{end_message_text}", + value=turn_result.result, + ) + + # Send End of conversation at the end. + activity = Activity( + type=ActivityTypes.end_of_conversation, + value=turn_result.result, + locale=turn_context.activity.locale, + ) + await turn_context.send_activity(activity) + + return turn_result + + async def handle_bot_on_turn( + self, dialog_context: DialogContext + ) -> DialogTurnResult: + # the bot is running as a root bot. + if dialog_context.active_dialog is None: + # start root dialog + turn_result = await dialog_context.begin_dialog(self._root_dialog_id) + else: + # Continue execution + # - This will apply any queued up interruptions and execute the current/next step(s). + turn_result = await dialog_context.continue_dialog() + + if turn_result.status == DialogTurnStatus.Empty: + # restart root dialog + turn_result = await dialog_context.begin_dialog(self._root_dialog_id) + + await self.send_state_snapshot_trace(dialog_context, "Bot State") + + return turn_result diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py new file mode 100644 index 000000000..c184f0df2 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.schema import Activity + +from .dialog_turn_result import DialogTurnResult +from .persisted_state import PersistedState + + +class DialogManagerResult: + def __init__( + self, + turn_result: DialogTurnResult = None, + activities: List[Activity] = None, + persisted_state: PersistedState = None, + ): + self.turn_result = turn_result + self.activities = activities + self.persisted_state = persisted_state diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index d6870128a..5820a3422 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -1,16 +1,17 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import inspect +from hashlib import sha256 from typing import Dict from botbuilder.core import TurnContext, BotAssert, StatePropertyAccessor from .dialog import Dialog from .dialog_state import DialogState -from .dialog_context import DialogContext class DialogSet: def __init__(self, dialog_state: StatePropertyAccessor = None): + # pylint: disable=import-outside-toplevel if dialog_state is None: frame = inspect.currentframe().f_back try: @@ -20,10 +21,13 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): except KeyError: raise TypeError("DialogSet(): dialog_state cannot be None.") # Only ComponentDialog can initialize with None dialog_state - # pylint: disable=import-outside-toplevel from .component_dialog import ComponentDialog + from .dialog_manager import DialogManager + from .dialog_container import DialogContainer - if not isinstance(self_obj, ComponentDialog): + if not isinstance( + self_obj, (ComponentDialog, DialogContainer, DialogManager) + ): raise TypeError("DialogSet(): dialog_state cannot be None.") finally: # make sure to clean up the frame at the end to avoid ref cycles @@ -32,7 +36,24 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): self._dialog_state = dialog_state # self.__telemetry_client = NullBotTelemetryClient.Instance; - self._dialogs: Dict[str, object] = {} + self._dialogs: Dict[str, Dialog] = {} + self._version: str = None + + def get_version(self) -> str: + """ + Gets a unique string which represents the combined versions of all dialogs in this this dialogset. + Version will change when any of the child dialogs version changes. + """ + if not self._version: + version = "" + for _, dialog in self._dialogs.items(): + aux_version = dialog.get_version() + if aux_version: + version += aux_version + + self._version = sha256(version) + + return self._version def add(self, dialog: Dialog): """ @@ -55,7 +76,11 @@ def add(self, dialog: Dialog): return self - async def create_context(self, turn_context: TurnContext) -> DialogContext: + async def create_context(self, turn_context: TurnContext) -> "DialogContext": + # This import prevents circular dependency issues + # pylint: disable=import-outside-toplevel + from .dialog_context import DialogContext + # pylint: disable=unnecessary-lambda BotAssert.context_not_none(turn_context) @@ -64,7 +89,9 @@ async def create_context(self, turn_context: TurnContext) -> DialogContext: "DialogSet.CreateContextAsync(): DialogSet created with a null IStatePropertyAccessor." ) - state = await self._dialog_state.get(turn_context, lambda: DialogState()) + state: DialogState = await self._dialog_state.get( + turn_context, lambda: DialogState() + ) return DialogContext(self, turn_context, state) @@ -82,6 +109,20 @@ async def find(self, dialog_id: str) -> Dialog: return None + def find_dialog(self, dialog_id: str) -> Dialog: + """ + Finds a dialog that was previously added to the set using add(dialog) + :param dialog_id: ID of the dialog/prompt to look up. + :return: The dialog if found, otherwise null. + """ + if not dialog_id: + raise TypeError("DialogContext.find(): dialog_id cannot be None.") + + if dialog_id in self._dialogs: + return self._dialogs[dialog_id] + + return None + def __str__(self): if self._dialogs: return "dialog set empty!" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py new file mode 100644 index 000000000..acbddd1e0 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Iterable + +from botbuilder.core import ComponentRegistration + +from botbuilder.dialogs.memory import ( + ComponentMemoryScopesBase, + ComponentPathResolversBase, + PathResolverBase, +) +from botbuilder.dialogs.memory.scopes import ( + TurnMemoryScope, + SettingsMemoryScope, + DialogMemoryScope, + DialogContextMemoryScope, + DialogClassMemoryScope, + ClassMemoryScope, + MemoryScope, + ThisMemoryScope, + ConversationMemoryScope, + UserMemoryScope, +) + +from botbuilder.dialogs.memory.path_resolvers import ( + AtAtPathResolver, + AtPathResolver, + DollarPathResolver, + HashPathResolver, + PercentPathResolver, +) + + +class DialogsComponentRegistration( + ComponentRegistration, ComponentMemoryScopesBase, ComponentPathResolversBase +): + def get_memory_scopes(self) -> Iterable[MemoryScope]: + yield TurnMemoryScope() + yield SettingsMemoryScope() + yield DialogMemoryScope() + yield DialogContextMemoryScope() + yield DialogClassMemoryScope() + yield ClassMemoryScope() + yield ThisMemoryScope() + yield ConversationMemoryScope() + yield UserMemoryScope() + + def get_path_resolvers(self) -> Iterable[PathResolverBase]: + yield AtAtPathResolver() + yield AtPathResolver() + yield DollarPathResolver() + yield HashPathResolver() + yield PercentPathResolver() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py new file mode 100644 index 000000000..a43b4cfb8 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py @@ -0,0 +1,24 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .dialog_path import DialogPath +from .dialog_state_manager import DialogStateManager +from .dialog_state_manager_configuration import DialogStateManagerConfiguration +from .component_memory_scopes_base import ComponentMemoryScopesBase +from .component_path_resolvers_base import ComponentPathResolversBase +from .path_resolver_base import PathResolverBase +from . import scope_path + +__all__ = [ + "DialogPath", + "DialogStateManager", + "DialogStateManagerConfiguration", + "ComponentMemoryScopesBase", + "ComponentPathResolversBase", + "PathResolverBase", + "scope_path", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py new file mode 100644 index 000000000..428e631ff --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from abc import ABC, abstractmethod +from typing import Iterable + +from botbuilder.dialogs.memory.scopes import MemoryScope + + +class ComponentMemoryScopesBase(ABC): + @abstractmethod + def get_memory_scopes(self) -> Iterable[MemoryScope]: + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py new file mode 100644 index 000000000..4c3c0ec73 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from abc import ABC, abstractmethod +from typing import Iterable + +from .path_resolver_base import PathResolverBase + + +class ComponentPathResolversBase(ABC): + @abstractmethod + def get_path_resolvers(self) -> Iterable[PathResolverBase]: + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py new file mode 100644 index 000000000..be11cb2fb --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class DialogPath: + # Counter of emitted events. + EVENT_COUNTER = "dialog.eventCounter" + + # Currently expected properties. + EXPECTED_PROPERTIES = "dialog.expectedProperties" + + # Default operation to use for entities where there is no identified operation entity. + DEFAULT_OPERATION = "dialog.defaultOperation" + + # Last surfaced entity ambiguity event. + LAST_EVENT = "dialog.lastEvent" + + # Currently required properties. + REQUIRED_PROPERTIES = "dialog.requiredProperties" + + # Number of retries for the current Ask. + RETRIES = "dialog.retries" + + # Last intent. + LAST_INTENT = "dialog.lastIntent" + + # Last trigger event: defined in FormEvent, ask, clarifyEntity etc.. + LAST_TRIGGER_EVENT = "dialog.lastTriggerEvent" + + @staticmethod + def get_property_name(prop: str) -> str: + return prop.replace("dialog.", "") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py new file mode 100644 index 000000000..0610f3ac5 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -0,0 +1,660 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import builtins + +from inspect import isawaitable +from traceback import print_tb +from typing import ( + Callable, + Dict, + Iterable, + Iterator, + List, + Tuple, + Type, + TypeVar, +) + +from botbuilder.core import ComponentRegistration + +from botbuilder.dialogs.memory.scopes import MemoryScope + +from .component_memory_scopes_base import ComponentMemoryScopesBase +from .component_path_resolvers_base import ComponentPathResolversBase +from .dialog_path import DialogPath +from .dialog_state_manager_configuration import DialogStateManagerConfiguration + +# Declare type variable +T = TypeVar("T") # pylint: disable=invalid-name + +BUILTIN_TYPES = list(filter(lambda x: not x.startswith("_"), dir(builtins))) + + +# +# The DialogStateManager manages memory scopes and pathresolvers +# MemoryScopes are named root level objects, which can exist either in the dialogcontext or off of turn state +# PathResolvers allow for shortcut behavior for mapping things like $foo -> dialog.foo. +# +class DialogStateManager: + + SEPARATORS = [",", "["] + + def __init__( + self, + dialog_context: "DialogContext", + configuration: DialogStateManagerConfiguration = None, + ): + """ + Initializes a new instance of the DialogStateManager class. + :param dialog_context: The dialog context for the current turn of the conversation. + :param configuration: Configuration for the dialog state manager. Default is None. + """ + # pylint: disable=import-outside-toplevel + # These modules are imported at static level to avoid circular dependency problems + from botbuilder.dialogs import ( + DialogsComponentRegistration, + ObjectPath, + ) + + self._object_path_cls = ObjectPath + self._dialog_component_registration_cls = DialogsComponentRegistration + + # Information for tracking when path was last modified. + self.path_tracker = "dialog._tracker.paths" + + self._dialog_context = dialog_context + self._version: int = 0 + + ComponentRegistration.add(self._dialog_component_registration_cls()) + + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + self._configuration = configuration or dialog_context.context.turn_state.get( + DialogStateManagerConfiguration.__name__, None + ) + if not self._configuration: + self._configuration = DialogStateManagerConfiguration() + + # get all of the component memory scopes + memory_component: ComponentMemoryScopesBase + for memory_component in filter( + lambda comp: isinstance(comp, ComponentMemoryScopesBase), + ComponentRegistration.get_components(), + ): + for memory_scope in memory_component.get_memory_scopes(): + self._configuration.memory_scopes.append(memory_scope) + + # get all of the component path resolvers + path_component: ComponentPathResolversBase + for path_component in filter( + lambda comp: isinstance(comp, ComponentPathResolversBase), + ComponentRegistration.get_components(), + ): + for path_resolver in path_component.get_path_resolvers(): + self._configuration.path_resolvers.append(path_resolver) + + # cache for any other new dialog_state_manager instances in this turn. + dialog_context.context.turn_state[ + self._configuration.__class__.__name__ + ] = self._configuration + + def __len__(self) -> int: + """ + Gets the number of memory scopes in the dialog state manager. + :return: Number of memory scopes in the configuration. + """ + return len(self._configuration.memory_scopes) + + @property + def configuration(self) -> DialogStateManagerConfiguration: + """ + Gets or sets the configured path resolvers and memory scopes for the dialog state manager. + :return: The configuration object. + """ + return self._configuration + + @property + def keys(self) -> Iterable[str]: + """ + Gets a Iterable containing the keys of the memory scopes + :return: Keys of the memory scopes. + """ + return [memory_scope.name for memory_scope in self.configuration.memory_scopes] + + @property + def values(self) -> Iterable[object]: + """ + Gets a Iterable containing the values of the memory scopes. + :return: Values of the memory scopes. + """ + return [ + memory_scope.get_memory(self._dialog_context) + for memory_scope in self.configuration.memory_scopes + ] + + # + # Gets a value indicating whether the dialog state manager is read-only. + # + # true. + @property + def is_read_only(self) -> bool: + """ + Gets a value indicating whether the dialog state manager is read-only. + :return: True. + """ + return True + + # + # Gets or sets the elements with the specified key. + # + # Key to get or set the element. + # The element with the specified key. + def __getitem__(self, key): + """ + :param key: + :return The value stored at key's position: + """ + return self.get_value(object, key, default_value=lambda: None) + + def __setitem__(self, key, value): + if self._index_of_any(key, self.SEPARATORS) == -1: + # Root is handled by SetMemory rather than SetValue + scope = self.get_memory_scope(key) + if not scope: + raise IndexError(self._get_bad_scope_message(key)) + # TODO: C# transforms value to JToken + scope.set_memory(self._dialog_context, value) + else: + self.set_value(key, value) + + def _get_bad_scope_message(self, path: str) -> str: + return ( + f"'{path}' does not match memory scopes:[" + f"{', '.join((memory_scope.name for memory_scope in self.configuration.memory_scopes))}]" + ) + + @staticmethod + def _index_of_any(string: str, elements_to_search_for) -> int: + for element in elements_to_search_for: + index = string.find(element) + if index != -1: + return index + + return -1 + + def get_memory_scope(self, name: str) -> MemoryScope: + """ + Get MemoryScope by name. + :param name: + :return: A memory scope. + """ + if not name: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + return next( + ( + memory_scope + for memory_scope in self.configuration.memory_scopes + if memory_scope.name.lower() == name.lower() + ), + None, + ) + + def version(self) -> str: + """ + Version help caller to identify the updates and decide cache or not. + :return: Current version. + """ + return str(self._version) + + def resolve_memory_scope(self, path: str) -> Tuple[MemoryScope, str]: + """ + Will find the MemoryScope for and return the remaining path. + :param path: + :return: The memory scope and remaining subpath in scope. + """ + scope = path + sep_index = -1 + dot = path.find(".") + open_square_bracket = path.find("[") + + if dot > 0 and open_square_bracket > 0: + sep_index = min(dot, open_square_bracket) + + elif dot > 0: + sep_index = dot + + elif open_square_bracket > 0: + sep_index = open_square_bracket + + if sep_index > 0: + scope = path[0:sep_index] + memory_scope = self.get_memory_scope(scope) + if memory_scope: + remaining_path = path[sep_index + 1 :] + return memory_scope, remaining_path + + memory_scope = self.get_memory_scope(scope) + if not scope: + raise IndexError(self._get_bad_scope_message(scope)) + return memory_scope, "" + + def transform_path(self, path: str) -> str: + """ + Transform the path using the registered PathTransformers. + :param path: Path to transform. + :return: The transformed path. + """ + for path_resolver in self.configuration.path_resolvers: + path = path_resolver.transform_path(path) + + return path + + @staticmethod + def _is_primitive(type_to_check: Type) -> bool: + return type_to_check.__name__ in BUILTIN_TYPES + + def try_get_value( + self, path: str, class_type: Type = object + ) -> Tuple[bool, object]: + """ + Get the value from memory using path expression (NOTE: This always returns clone of value). + :param class_type: The value type to return. + :param path: Path expression to use. + :return: True if found, false if not and the value. + """ + if not path: + raise TypeError(f"Expecting: {str.__name__}, but received None") + return_value = ( + class_type() if DialogStateManager._is_primitive(class_type) else None + ) + path = self.transform_path(path) + + try: + memory_scope, remaining_path = self.resolve_memory_scope(path) + except Exception as error: + print_tb(error.__traceback__) + return False, return_value + + if not memory_scope: + return False, return_value + + if not remaining_path: + memory = memory_scope.get_memory(self._dialog_context) + if not memory: + return False, return_value + + return True, memory + + # TODO: HACK to support .First() retrieval on turn.recognized.entities.foo, replace with Expressions once + # expressions ship + first = ".FIRST()" + i_first = path.upper().rindex(first) + if i_first >= 0: + remaining_path = path[i_first + len(first) :] + path = path[0:i_first] + success, first_value = self._try_get_first_nested_value(path, self) + if success: + if not remaining_path: + return True, first_value + + path_value = self._object_path_cls.try_get_path_value( + first_value, remaining_path + ) + return bool(path_value), path_value + + return False, return_value + + path_value = self._object_path_cls.try_get_path_value(self, path) + return bool(path_value), path_value + + def get_value( + self, + class_type: Type, + path_expression: str, + default_value: Callable[[], T] = None, + ) -> T: + """ + Get the value from memory using path expression (NOTE: This always returns clone of value). + :param class_type: The value type to return. + :param path_expression: Path expression to use. + :param default_value: Function to give default value if there is none (OPTIONAL). + :return: Result or null if the path is not valid. + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + success, value = self.try_get_value(path_expression, class_type) + if success: + return value + + return default_value() if default_value else None + + def get_int_value(self, path_expression: str, default_value: int = 0) -> int: + """ + Get an int value from memory using a path expression. + :param path_expression: Path expression to use. + :param default_value: Default value if there is none (OPTIONAL). + :return: + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + success, value = self.try_get_value(path_expression, int) + if success: + return value + + return default_value + + def get_bool_value(self, path_expression: str, default_value: bool = False) -> bool: + """ + Get a bool value from memory using a path expression. + :param path_expression: Path expression to use. + :param default_value: Default value if there is none (OPTIONAL). + :return: + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + success, value = self.try_get_value(path_expression, bool) + if success: + return value + + return default_value + + def get_string_value(self, path_expression: str, default_value: str = "") -> str: + """ + Get a string value from memory using a path expression. + :param path_expression: Path expression to use. + :param default_value: Default value if there is none (OPTIONAL). + :return: + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + success, value = self.try_get_value(path_expression, str) + if success: + return value + + return default_value + + def set_value(self, path: str, value: object): + """ + Set memory to value. + :param path: Path to memory. + :param value: Object to set. + :return: + """ + if isawaitable(value): + raise Exception(f"{path} = You can't pass an awaitable to set_value") + + if not path: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + path = self.transform_path(path) + if self._track_change(path, value): + self._object_path_cls.set_path_value(self, path, value) + + # Every set will increase version + self._version += 1 + + def remove_value(self, path: str): + """ + Set memory to value. + :param path: Path to memory. + :param value: Object to set. + :return: + """ + if not path: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + path = self.transform_path(path) + if self._track_change(path, None): + self._object_path_cls.remove_path_value(self, path) + + def get_memory_snapshot(self) -> Dict[str, object]: + """ + Gets all memoryscopes suitable for logging. + :return: object which represents all memory scopes. + """ + result = {} + + for scope in [ + ms for ms in self.configuration.memory_scopes if ms.include_in_snapshot + ]: + memory = scope.get_memory(self._dialog_context) + if memory: + result[scope.name] = memory + + return result + + async def load_all_scopes(self): + """ + Load all of the scopes. + :return: + """ + for scope in self.configuration.memory_scopes: + await scope.load(self._dialog_context) + + async def save_all_changes(self): + """ + Save all changes for all scopes. + :return: + """ + for scope in self.configuration.memory_scopes: + await scope.save_changes(self._dialog_context) + + async def delete_scopes_memory_async(self, name: str): + """ + Delete the memory for a scope. + :param name: name of the scope. + :return: + """ + name = name.upper() + scope_list = [ + ms for ms in self.configuration.memory_scopes if ms.name.upper == name + ] + if len(scope_list) > 1: + raise RuntimeError(f"More than 1 scopes found with the name '{name}'") + scope = scope_list[0] if scope_list else None + if scope: + await scope.delete(self._dialog_context) + + def add(self, key: str, value: object): + """ + Adds an element to the dialog state manager. + :param key: Key of the element to add. + :param value: Value of the element to add. + :return: + """ + raise RuntimeError("Not supported") + + def contains_key(self, key: str) -> bool: + """ + Determines whether the dialog state manager contains an element with the specified key. + :param key: The key to locate in the dialog state manager. + :return: True if the dialog state manager contains an element with the key otherwise, False. + """ + scopes_with_key = [ + ms + for ms in self.configuration.memory_scopes + if ms.name.upper == key.upper() + ] + return bool(scopes_with_key) + + def remove(self, key: str): + """ + Removes the element with the specified key from the dialog state manager. + :param key: Key of the element to remove. + :return: + """ + raise RuntimeError("Not supported") + + # + # Removes all items from the dialog state manager. + # + # This method is not supported. + def clear(self, key: str): + """ + Removes all items from the dialog state manager. + :param key: Key of the element to remove. + :return: + """ + raise RuntimeError("Not supported") + + def contains(self, item: Tuple[str, object]) -> bool: + """ + Determines whether the dialog state manager contains a specific value (should use __contains__). + :param item: The tuple of the item to locate. + :return bool: True if item is found in the dialog state manager otherwise, False + """ + raise RuntimeError("Not supported") + + def __contains__(self, item: Tuple[str, object]) -> bool: + """ + Determines whether the dialog state manager contains a specific value. + :param item: The tuple of the item to locate. + :return bool: True if item is found in the dialog state manager otherwise, False + """ + raise RuntimeError("Not supported") + + def copy_to(self, array: List[Tuple[str, object]], array_index: int): + """ + Copies the elements of the dialog state manager to an array starting at a particular index. + :param array: The one-dimensional array that is the destination of the elements copied + from the dialog state manager. The array must have zero-based indexing. + :param array_index: + :return: + """ + for memory_scope in self.configuration.memory_scopes: + array[array_index] = ( + memory_scope.name, + memory_scope.get_memory(self._dialog_context), + ) + array_index += 1 + + def remove_item(self, item: Tuple[str, object]) -> bool: + """ + Determines whether the dialog state manager contains a specific value (should use __contains__). + :param item: The tuple of the item to locate. + :return bool: True if item is found in the dialog state manager otherwise, False + """ + raise RuntimeError("Not supported") + + # + # Returns an enumerator that iterates through the collection. + # + # An enumerator that can be used to iterate through the collection. + def get_enumerator(self) -> Iterator[Tuple[str, object]]: + """ + Returns an enumerator that iterates through the collection. + :return: An enumerator that can be used to iterate through the collection. + """ + for memory_scope in self.configuration.memory_scopes: + yield (memory_scope.name, memory_scope.get_memory(self._dialog_context)) + + def track_paths(self, paths: Iterable[str]) -> List[str]: + """ + Track when specific paths are changed. + :param paths: Paths to track. + :return: Normalized paths to pass to any_path_changed. + """ + all_paths = [] + for path in paths: + t_path = self.transform_path(path) + + # Track any path that resolves to a constant path + segments = self._object_path_cls.try_resolve_path(self, t_path) + if segments: + n_path = "_".join(segments) + self.set_value(self.path_tracker + "." + n_path, 0) + all_paths.append(n_path) + + return all_paths + + def any_path_changed(self, counter: int, paths: Iterable[str]) -> bool: + """ + Check to see if any path has changed since watermark. + :param counter: Time counter to compare to. + :param paths: Paths from track_paths to check. + :return: True if any path has changed since counter. + """ + found = False + if paths: + for path in paths: + if self.get_value(int, self.path_tracker + "." + path) > counter: + found = True + break + + return found + + def __iter__(self): + for memory_scope in self.configuration.memory_scopes: + yield (memory_scope.name, memory_scope.get_memory(self._dialog_context)) + + @staticmethod + def _try_get_first_nested_value( + remaining_path: str, memory: object + ) -> Tuple[bool, object]: + # These modules are imported at static level to avoid circular dependency problems + # pylint: disable=import-outside-toplevel + + from botbuilder.dialogs import ObjectPath + + array = ObjectPath.try_get_path_value(memory, remaining_path) + if array: + if isinstance(array[0], list): + first = array[0] + if first: + second = first[0] + return True, second + + return False, None + + return True, array[0] + + return False, None + + def _track_change(self, path: str, value: object) -> bool: + has_path = False + segments = self._object_path_cls.try_resolve_path(self, path) + if segments: + root = segments[1] if len(segments) > 1 else "" + + # Skip _* as first scope, i.e. _adaptive, _tracker, ... + if not root.startswith("_"): + # Convert to a simple path with _ between segments + path_name = "_".join(segments) + tracked_path = f"{self.path_tracker}.{path_name}" + counter = None + + def update(): + nonlocal counter + last_changed = self.try_get_value(tracked_path, int) + if last_changed: + if counter is not None: + counter = self.get_value(int, DialogPath.EVENT_COUNTER) + + self.set_value(tracked_path, counter) + + update() + if not self._is_primitive(type(value)): + # For an object we need to see if any children path are being tracked + def check_children(property: str, instance: object): + nonlocal tracked_path + # Add new child segment + tracked_path += "_" + property.lower() + update() + if not self._is_primitive(type(instance)): + self._object_path_cls.for_each_property( + property, check_children + ) + + # Remove added child segment + tracked_path = tracked_path.Substring( + 0, tracked_path.LastIndexOf("_") + ) + + self._object_path_cls.for_each_property(value, check_children) + + has_path = True + + return has_path diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py new file mode 100644 index 000000000..b1565a53d --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py @@ -0,0 +1,10 @@ +from typing import List + +from botbuilder.dialogs.memory.scopes import MemoryScope +from .path_resolver_base import PathResolverBase + + +class DialogStateManagerConfiguration: + def __init__(self): + self.path_resolvers: List[PathResolverBase] = list() + self.memory_scopes: List[MemoryScope] = list() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py new file mode 100644 index 000000000..42b80c93f --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py @@ -0,0 +1,7 @@ +from abc import ABC, abstractmethod + + +class PathResolverBase(ABC): + @abstractmethod + def transform_path(self, path: str): + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py new file mode 100644 index 000000000..b22ac063a --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from .alias_path_resolver import AliasPathResolver +from .at_at_path_resolver import AtAtPathResolver +from .at_path_resolver import AtPathResolver +from .dollar_path_resolver import DollarPathResolver +from .hash_path_resolver import HashPathResolver +from .percent_path_resolver import PercentPathResolver + +__all__ = [ + "AliasPathResolver", + "AtAtPathResolver", + "AtPathResolver", + "DollarPathResolver", + "HashPathResolver", + "PercentPathResolver", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py new file mode 100644 index 000000000..b16930284 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import PathResolverBase + + +class AliasPathResolver(PathResolverBase): + def __init__(self, alias: str, prefix: str, postfix: str = None): + """ + Initializes a new instance of the class. + Alias name. + Prefix name. + Postfix name. + """ + if alias is None: + raise TypeError(f"Expecting: alias, but received None") + if prefix is None: + raise TypeError(f"Expecting: prefix, but received None") + + # Gets the alias name. + self.alias = alias.strip() + self._prefix = prefix.strip() + self._postfix = postfix.strip() if postfix else "" + + def transform_path(self, path: str): + """ + Transforms the path. + Path to inspect. + Transformed path. + """ + if not path: + raise TypeError(f"Expecting: path, but received None") + + path = path.strip() + if ( + path.startswith(self.alias) + and len(path) > len(self.alias) + and AliasPathResolver._is_path_char(path[len(self.alias)]) + ): + # here we only deals with trailing alias, alias in middle be handled in further breakdown + # $xxx -> path.xxx + return f"{self._prefix}{path[len(self.alias):]}{self._postfix}".rstrip(".") + + return path + + @staticmethod + def _is_path_char(char: str) -> bool: + """ + Verifies if a character is valid for a path. + Character to verify. + true if the character is valid for a path otherwise, false. + """ + return len(char) == 1 and (char.isalpha() or char == "_") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py new file mode 100644 index 000000000..d440c040a --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class AtAtPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="@@", prefix="turn.recognized.entities.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py new file mode 100644 index 000000000..91bbb6564 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class AtPathResolver(AliasPathResolver): + + _DELIMITERS = [".", "["] + + def __init__(self): + super().__init__(alias="@", prefix="") + + self._PREFIX = "turn.recognized.entities." # pylint: disable=invalid-name + + def transform_path(self, path: str): + if not path: + raise TypeError(f"Expecting: path, but received None") + + path = path.strip() + if ( + path.startswith("@") + and len(path) > 1 + and AtPathResolver._is_path_char(path[1]) + ): + end = any(delimiter in path for delimiter in AtPathResolver._DELIMITERS) + if end == -1: + end = len(path) + + prop = path[1:end] + suffix = path[end:] + path = f"{self._PREFIX}{prop}.first(){suffix}" + + return path + + @staticmethod + def _index_of_any(string: str, elements_to_search_for) -> int: + for element in elements_to_search_for: + index = string.find(element) + if index != -1: + return index + + return -1 diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py new file mode 100644 index 000000000..8152d23c5 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class DollarPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="$", prefix="dialog.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py new file mode 100644 index 000000000..b00376e59 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class HashPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="#", prefix="turn.recognized.intents.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py new file mode 100644 index 000000000..dd0fa2e17 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class PercentPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="%", prefix="class.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py new file mode 100644 index 000000000..faf906699 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# User memory scope root path. +# This property is deprecated, use ScopePath.User instead. +USER = "user" + +# Conversation memory scope root path. +# This property is deprecated, use ScopePath.Conversation instead.This property is deprecated, use ScopePath.Dialog instead.This property is deprecated, use ScopePath.DialogClass instead.This property is deprecated, use ScopePath.This instead.This property is deprecated, use ScopePath.Class instead. +CLASS = "class" + +# Settings memory scope root path. +# This property is deprecated, use ScopePath.Settings instead. + +SETTINGS = "settings" + +# Turn memory scope root path. +# This property is deprecated, use ScopePath.Turn instead. +TURN = "turn" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py new file mode 100644 index 000000000..ec2e2b61c --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py @@ -0,0 +1,32 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from .bot_state_memory_scope import BotStateMemoryScope +from .class_memory_scope import ClassMemoryScope +from .conversation_memory_scope import ConversationMemoryScope +from .dialog_class_memory_scope import DialogClassMemoryScope +from .dialog_context_memory_scope import DialogContextMemoryScope +from .dialog_memory_scope import DialogMemoryScope +from .memory_scope import MemoryScope +from .settings_memory_scope import SettingsMemoryScope +from .this_memory_scope import ThisMemoryScope +from .turn_memory_scope import TurnMemoryScope +from .user_memory_scope import UserMemoryScope + + +__all__ = [ + "BotStateMemoryScope", + "ClassMemoryScope", + "ConversationMemoryScope", + "DialogClassMemoryScope", + "DialogContextMemoryScope", + "DialogMemoryScope", + "MemoryScope", + "SettingsMemoryScope", + "ThisMemoryScope", + "TurnMemoryScope", + "UserMemoryScope", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py new file mode 100644 index 000000000..088c7a0fb --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Type + +from botbuilder.core import BotState + +from .memory_scope import MemoryScope + + +class BotStateMemoryScope(MemoryScope): + def __init__(self, bot_state_type: Type[BotState], name: str): + super().__init__(name, include_in_snapshot=True) + self.bot_state_type = bot_state_type + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + bot_state: BotState = self._get_bot_state(dialog_context) + cached_state = ( + bot_state.get_cached_state(dialog_context.context) if bot_state else None + ) + + return cached_state.state if cached_state else None + + def set_memory(self, dialog_context: "DialogContext", memory: object): + raise RuntimeError("You cannot replace the root BotState object") + + async def load(self, dialog_context: "DialogContext", force: bool = False): + bot_state: BotState = self._get_bot_state(dialog_context) + + if bot_state: + await bot_state.load(dialog_context.context, force) + + async def save_changes(self, dialog_context: "DialogContext", force: bool = False): + bot_state: BotState = self._get_bot_state(dialog_context) + + if bot_state: + await bot_state.save_changes(dialog_context.context, force) + + def _get_bot_state(self, dialog_context: "DialogContext") -> BotState: + return dialog_context.context.turn_state.get(self.bot_state_type.__name__, None) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py new file mode 100644 index 000000000..1589ac152 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from collections import namedtuple + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class ClassMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.SETTINGS, include_in_snapshot=False) + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + # if active dialog is a container dialog then "dialogclass" binds to it. + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) + if dialog: + return ClassMemoryScope._bind_to_dialog_context(dialog, dialog_context) + + return None + + def set_memory(self, dialog_context: "DialogContext", memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) + + @staticmethod + def _bind_to_dialog_context(obj, dialog_context: "DialogContext") -> object: + clone = {} + for prop in dir(obj): + # don't process double underscore attributes + if prop[:1] != "_": + prop_value = getattr(obj, prop) + if not callable(prop_value): + # the only objects + if hasattr(prop_value, "try_get_value"): + clone[prop] = prop_value.try_get_value(dialog_context.state) + elif hasattr(prop_value, "__dict__") and not isinstance( + prop_value, type + ): + clone[prop] = ClassMemoryScope._bind_to_dialog_context( + prop_value, dialog_context + ) + else: + clone[prop] = prop_value + if clone: + ReadOnlyObject = namedtuple( # pylint: disable=invalid-name + "ReadOnlyObject", clone + ) + return ReadOnlyObject(**clone) + + return None diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py new file mode 100644 index 000000000..2f88dd57a --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ConversationState +from botbuilder.dialogs.memory import scope_path + +from .bot_state_memory_scope import BotStateMemoryScope + + +class ConversationMemoryScope(BotStateMemoryScope): + def __init__(self): + super().__init__(ConversationState, scope_path.CONVERSATION) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py new file mode 100644 index 000000000..b363d1065 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import deepcopy + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class DialogClassMemoryScope(MemoryScope): + def __init__(self): + # pylint: disable=import-outside-toplevel + super().__init__(scope_path.DIALOG_CLASS, include_in_snapshot=False) + + # This import is to avoid circular dependency issues + from botbuilder.dialogs import DialogContainer + + self._dialog_container_cls = DialogContainer + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + # if active dialog is a container dialog then "dialogclass" binds to it. + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) + if isinstance(dialog, self._dialog_container_cls): + return deepcopy(dialog) + + # Otherwise we always bind to parent, or if there is no parent the active dialog + parent_id = ( + dialog_context.parent.active_dialog.id + if dialog_context.parent and dialog_context.parent.active_dialog + else None + ) + active_id = ( + dialog_context.active_dialog.id if dialog_context.active_dialog else None + ) + return deepcopy(dialog_context.find_dialog_sync(parent_id or active_id)) + + def set_memory(self, dialog_context: "DialogContext", memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py new file mode 100644 index 000000000..200f71b8c --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class DialogContextMemoryScope(MemoryScope): + def __init__(self): + # pylint: disable=invalid-name + + super().__init__(scope_path.SETTINGS, include_in_snapshot=False) + # Stack name. + self.STACK = "stack" + + # Active dialog name. + self.ACTIVE_DIALOG = "activeDialog" + + # Parent name. + self.PARENT = "parent" + + def get_memory(self, dialog_context: "DialogContext") -> object: + """ + Gets the backing memory for this scope. + The object for this turn. + Memory for the scope. + """ + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + # TODO: make sure that every object in the dict is serializable + memory = {} + stack = list([]) + current_dc = dialog_context + + # go to leaf node + while current_dc.child: + current_dc = current_dc.child + + while current_dc: + # (PORTERS NOTE: javascript stack is reversed with top of stack on end) + for item in current_dc.stack: + # filter out ActionScope items because they are internal bookkeeping. + if not item.id.startswith("ActionScope["): + stack.append(item.id) + + current_dc = current_dc.parent + + # top of stack is stack[0]. + memory[self.STACK] = stack + memory[self.ACTIVE_DIALOG] = ( + dialog_context.active_dialog.id if dialog_context.active_dialog else None + ) + memory[self.PARENT] = ( + dialog_context.parent.active_dialog.id + if dialog_context.parent and dialog_context.parent.active_dialog + else None + ) + return memory + + def set_memory(self, dialog_context: "DialogContext", memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py new file mode 100644 index 000000000..490ad23a1 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class DialogMemoryScope(MemoryScope): + def __init__(self): + # pylint: disable=import-outside-toplevel + super().__init__(scope_path.DIALOG) + + # This import is to avoid circular dependency issues + from botbuilder.dialogs import DialogContainer + + self._dialog_container_cls = DialogContainer + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + # if active dialog is a container dialog then "dialog" binds to it. + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) + if isinstance(dialog, self._dialog_container_cls): + return dialog_context.active_dialog.state + + # Otherwise we always bind to parent, or if there is no parent the active dialog + parent_state = ( + dialog_context.parent.active_dialog.state + if dialog_context.parent and dialog_context.parent.active_dialog + else None + ) + dc_state = ( + dialog_context.active_dialog.state if dialog_context.active_dialog else None + ) + return parent_state or dc_state + + def set_memory(self, dialog_context: "DialogContext", memory: object): + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + if not memory: + raise TypeError(f"Expecting: memory object, but received None") + + # If active dialog is a container dialog then "dialog" binds to it. + # Otherwise the "dialog" will bind to the dialogs parent assuming it + # is a container. + parent = dialog_context + if not self.is_container(parent) and self.is_container(parent.parent): + parent = parent.parent + + # If there's no active dialog then throw an error. + if not parent.active_dialog: + raise Exception( + "Cannot set DialogMemoryScope. There is no active dialog dialog or parent dialog in the context" + ) + + parent.active_dialog.state = memory + + def is_container(self, dialog_context: "DialogContext"): + if dialog_context and dialog_context.active_dialog: + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) + if isinstance(dialog, self._dialog_container_cls): + return True + + return False diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py new file mode 100644 index 000000000..3b00401fc --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + + +class MemoryScope(ABC): + def __init__(self, name: str, include_in_snapshot: bool = True): + # + # Gets or sets name of the scope. + # + # + # Name of the scope. + # + self.include_in_snapshot = include_in_snapshot + # + # Gets or sets a value indicating whether this memory should be included in snapshot. + # + # + # True or false. + # + self.name = name + + # + # Get the backing memory for this scope. + # + # dc. + # memory for the scope. + @abstractmethod + def get_memory( + self, dialog_context: "DialogContext" + ) -> object: # pylint: disable=unused-argument + raise NotImplementedError() + + # + # Changes the backing object for the memory scope. + # + # dc. + # memory. + @abstractmethod + def set_memory( + self, dialog_context: "DialogContext", memory: object + ): # pylint: disable=unused-argument + raise NotImplementedError() + + # + # Populates the state cache for this from the storage layer. + # + # The dialog context object for this turn. + # Optional, true to overwrite any existing state cache + # or false to load state from storage only if the cache doesn't already exist. + # A cancellation token that can be used by other objects + # or threads to receive notice of cancellation. + # A task that represents the work queued to execute. + async def load( + self, dialog_context: "DialogContext", force: bool = False + ): # pylint: disable=unused-argument + return + + # + # Writes the state cache for this to the storage layer. + # + # The dialog context object for this turn. + # Optional, true to save the state cache to storage + # or false to save state to storage only if a property in the cache has changed. + # A cancellation token that can be used by other objects + # or threads to receive notice of cancellation. + # A task that represents the work queued to execute. + async def save_changes( + self, dialog_context: "DialogContext", force: bool = False + ): # pylint: disable=unused-argument + return + + # + # Deletes any state in storage and the cache for this . + # + # The dialog context object for this turn. + # A cancellation token that can be used by other objects + # or threads to receive notice of cancellation. + # A task that represents the work queued to execute. + async def delete( + self, dialog_context: "DialogContext" + ): # pylint: disable=unused-argument + return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py new file mode 100644 index 000000000..790137aea --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class SettingsMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.SETTINGS) + self._empty_settings = {} + self.include_in_snapshot = False + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + settings: dict = dialog_context.context.turn_state.get( + scope_path.SETTINGS, None + ) + + if not settings: + settings = self._empty_settings + + return settings + + def set_memory(self, dialog_context: "DialogContext", memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py new file mode 100644 index 000000000..3de53bab3 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class ThisMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.THIS) + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + return ( + dialog_context.active_dialog.state if dialog_context.active_dialog else None + ) + + def set_memory(self, dialog_context: "DialogContext", memory: object): + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + if not memory: + raise TypeError(f"Expecting: object, but received None") + + dialog_context.active_dialog.state = memory diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py new file mode 100644 index 000000000..3773edf6b --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class CaseInsensitiveDict(dict): + # pylint: disable=protected-access + + @classmethod + def _k(cls, key): + return key.lower() if isinstance(key, str) else key + + def __init__(self, *args, **kwargs): + super(CaseInsensitiveDict, self).__init__(*args, **kwargs) + self._convert_keys() + + def __getitem__(self, key): + return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) + + def __setitem__(self, key, value): + super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) + + def __delitem__(self, key): + return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) + + def __contains__(self, key): + return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) + + def pop(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).pop( + self.__class__._k(key), *args, **kwargs + ) + + def get(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).get( + self.__class__._k(key), *args, **kwargs + ) + + def setdefault(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).setdefault( + self.__class__._k(key), *args, **kwargs + ) + + def update(self, e=None, **f): + if e is None: + e = {} + super(CaseInsensitiveDict, self).update(self.__class__(e)) + super(CaseInsensitiveDict, self).update(self.__class__(**f)) + + def _convert_keys(self): + for k in list(self.keys()): + val = super(CaseInsensitiveDict, self).pop(k) + self.__setitem__(k, val) + + +class TurnMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.TURN) + + def get_memory(self, dialog_context: "DialogContext") -> object: + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + turn_value = dialog_context.context.turn_state.get(scope_path.TURN, None) + + if not turn_value: + turn_value = CaseInsensitiveDict() + dialog_context.context.turn_state[scope_path.TURN] = turn_value + + return turn_value + + def set_memory(self, dialog_context: "DialogContext", memory: object): + if not dialog_context: + raise TypeError(f"Expecting: DialogContext, but received None") + + dialog_context.context.turn_state[scope_path.TURN] = memory diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py new file mode 100644 index 000000000..b1bc6351d --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import UserState +from botbuilder.dialogs.memory import scope_path + +from .bot_state_memory_scope import BotStateMemoryScope + + +class UserMemoryScope(BotStateMemoryScope): + def __init__(self): + super().__init__(UserState, scope_path.USER) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py index 6e6435582..80f722519 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py @@ -267,6 +267,15 @@ def emit(): return so_far + @staticmethod + def for_each_property(obj: object, action: Callable[[str, object], None]): + if isinstance(obj, dict): + for key, value in obj.items(): + action(key, value) + elif hasattr(obj, "__dict__"): + for key, value in vars(obj).items(): + action(key, value) + @staticmethod def __resolve_segments(current, segments: []) -> object: result = current diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py new file mode 100644 index 000000000..e4fc016e8 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from .persisted_state_keys import PersistedStateKeys + + +class PersistedState: + def __init__(self, keys: PersistedStateKeys = None, data: Dict[str, object] = None): + if keys and data: + self.user_state: Dict[str, object] = data[ + keys.user_state + ] if keys.user_state in data else {} + self.conversation_state: Dict[str, object] = data[ + keys.conversation_state + ] if keys.conversation_state in data else {} + else: + self.user_state: Dict[str, object] = {} + self.conversation_state: Dict[str, object] = {} diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py new file mode 100644 index 000000000..59f7c34cd --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class PersistedStateKeys: + def __init__(self): + self.user_state: str = None + self.conversation_state: str = None diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py new file mode 100644 index 000000000..5101c7070 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py @@ -0,0 +1,566 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from collections import namedtuple + +import aiounittest + +from botbuilder.core import ConversationState, MemoryStorage, TurnContext, UserState +from botbuilder.core.adapters import TestAdapter +from botbuilder.dialogs import ( + Dialog, + DialogContext, + DialogContainer, + DialogInstance, + DialogSet, + DialogState, + ObjectPath, +) +from botbuilder.dialogs.memory.scopes import ( + ClassMemoryScope, + ConversationMemoryScope, + DialogMemoryScope, + UserMemoryScope, + SettingsMemoryScope, + ThisMemoryScope, + TurnMemoryScope, +) +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) + + +class TestDialog(Dialog): + def __init__(self, id: str, message: str): + super().__init__(id) + + def aux_try_get_value(state): # pylint: disable=unused-argument + return "resolved value" + + ExpressionObject = namedtuple("ExpressionObject", "try_get_value") + self.message = message + self.expression = ExpressionObject(aux_try_get_value) + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + dialog_context.active_dialog.state["is_dialog"] = True + await dialog_context.context.send_activity(self.message) + return Dialog.end_of_turn + + +class TestContainer(DialogContainer): + def __init__(self, id: str, child: Dialog = None): + super().__init__(id) + self.child_id = None + if child: + self.dialogs.add(child) + self.child_id = child.id + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + state = dialog_context.active_dialog.state + state["is_container"] = True + if self.child_id: + state["dialog"] = DialogState() + child_dc = self.create_child_context(dialog_context) + return await child_dc.begin_dialog(self.child_id, options) + + return Dialog.end_of_turn + + async def continue_dialog(self, dialog_context: DialogContext): + child_dc = self.create_child_context(dialog_context) + if child_dc: + return await child_dc.continue_dialog() + + return Dialog.end_of_turn + + def create_child_context(self, dialog_context: DialogContext): + state = dialog_context.active_dialog.state + if state["dialog"] is not None: + child_dc = DialogContext( + self.dialogs, dialog_context.context, state["dialog"] + ) + child_dc.parent = dialog_context + return child_dc + + return None + + +class MemoryScopesTests(aiounittest.AsyncTestCase): + begin_message = Activity( + text="begin", + type=ActivityTypes.message, + channel_id="test", + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="bot"), + conversation=ConversationAccount(id="convo1"), + ) + + async def test_class_memory_scope_should_find_registered_dialog(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + await dialog_state.set( + context, DialogState(stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertTrue(memory, "memory not returned") + self.assertEqual("test message", memory.message) + self.assertEqual("resolved value", memory.expression) + + async def test_class_memory_scope_should_not_allow_set_memory_call(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + await dialog_state.set( + context, DialogState(stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + with self.assertRaises(Exception) as context: + scope.set_memory(dialog_context, {}) + + self.assertTrue("not supported" in str(context.exception)) + + async def test_class_memory_scope_should_not_allow_load_and_save_changes_calls( + self, + ): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + await dialog_state.set( + context, DialogState(stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + await scope.load(dialog_context) + memory = scope.get_memory(dialog_context) + with self.assertRaises(AttributeError) as context: + memory.message = "foo" + + self.assertTrue("can't set attribute" in str(context.exception)) + await scope.save_changes(dialog_context) + self.assertEqual("test message", dialog.message) + + async def test_conversation_memory_scope_should_return_conversation_state(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + context.turn_state["ConversationState"] = conversation_state + + dialog_context = await dialogs.create_context(context) + + # Initialize conversation state + foo_cls = namedtuple("TestObject", "foo") + conversation_prop = conversation_state.create_property("conversation") + await conversation_prop.set(context, foo_cls(foo="bar")) + await conversation_state.save_changes(context) + + # Run test + scope = ConversationMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertTrue(memory, "memory not returned") + + # TODO: Make get_path_value take conversation.foo + test_obj = ObjectPath.get_path_value(memory, "conversation") + self.assertEqual("bar", test_obj.foo) + + async def test_user_memory_scope_should_not_return_state_if_not_loaded(self): + # Initialize user state + storage = MemoryStorage() + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + foo_cls = namedtuple("TestObject", "foo") + user_prop = user_state.create_property("conversation") + await user_prop.set(context, foo_cls(foo="bar")) + await user_state.save_changes(context) + + # Replace context and user_state with new instances + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + dialog_context = await dialogs.create_context(context) + + # Run test + scope = UserMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertIsNone(memory, "state returned") + + async def test_user_memory_scope_should_return_state_once_loaded(self): + # Initialize user state + storage = MemoryStorage() + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + foo_cls = namedtuple("TestObject", "foo") + user_prop = user_state.create_property("conversation") + await user_prop.set(context, foo_cls(foo="bar")) + await user_state.save_changes(context) + + # Replace context and conversation_state with instances + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(storage) + context.turn_state["ConversationState"] = conversation_state + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + dialog_context = await dialogs.create_context(context) + + # Run test + scope = UserMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertIsNone(memory, "state returned") + + await scope.load(dialog_context) + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + + # TODO: Make get_path_value take conversation.foo + test_obj = ObjectPath.get_path_value(memory, "conversation") + self.assertEqual("bar", test_obj.foo) + + async def test_dialog_memory_scope_should_return_containers_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_container"]) + + async def test_dialog_memory_scope_should_return_parent_containers_state_for_children( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container", TestDialog("child", "test message")) + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + child_dc = dialog_context.child + self.assertIsNotNone(child_dc, "No child DC") + memory = scope.get_memory(child_dc) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_container"]) + + async def test_dialog_memory_scope_should_return_childs_state_when_no_parent(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("test") + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_dialog"]) + + async def test_dialog_memory_scope_should_overwrite_parents_memory(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container", TestDialog("child", "test message")) + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + child_dc = dialog_context.child + self.assertIsNotNone(child_dc, "No child DC") + + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(child_dc, foo_cls("bar")) + memory = scope.get_memory(child_dc) + self.assertIsNotNone(memory, "state not returned") + self.assertEqual(memory.foo, "bar") + + async def test_dialog_memory_scope_should_overwrite_active_dialogs_memory(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + self.assertEqual(memory.foo, "bar") + + async def test_dialog_memory_scope_should_raise_error_if_set_memory_called_without_memory( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + with self.assertRaises(Exception): + scope = DialogMemoryScope() + await dialog_context.begin_dialog("container") + scope.set_memory(dialog_context, None) + + async def test_settings_memory_scope_should_return_content_of_settings(self): + # pylint: disable=import-outside-toplevel + from test_settings import DefaultConfig + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(MemoryStorage()) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state).add(TestDialog("test", "test message")) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + settings = DefaultConfig() + dialog_context.context.turn_state["settings"] = settings + + # Run test + scope = SettingsMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory) + self.assertEqual(memory.STRING, "test") + self.assertEqual(memory.INT, 3) + self.assertEqual(memory.LIST[0], "zero") + self.assertEqual(memory.LIST[1], "one") + self.assertEqual(memory.LIST[2], "two") + self.assertEqual(memory.LIST[3], "three") + + async def test_this_memory_scope_should_return_active_dialogs_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ThisMemoryScope() + await dialog_context.begin_dialog("test") + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_dialog"]) + + async def test_this_memory_scope_should_overwrite_active_dialogs_memory(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ThisMemoryScope() + await dialog_context.begin_dialog("container") + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + self.assertEqual(memory.foo, "bar") + + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_memory( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + with self.assertRaises(Exception): + scope = ThisMemoryScope() + await dialog_context.begin_dialog("container") + scope.set_memory(dialog_context, None) + + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_active_dialog( + self, + ): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + with self.assertRaises(Exception): + scope = ThisMemoryScope() + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + + async def test_turn_memory_scope_should_persist_changes_to_turn_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = TurnMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + memory["foo"] = "bar" + memory = scope.get_memory(dialog_context) + self.assertEqual(memory["foo"], "bar") + + async def test_turn_memory_scope_should_overwrite_values_in_turn_state(self): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.create_context(context) + + # Run test + scope = TurnMemoryScope() + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) + memory = scope.get_memory(dialog_context) + self.assertIsNotNone(memory, "state not returned") + self.assertEqual(memory.foo, "bar") diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py new file mode 100644 index 000000000..ab83adef1 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +class DefaultConfig: + """ Bot Configuration """ + + STRING = os.environ.get("STRING", "test") + INT = os.environ.get("INT", 3) + LIST = os.environ.get("LIST", ["zero", "one", "two", "three"]) + NOT_TO_BE_OVERRIDDEN = os.environ.get("NOT_TO_BE_OVERRIDDEN", "one") + TO_BE_OVERRIDDEN = os.environ.get("TO_BE_OVERRIDDEN", "one") diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_manager.py b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py new file mode 100644 index 000000000..6ed5198f7 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py @@ -0,0 +1,352 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from enum import Enum +from typing import Callable, List, Tuple + +import aiounittest + +from botbuilder.core import ( + AutoSaveStateMiddleware, + BotAdapter, + ConversationState, + MemoryStorage, + MessageFactory, + UserState, + TurnContext, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.core.skills import SkillHandler, SkillConversationReference +from botbuilder.dialogs import ( + ComponentDialog, + Dialog, + DialogContext, + DialogEvents, + DialogInstance, + DialogReason, + TextPrompt, + WaterfallDialog, + DialogManager, + DialogManagerResult, + DialogTurnStatus, + WaterfallStepContext, +) +from botbuilder.dialogs.prompts import PromptOptions +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + InputHints, +) +from botframework.connector.auth import AuthenticationConstants, ClaimsIdentity + + +class SkillFlowTestCase(str, Enum): + # DialogManager is executing on a root bot with no skills (typical standalone bot). + root_bot_only = "RootBotOnly" + + # DialogManager is executing on a root bot handling replies from a skill. + root_bot_consuming_skill = "RootBotConsumingSkill" + + # DialogManager is executing in a skill that is called from a root and calling another skill. + middle_skill = "MiddleSkill" + + # DialogManager is executing in a skill that is called from a parent (a root or another skill) but doesn"t call + # another skill. + leaf_skill = "LeafSkill" + + +class SimpleComponentDialog(ComponentDialog): + # An App ID for a parent bot. + parent_bot_id = "00000000-0000-0000-0000-0000000000PARENT" + + # An App ID for a skill bot. + skill_bot_id = "00000000-0000-0000-0000-00000000000SKILL" + + # Captures an EndOfConversation if it was sent to help with assertions. + eoc_sent: Activity = None + + # Property to capture the DialogManager turn results and do assertions. + dm_turn_result: DialogManagerResult = None + + def __init__( + self, id: str = None, prop: str = None + ): # pylint: disable=unused-argument + super().__init__(id or "SimpleComponentDialog") + self.text_prompt = "TextPrompt" + self.waterfall_dialog = "WaterfallDialog" + self.add_dialog(TextPrompt(self.text_prompt)) + self.add_dialog( + WaterfallDialog( + self.waterfall_dialog, [self.prompt_for_name, self.final_step,] + ) + ) + self.initial_dialog_id = self.waterfall_dialog + self.end_reason = None + + @staticmethod + async def create_test_flow( + dialog: Dialog, + test_case: SkillFlowTestCase = SkillFlowTestCase.root_bot_only, + enabled_trace=False, + ) -> TestAdapter: + conversation_id = "testFlowConversationId" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + user_state = UserState(storage) + + activity = Activity( + channel_id="test", + service_url="https://test.com", + from_property=ChannelAccount(id="user1", name="User1"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount( + is_group=False, conversation_type=conversation_id, id=conversation_id + ), + ) + + dialog_manager = DialogManager(dialog) + dialog_manager.user_state = user_state + dialog_manager.conversation_state = conversation_state + + async def logic(context: TurnContext): + if test_case != SkillFlowTestCase.root_bot_only: + # Create a skill ClaimsIdentity and put it in turn_state so isSkillClaim() returns True. + claims_identity = ClaimsIdentity({}, False) + claims_identity.claims[ + "ver" + ] = "2.0" # AuthenticationConstants.VersionClaim + claims_identity.claims[ + "aud" + ] = ( + SimpleComponentDialog.skill_bot_id + ) # AuthenticationConstants.AudienceClaim + claims_identity.claims[ + "azp" + ] = ( + SimpleComponentDialog.parent_bot_id + ) # AuthenticationConstants.AuthorizedParty + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + + if test_case == SkillFlowTestCase.root_bot_consuming_skill: + # Simulate the SkillConversationReference with a channel OAuthScope stored in turn_state. + # This emulates a response coming to a root bot through SkillHandler. + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = SkillConversationReference( + None, AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + if test_case == SkillFlowTestCase.middle_skill: + # Simulate the SkillConversationReference with a parent Bot ID stored in turn_state. + # This emulates a response coming to a skill from another skill through SkillHandler. + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = SkillConversationReference( + None, SimpleComponentDialog.parent_bot_id + ) + + async def aux( + turn_context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + next: Callable, + ): + for activity in activities: + if activity.type == ActivityTypes.end_of_conversation: + SimpleComponentDialog.eoc_sent = activity + break + + return await next() + + # Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. + context.on_send_activities(aux) + + SimpleComponentDialog.dm_turn_result = await dialog_manager.on_turn(context) + + adapter = TestAdapter(logic, activity, enabled_trace) + adapter.use(AutoSaveStateMiddleware([user_state, conversation_state])) + + return adapter + + async def on_end_dialog( + self, context: DialogContext, instance: DialogInstance, reason: DialogReason + ): + self.end_reason = reason + return await super().on_end_dialog(context, instance, reason) + + async def prompt_for_name(self, step: WaterfallStepContext): + return await step.prompt( + self.text_prompt, + PromptOptions( + prompt=MessageFactory.text( + "Hello, what is your name?", None, InputHints.expecting_input + ), + retry_prompt=MessageFactory.text( + "Hello, what is your name again?", None, InputHints.expecting_input + ), + ), + ) + + async def final_step(self, step: WaterfallStepContext): + await step.context.send_activity(f"Hello { step.result }, nice to meet you!") + return await step.end_dialog(step.result) + + +class DialogManagerTests(aiounittest.AsyncTestCase): + """ + self.beforeEach(() => { + _dmTurnResult = undefined + }) + """ + + async def test_handles_bot_and_skills(self): + construction_data: List[Tuple[SkillFlowTestCase, bool]] = [ + (SkillFlowTestCase.root_bot_only, False), + (SkillFlowTestCase.root_bot_consuming_skill, False), + (SkillFlowTestCase.middle_skill, True), + (SkillFlowTestCase.leaf_skill, True), + ] + + for test_case, should_send_eoc in construction_data: + with self.subTest(test_case=test_case, should_send_eoc=should_send_eoc): + SimpleComponentDialog.dm_turn_result = None + SimpleComponentDialog.eoc_sent = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, test_case + ) + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send("SomeName") + await step3.assert_reply("Hello SomeName, nice to meet you!") + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Complete, + ) + + self.assertEqual(dialog.end_reason, DialogReason.EndCalled) + if should_send_eoc: + self.assertTrue( + bool(SimpleComponentDialog.eoc_sent), + "Skills should send EndConversation to channel", + ) + self.assertEqual( + SimpleComponentDialog.eoc_sent.type, + ActivityTypes.end_of_conversation, + ) + self.assertEqual(SimpleComponentDialog.eoc_sent.value, "SomeName") + else: + self.assertIsNone( + SimpleComponentDialog.eoc_sent, + "Root bot should not send EndConversation to channel", + ) + + async def test_skill_handles_eoc_from_parent(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + await step2.send(Activity(type=ActivityTypes.end_of_conversation)) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Cancelled, + ) + + async def test_skill_handles_reprompt_from_parent(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send( + Activity(type=ActivityTypes.event, name=DialogEvents.reprompt_dialog) + ) + await step3.assert_reply("Hello, what is your name?") + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Waiting, + ) + + async def test_skill_should_return_empty_on_reprompt_with_no_dialog(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill + ) + + await test_flow.send( + Activity(type=ActivityTypes.event, name=DialogEvents.reprompt_dialog) + ) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Empty, + ) + + async def test_trace_skill_state(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + + def assert_is_trace(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.trace + + def assert_is_trace_and_label(activity, description): + assert_is_trace(activity, description) + assert activity.label == "Skill State" + + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill, True + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply(assert_is_trace) + step2 = await step2.assert_reply("Hello, what is your name?") + step3 = await step2.assert_reply(assert_is_trace_and_label) + step4 = await step3.send("SomeName") + step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") + step6 = await step5.assert_reply(assert_is_trace_and_label) + await step6.assert_reply(assert_is_trace) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Complete, + ) + + async def test_trace_bot_state(self): + SimpleComponentDialog.dm_turn_result = None + dialog = SimpleComponentDialog() + + def assert_is_trace(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.trace + + def assert_is_trace_and_label(activity, description): + assert_is_trace(activity, description) + assert activity.label == "Bot State" + + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.root_bot_only, True + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.assert_reply(assert_is_trace_and_label) + step4 = await step3.send("SomeName") + step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") + await step5.assert_reply(assert_is_trace_and_label) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Complete, + ) From 08076e254388d7b5282b0cfc70ba7967b40ce37a Mon Sep 17 00:00:00 2001 From: Denise Scollo Date: Mon, 1 Feb 2021 11:20:33 -0300 Subject: [PATCH 613/616] [#631][PORT] [Slack Adapters] Add Slack Functional Test (#1432) * Add slacktestbot, test_slack_client and new YAML * Reduce timeout * Add bot requirements and methods return * Remove comment * Add variable description, remove comments * Add README * README Indentation fix * fix black styling and ignore functional test in pipeline * Fix ordered list format * Fix README image link * README: Add missing link, fix indentation * Remove faulty formatting Co-authored-by: Ian Luca Scaltritti Co-authored-by: Santiago Grangetto Co-authored-by: Cecilia Avila <44245136+ceciliaavila@users.noreply.github.com> --- libraries/functional-tests/requirements.txt | 2 + .../functional-tests/slacktestbot/README.md | 131 ++++++++ .../functional-tests/slacktestbot/app.py | 78 +++++ .../slacktestbot/bots/__init__.py | 6 + .../slacktestbot/bots/echo_bot.py | 52 +++ .../functional-tests/slacktestbot/config.py | 15 + .../template-with-new-rg.json | 297 ++++++++++++++++++ .../template-with-preexisting-rg.json | 275 ++++++++++++++++ .../media/AzureAppRegistration1.png | Bin 0 -> 321608 bytes .../media/AzureAppRegistration2.png | Bin 0 -> 399321 bytes .../media/AzurePipelineSetup1.png | Bin 0 -> 510310 bytes .../media/AzurePipelineSetup2.png | Bin 0 -> 216637 bytes .../media/AzurePipelineVariables.png | Bin 0 -> 131133 bytes .../media/SlackAppCredentials.png | Bin 0 -> 195440 bytes .../slacktestbot/media/SlackChannelID.png | Bin 0 -> 55225 bytes .../media/SlackCreateSlackApp.png | Bin 0 -> 102270 bytes .../slacktestbot/media/SlackGrantScopes.png | Bin 0 -> 172196 bytes .../slacktestbot/media/SlackInstallApp.png | Bin 0 -> 516602 bytes .../slacktestbot/media/SlackOAuthToken.png | Bin 0 -> 182396 bytes .../slacktestbot/requirements.txt | 2 + .../resources/InteractiveMessage.json | 62 ++++ .../tests/test_slack_client.py | 113 +++++++ pipelines/botbuilder-python-ci-slack-test.yml | 105 +++++++ pipelines/botbuilder-python-ci.yml | 2 +- 24 files changed, 1139 insertions(+), 1 deletion(-) create mode 100644 libraries/functional-tests/requirements.txt create mode 100644 libraries/functional-tests/slacktestbot/README.md create mode 100644 libraries/functional-tests/slacktestbot/app.py create mode 100644 libraries/functional-tests/slacktestbot/bots/__init__.py create mode 100644 libraries/functional-tests/slacktestbot/bots/echo_bot.py create mode 100644 libraries/functional-tests/slacktestbot/config.py create mode 100644 libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-new-rg.json create mode 100644 libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-preexisting-rg.json create mode 100644 libraries/functional-tests/slacktestbot/media/AzureAppRegistration1.png create mode 100644 libraries/functional-tests/slacktestbot/media/AzureAppRegistration2.png create mode 100644 libraries/functional-tests/slacktestbot/media/AzurePipelineSetup1.png create mode 100644 libraries/functional-tests/slacktestbot/media/AzurePipelineSetup2.png create mode 100644 libraries/functional-tests/slacktestbot/media/AzurePipelineVariables.png create mode 100644 libraries/functional-tests/slacktestbot/media/SlackAppCredentials.png create mode 100644 libraries/functional-tests/slacktestbot/media/SlackChannelID.png create mode 100644 libraries/functional-tests/slacktestbot/media/SlackCreateSlackApp.png create mode 100644 libraries/functional-tests/slacktestbot/media/SlackGrantScopes.png create mode 100644 libraries/functional-tests/slacktestbot/media/SlackInstallApp.png create mode 100644 libraries/functional-tests/slacktestbot/media/SlackOAuthToken.png create mode 100644 libraries/functional-tests/slacktestbot/requirements.txt create mode 100644 libraries/functional-tests/slacktestbot/resources/InteractiveMessage.json create mode 100644 libraries/functional-tests/tests/test_slack_client.py create mode 100644 pipelines/botbuilder-python-ci-slack-test.yml diff --git a/libraries/functional-tests/requirements.txt b/libraries/functional-tests/requirements.txt new file mode 100644 index 000000000..b1c2f0a5d --- /dev/null +++ b/libraries/functional-tests/requirements.txt @@ -0,0 +1,2 @@ +requests==2.23.0 +aiounittest==1.3.0 diff --git a/libraries/functional-tests/slacktestbot/README.md b/libraries/functional-tests/slacktestbot/README.md new file mode 100644 index 000000000..e27305746 --- /dev/null +++ b/libraries/functional-tests/slacktestbot/README.md @@ -0,0 +1,131 @@ +# Slack functional test pipeline setup + +This is a step by step guide to setup the Slack functional test pipeline. + +## Slack Application setup + +We'll need to create a Slack application to connect with the bot. + +1. Create App + + Create a Slack App from [here](https://api.slack.com/apps), associate it to a workspace. + + ![Create Slack App](./media/SlackCreateSlackApp.png) + +2. Get the Signing Secret and the Verification Token + + Keep the Signing Secret and the Verification Token from the Basic Information tab. + + These tokens will be needed to configure the pipeline. + + - Signing Secret will become *SlackTestBotSlackClientSigningSecret*. + - Verification Token will become *SlackTestBotSlackVerificationToken*. + + ![App Credentials](./media/SlackAppCredentials.png) + +3. Grant Scopes + + Go to the OAuth & Permissions tab and scroll to the Scopes section. + + In the Bot Token Scopes, add chat:write, im:history, and im:read using the Add an Oauth Scope button. + + ![Grant Scopes](./media/SlackGrantScopes.png) + +4. Install App + + On the same OAuth & Permissions tab, scroll up to the OAuth Tokens & Redirect URLs section and click on Install to Workspace. + + A new window will be prompted, click on Allow. + + ![Install App](./media/SlackInstallApp.png) + +5. Get the Bot User OAuth Access Token + + You will be redirected back to OAuth & Permissions tab, keep the Bot User OAuth Access Token. + + - Bot User OAuth Access Token will become *SlackTestBotSlackBotToken* later in the pipeline variables. + + ![OAuthToken](./media/SlackOAuthToken.png) + +6. Get the Channel ID + + Go to the Slack workspace you associated the app to. The new App should have appeared; if not, add it using the plus sign that shows up while hovering the mouse over the Apps tab. + + Right click on it and then on Copy link. + + ![ChannelID](./media/SlackChannelID.png) + + The link will look something like https://workspace.slack.com/archives/N074R34L1D. + + The last segment of the URL represents the channel ID, in this case, **N074R34L1D**. + + - Keep this ID as it will later become the *SlackTestBotSlackChannel* pipeline variable. + +## Azure setup + +We will need to create an Azure App Registration and setup a pipeline. + +### App Registration + +1. Create an App Registration + + Go [here](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) and click on New Registration. + + Set a name and change the supported account type to Multitenant, then Register. + + ![Azure App Registration 1](./media/AzureAppRegistration1.png) + + 1. Get the Application ID and client secret values + + You will be redirected to the Overview tab. + + Copy the Application ID then go to the Certificates and secrets tab. + + Create a secret and copy its value. + + - The Azure App Registration ID will be the *SlackTestBotAppId* for the pipeline. + - The Azure App Registration Secret value will be the *SlackTestBotAppSecret* for the pipeline. + +![Azure App Registration 2](./media/AzureAppRegistration2.png) + +### Pipeline Setup + +1. Create the pipeline + + From an Azure DevOps project, go to the Pipelines view and create a new one. + + Using the classic editor, select GitHub, then set the repository and branch. + + ![Azure Pipeline Setup 1](./media/AzurePipelineSetup1.png) + +2. Set the YAML + + On the following view, click on the Apply button of the YAML configuration. + + Set the pipeline name and point to the YAML file clicking on the three highlighted dots. + +![Azure Pipeline Setup 2](./media/AzurePipelineSetup2.png) + +3. Set the pipeline variables + + Finally, click on the variables tab. + + You will need to set up the variables using the values you got throughout this guide: + + |Variable|Value| + |---|---| + | AzureSubscription | Azure Resource Manager name, click [here](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/overview) for more information. | + | SlackTestBotAppId | Azure App Registration ID. | + | SlackTestBotAppSecret | Azure App Registration Secret value. | + | SlackTestBotBotGroup | Name of the Azure resource group to be created. | + | SlackTestBotBotName | Name of the Bot to be created. | + | SlackTestBotSlackBotToken | Slack Bot User OAuth Access Token. | + | SlackTestBotSlackChannel | Slack Channel ID. | + | SlackTestBotSlackClientSigningSecret | Slack Signing Secret. | + | SlackTestBotSlackVerificationToken | Slack Verification Token. | + + Once the variables are set up your panel should look something like this: + + ![Azure Pipeline Variables](./media/AzurePipelineVariables.png) + + Click Save and the pipeline is ready to run. diff --git a/libraries/functional-tests/slacktestbot/app.py b/libraries/functional-tests/slacktestbot/app.py new file mode 100644 index 000000000..e8fb9b63c --- /dev/null +++ b/libraries/functional-tests/slacktestbot/app.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.adapters.slack import SlackAdapterOptions +from botbuilder.adapters.slack import SlackAdapter +from botbuilder.adapters.slack import SlackClient +from botbuilder.core import TurnContext +from botbuilder.core.integration import aiohttp_error_middleware +from botbuilder.schema import Activity, ActivityTypes + +from bots import EchoBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +SLACK_OPTIONS = SlackAdapterOptions( + CONFIG.SLACK_VERIFICATION_TOKEN, + CONFIG.SLACK_BOT_TOKEN, + CONFIG.SLACK_CLIENT_SIGNING_SECRET, +) +SLACK_CLIENT = SlackClient(SLACK_OPTIONS) +ADAPTER = SlackAdapter(SLACK_CLIENT) + + +# 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) + traceback.print_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 = EchoBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + return await ADAPTER.process(req, BOT.on_turn) + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/libraries/functional-tests/slacktestbot/bots/__init__.py b/libraries/functional-tests/slacktestbot/bots/__init__.py new file mode 100644 index 000000000..f95fbbbad --- /dev/null +++ b/libraries/functional-tests/slacktestbot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/libraries/functional-tests/slacktestbot/bots/echo_bot.py b/libraries/functional-tests/slacktestbot/bots/echo_bot.py new file mode 100644 index 000000000..c396a42f5 --- /dev/null +++ b/libraries/functional-tests/slacktestbot/bots/echo_bot.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os + +from botbuilder.adapters.slack import SlackRequestBody, SlackEvent +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount, Attachment + + +class EchoBot(ActivityHandler): + async def on_members_added_activity( + self, members_added: [ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + return await turn_context.send_activity( + MessageFactory.text(f"Echo: {turn_context.activity.text}") + ) + + async def on_event_activity(self, turn_context: TurnContext): + body = turn_context.activity.channel_data + if not body: + return + + if isinstance(body, SlackRequestBody) and body.command == "/test": + interactive_message = MessageFactory.attachment( + self.__create_interactive_message( + os.path.join(os.getcwd(), "./resources/InteractiveMessage.json") + ) + ) + await turn_context.send_activity(interactive_message) + + if isinstance(body, SlackEvent): + if body.subtype == "file_share": + await turn_context.send_activity("Echo: I received and attachment") + elif body.message and body.message.attachments: + await turn_context.send_activity("Echo: I received a link share") + + def __create_interactive_message(self, file_path: str) -> Attachment: + with open(file_path, "rb") as in_file: + adaptive_card_attachment = json.load(in_file) + + return Attachment( + content=adaptive_card_attachment, + content_type="application/json", + name="blocks", + ) diff --git a/libraries/functional-tests/slacktestbot/config.py b/libraries/functional-tests/slacktestbot/config.py new file mode 100644 index 000000000..73916b758 --- /dev/null +++ b/libraries/functional-tests/slacktestbot/config.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3978 + + SLACK_VERIFICATION_TOKEN = os.environ.get("SlackVerificationToken", "") + SLACK_BOT_TOKEN = os.environ.get("SlackBotToken", "") + SLACK_CLIENT_SIGNING_SECRET = os.environ.get("SlackClientSigningSecret", "") diff --git a/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-new-rg.json b/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..456508b2d --- /dev/null +++ b/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,297 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + }, + "slackVerificationToken": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The slack verification token, taken from the Slack page after create an app." + } + }, + "slackBotToken": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The slack bot token, taken from the Slack page after create an app." + } + }, + "slackClientSigningSecret": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The slack client signing secret, taken from the Slack page after create an app." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourceGroupId": "[concat(subscription().id, '/resourceGroups/', parameters('groupName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('appServicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2015-08-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "siteConfig": { + "appSettings": [ + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SlackVerificationToken", + "value": "[parameters('slackVerificationToken')]" + }, + { + "name": "SlackBotToken", + "value": "[parameters('slackBotToken')]" + }, + { + "name": "SlackClientSigningSecret", + "value": "[parameters('slackClientSigningSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "gunicorn --bind 0.0.0.0 --worker-class aiohttp.worker.GunicornWebWorker --timeout 600 app:APP", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} diff --git a/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-preexisting-rg.json b/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..0a393754c --- /dev/null +++ b/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,275 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "F0", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + }, + "slackVerificationToken": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The slack verification token, taken from the Slack page after create an app." + } + }, + "slackBotToken": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The slack bot token, taken from the Slack page after create an app." + } + }, + "slackClientSigningSecret": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The slack client signing secret, taken from the Slack page after create an app." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2016-09-01", + "name": "[variables('servicePlanName')]", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "name": "[variables('servicePlanName')]", + "perSiteScaling": false, + "reserved": true, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('webAppName')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]" + ], + "kind": "app,linux", + "properties": { + "enabled": true, + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": false, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "siteConfig": { + "appSettings": [ + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + }, + { + "name": "SlackVerificationToken", + "value": "[parameters('slackVerificationToken')]" + }, + { + "name": "SlackBotToken", + "value": "[parameters('slackBotToken')]" + }, + { + "name": "SlackClientSigningSecret", + "value": "[parameters('slackClientSigningSecret')]" + }, + { + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2016-08-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "phpVersion": "", + "pythonVersion": "", + "nodeVersion": "", + "linuxFxVersion": "PYTHON|3.7", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "remoteDebuggingVersion": "VS2017", + "httpLoggingEnabled": true, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": false, + "appCommandLine": "gunicorn --bind 0.0.0.0 --worker-class aiohttp.worker.GunicornWebWorker --timeout 600 app:APP", + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": false, + "virtualDirectories": null + } + ], + "winAuthAdminState": 0, + "winAuthTenantState": 0, + "customAppPoolIdentityAdminState": false, + "customAppPoolIdentityTenantState": false, + "loadBalancing": "LeastRequests", + "routingRules": [], + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "vnetName": "", + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} diff --git a/libraries/functional-tests/slacktestbot/media/AzureAppRegistration1.png b/libraries/functional-tests/slacktestbot/media/AzureAppRegistration1.png new file mode 100644 index 0000000000000000000000000000000000000000..c39964a1470a45adb490301b49d8f3f68981d76b GIT binary patch literal 321608 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 literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/AzureAppRegistration2.png b/libraries/functional-tests/slacktestbot/media/AzureAppRegistration2.png new file mode 100644 index 0000000000000000000000000000000000000000..5f64b622056d9ab4a47cc619316e9000ae794c82 GIT binary patch literal 399321 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 z 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≥M+96c|z#>pCX1SUc<|zRr$y)8Zza-m@hSmfN`+{&qs@_ zp6pFoE9B#FB{#d1>(@AZzG=FF6V1l^dOH`MULBp2lMjvVP@k2=vnlmHaHk!{B1fRQ zTqLzel90A%EDJl-EAy_i<}_s%me%p4U43%) zE1=WrAGhB6iIIy5Tw-g3RfjYj zLMRVw-=^KtT9{ONQ12$j4iTvhjTi?&J!g%SW#&Ilmi~Qfacd^;N4Y;{wtqn|6K3>EPIkxv^m(fC}T$`r$Io-a?g(|y8bByLMdf2_gGr$NP_ zeJyr$LD>Tz<8^eur?3gtxTQes$9mkMhfSKD{ppyV2rsKy3;sW$32!Q`gfej&XMH}4 z1z}OHtz`NgEVm}6>uW*Xw^RYcY5jL4LHzhOG?Rh{i)?A5S~_3e-v64+*0{olgM+bNTKS zgpKR6$EG$*$Nw&Kf@0u4%%jN|7L@MOF^(Z5CK}xvz{QtPK;t2R=N-Oi33&tA<~-)? zKbU!Qbh5L+*1=b1de~5MFyoQJ3^G1BA9X%566&g13=;6XryG3+P+mITA2{I$e)Mok zwh5|*+6~HUmCX-bb`sW;B!1kea6TX&&zN#Xd;i{*&+<(=UXzg{Sl67%ioMivbp)$} zx6JG?rMg(ZHd@I9ISZtZ(La4=&d)?o7%lI`Z9-*<7MYKn6>DW^PSIW$^FGN{YkdZv zb&k~!xxsUF$khsop$^qJesjO_YMO@fPLWE%3$uzR|Tg!!*IfPmSh;>r3Oosdvx&P&{xhj<~rP!23HdMq?wUg92DG)cXyv z(xiD0B27aT7Q}AYDypiU=0lTW)Ul8FjL(y-1`P0oLf=)+J2v#c^RW4=^RUZmfQpYH zwKz>8Gz~z)71C>HyS3W}0I8XJo7_V*2@}U77vV)PUG-C>HoSE)H;(^+Ka^}{a6h+9 zLdnP?FH3p`y|S-wh>BtOuCa?{NdvI7&3(aj%*c!QrVn%v$|sT)XLECNu?2`w1?|vK z74J`m-m>;`eCxBKh7OcJZg_at&bj~=5rb!yoPF?qdZDr?3FwM^A`P{}4JsrVFcUKb zbh~(s#%53$H&cPkP@3IGJh84~M49qSG1<)3A43aFo^_e-~xqf8FU zh#n-9x7m%Ok;CIdJfHC+Y{{UM5uZH_hV;TSp_(@{N;B$Mv%mOaG@giNNC3UPQ^=V2_K;Xh?39>N zA$60xa{apd$7+zW({&HJ=~??tOLqc+YTQFwK@kIgHAqMZ#e3^LO14<@y`{K(^{2vq z6Y~28f%EZ7Jy3K50k5dT=fglAUI@m1#it>b$_&Hxsnf46cFS#fn4~jJUVI+PGATC} zF}o~fRsz_$0fjjtO&tM)sWTOClZa^=RTK!sYwRrSuBoW)dmI^> z&qeMJhB+>(Eu#=;f}ml_71e948i%z^h>=fY=9;m}t>G1HDu4Dy&doChn6lT!c+2?a$U(JQuo5+wiwyP*D&4+H^sWJyhk1GQz3wKQ}+)Zj?^PbFzBhbWKslX75{{X$LywD;EI0Chf;72d-v!hZg@{FFQ0q(bB zC3-x1w<>^zgy+JHOeMl(*S_8YEatfeF~KcWKb&#r)Tdi14=oSZzp`vJrGDmcKCg3v zMYPCl_~I86Er)rxV16;U^01!zC4NY6XEn(j)ut&s3la+WroT%{^;&_0E3+iT$xpma9L_|lTOI@Vs#8a+=4pG%1lvyw$i|G9)k`oe zt!Y6u!zR{;DOESyiJy?G`oFv`@RvOeBOF@Gln*J3uckNkm%Xd>9WySl$(DrLyxxOe z=H8so=Q=nfYSiQ%Ssn+Oyo?a88*-LTCJ)ql1zHa20s$?vswuhTx{KD+` z4RjIhdaP91Yj3(ldXQwYP`~U?%zO7yi#gt{3 z9KaxDf!bnKbpueh{GLrEjQOu}dm$HSb4L2S)O8ll)1<@A`5cN1o2j455qc9=BY{(1 z47jh|6URrT@%E+O{oNorYkbFYo><?enB zI2KV?xv1UlLLTphKB&`jL!N*VOwTm#p0>uUEdPq~O}*Ju-zRz}wF!&Fm7mFNN*dP{ zNxJ+;|HL5Tith83B<3oAl0L_~5_H^>uZ3Vc&uH)^ujLGO(LtQ5rXbR`EkTtQcQ5EK z4nKe|2fy51fF`ap887Z{M;u^pWx~ljqZq@ITCOO1jrhgx)XeRLN{gQ_yo9ue;@Iu% z%*PO@xw2MW#rWWN`Tdj zR;ewS#E0ILiQBVQgu|m+gpWh(_fa8=7+ZoPk=D4`cSQOI&koz~DvJL*q9N)!JM0>vtcO%rSS9A|1xx4WR=yAi zX1!V~Ih$pyYiH8Ix*HsS<6wHW-cw)jFPv!O{W47S+0&ULCyRU49*})UVo5LOH-r+K zx$izGk4luA<^1gKjaH<+M0LXA7aj{jq}kF-bbN+rPY3o@aS@`CN`Z(}7mJa&XDly; z+8)Le`8{sm_k!3=*5MiFmv$fS3DT`_@p`6;y%1;Dy>R%PD6F33J^8?=tksmInH7M(&g8rFH<=n)URS4oa{PX z=|NVKP(iyLLE+#+RM%LSb20`M$l7^3>wz9mLWo!Dc%B8i^<`3Lc=MI3Ef@06S)`;* zg*O?D7|eH3f5vl#mb5G{%2oue zl;s&RwxpPHE~CadRYT;x|M$Mw+4nVsG=W$qZROEVpDJ4{gk~Q!B}kQC@v7RV?OwT& zam4G2++xLJD{a#?T4i4ImD=IDy zIIVy5`IbPMs9f%FPfLJZsyv{FX|*Mm5<-eyzP`h#oL&zr9KUjliIQz;B(eq9L2X`* zH-5_3Wfs%JOpzFA5b>uF?M~)m;P>E83+90)iTUtKP8q*lQ$~9RhB>0EJt1g zMU3m2Iq&?!EvM_heHNOZW-)PtQ4<8Qn9a`F*)9Sz!mwjL@-|8shlbucK8%&C>SASV z#FC}0_qMt46wW)aA;7YyW6$VX5n85{cSMz8RK*n2KlbcA*0}7jpD(>auZQ_~heL~G zM;h(P^=e2v%Xt#(5@Hm5of9GId=?h=4&QsDzeS+ipmA1skheJSM70ANV*6H3(A3i6 z<}yxBR`BRAN_&0o9UlFBxWA}e6_V=u@I>S%v&j$04>lF+58{~&HwK>z;hz>_S_;_a`=)m#WB9%rDBsyZyC zCq(2y7{rI2Q)?{Qf*^;_G!NIu>~yTNaXE8cFgv4y!O@UdWeIhX;kC1ORr>Q4Y56BF zhgI0k8jKC+cn<@YuhS2jA@VWfennj=vcyN~45dG$vQ!2w%|4^=P1}Sn=hziTev7%< z11Q=*&qWnKG?`#@+ITD6QwolIZ^(2U<>01kOUPLjg6{i^A#`+g2FhvO-TVfChbd%O zY=v|{rIq>fxyfSjYNPwpeUaqZbzb1LH8(q_-HfgD3|Tk7(nYg=0e58G!2Zi!Bg$p^ zbStiJLz+jz}FSxr4n z;=%!URus(9!;oN z1QQhu++23a0dB;JZsp9u9D_FFHPecLLCv%+_`!XeOJ^nf%W5@3(}|yCov4zp98HZ2;4ZW@0N^oC}xju%OtY z?M-(NZSwteM|SCLC+-)g`yAl56QVJ@DY!f%t0z3$Yn=$yL+!E5=UUYVZa-x-)T8z= zR9mm0NN4Y4GyXA;8%fHl7YXP(3tQl8tW)Kf$9vocmf8Q;tOmVtR0Wnbl?6==88z zm!n?41!#c^B7BNWsvwRb$fbrxM~kKHo-sDXGh`%X%B7{c|IIO_L(%y_arp>Tl^5f( z^LV*+D@f0asQQ+S&}m5n5@S22GHlVivo%q&E87~-%O7(G^sWA@@+bfb1sTrqQ2sDp zQ7g^EI|Z=)I*}vNCYZ=m=&Y$CtqUws3H|5}S{+hMt4*jdQA8)1C;G|*?xImizi6gIc9adUUa_p-p0_%=J6lYD*c()N@4~6Om5bfr2Oqu`G08Nr zd81A847;Y%tPb1DhNsg(m1tkNJIZLUW9#e6JEp9HtQm4P#JIRPDy?nzlJ@5i_H|Vf zEVvuw7VWTzUJzc{AuBw`lfvPZifEnOmD$fFeBq%bZ)L%}=OW(jm2p5Zur~B4zF(6~ zTzSq3f|rFc(O1OfEUv8%&4xd;aM^YqFB?rYoIn%?mrl#~goCw|#Wf$DbwN$|tup zVPR*tH+8Nhxt>1qJ^ud0KpC`_^N0R{nhU1AWb0gs)dUcCUNIHM9d@(whmzx)n*4cP zu^H@Tc2bR^P9_2huhR@6t@xz9){XX_OqP=}*+r7|YyfSOM`E5j5Kc$FzO&~0 z?bpSV1FuG+vW{I<*<38xiG1l$A6&>6;2bUGho4ys1Q8byl6M!dkr3yfo-vPJWeTFiG zfP-^1Sb(F_M89jc(ll!-cq6iqp5$-yQGu&wRM|RhC&n0vSK}UZ3G==@gv& zsd5!blY?CTvd$IQuE36~dpBM_;)hCkhqQ9BnB!ThUY~Iz39&3brrzGE&Ij=>%D&vv98`bURUoHXaX3oYF3h)#$m|1&@myo2N9CBs+3;-sJfOvtH!|om)$(*1| zT6)Z5d0HfsxpUQ&Be_Ld(F(_DtxtmV?R6$bSDz~Fk7>>_*$+ni3a(hDwfIaLmv4{KzIW@ zl`nl|w0UQ|OYxD??#pUxcsdyR(LH-~OYGI=SW)@N_bdN^VqTpzMR))z@^kQE|2Cp< zyzYsJtTMf8pA0W~6D*fuH)n34RY;iO@6I8$*nf(ff6Kg231JMoc+a=1%sw>gO&stg z3iM{X?*15Cc2kI7#wF;78nG$TuAVz-Iol{U`aC*#BD|DZ$~3@%nERtYy$$!KkychC zKY5+4r?-L}NX9D{Y3WP&I}h(n(@;CIg9GZ6#o_;n@8iIe#IdS^D#hV4$^3zoIz*kn zX77_-eXJAce}{4su9kFRrZq}(yjIbxWCnu%qAi18)RE8k_zG0~E}sy+x5M1WUS7F{ z;;Eyj)fr7Tb5nYBFh)Lgww%#^9J*Te9!qTh(k3NTtT|0dr3Lo|vDXWopNWdp3T=rWP)7{dyJE&~%O zNK2dc(~_5hPA4B_BFb8`^)B=#N{q{zmf{a>j*qQ1%9IVQr*|Z3eVJeSBuRIWhUC?v zBFIbSs`Zs=yYC0 zHm3jYj`(A$9AOR>?+vd+VqWttpJL3qq}t4`+@{y6-z=h5PiGCxY7*kZ<@vY`;yx$+ zaLgeNPZIJ&9Yw=wH;0$kqMxT?(mzqCh?UZJmn$^mc1AHwPFGjl8#P;8uKEWqTGd*Z zmLCxm!J*BG3K&&&y+Xga(>0+AORZe_UmdqSfC*4Y8OTysOru3gG_mA#lAys>{b$#|Bxq&y0hw12psIR3C$PdIb=wsox= zgs++gOvc6a;Aqg}v7WM0X>6e(7V}D(g=jP*Z}XjNy)Eb3&WZC_EV}b(48n&QI^EX{ z0*rA9It&%%)C6@auIf-YyH((Gt|kq$l*#)H_Y2aoHlG*wfWXRaZ-SQ&ynp=NaKpJ# z`X8Nx@;l>K7$a97jtb!ii!nZ)(*al-mrz8sUq>q^i}^EG+n2NupM{HBlax3v>~y~UPRCT=>g2IvSO>p-VO zq7N0zIcmk7?{CUSI{7FiYwnFvg(lr7-7S5I*q5T1mzW7HRHU5prLfyO@yZ^mqNq>U zw0skC0r+amuRi4T%?Vq^^YIC$o+3aI=B+Foyy3o~WWQK!uJ0B$YsBXO+#iypX#GeF zol&ZwNtj3_O`2~IU@f}JxrqakfrlQQ!K?0kEZoOq!~OxG@A|+S3&l%MrwXMH8?uS3 z-~PC-wFeF!49_ol>B%iREN$c^Z%rL~2z!!9s*7`6@-f{Mbx4%Ot0s`DoGr$t%|auq zfwH&@WzSG*?Wg;|pXWwjLz_XIFlH#V;XmZfng0JO2T}q~;b%F<^Btva_Eiu1A}OF` z$&;hrCK!8Ov1!cm8cLu!mB@vT!yK>;iIHGOe%|)`cD$IuH2*377FA5ShFT)| zcg&$%m$&Wkvm)6=z1ZZ2LkeuAZXXoZ>5MYKsgIPvTA|U`X69_>@=udYzW6n`rv$vP zn}uj0yl2wp^eS&WseG3Jj4q7lbJFwr?_?JZ`rieiAgElUEu414qv(=?_^9U_>62jS z4fYH$cHKuZ-Di}mM#g`2+*Tz4`Ac%$5zS=@DtQE!Z@6+ZE?qt7`Fz%SXG`$sGEb(` z{^vk{Q5xRfrYo>Io~+&mmKOFem0m$is}V;5eS3N{ttST@Qqtk{In!4YHrz>SMP*;d zO{5*YOQ;X}v;}xd@Uv4ivjIgP0Oa07oyM`gl^qgbF>V&~IeABdiXi$*m#=nvQ#&2y z7M2337o&pETnVSYMccslrfSx%lw#P_BBbL=iar z`J4~)%cQIDlPQrkx1lodwvMoceZK*lJeiC9;?b4x*00yv9gLUHxwCu@1t$=?9f39> zIeP5zn^DeQ&yabwnVN$H*Vsngl?dXl*(Qf#f8Em<2{M5S^No!dYpvpmM%INm0x5*k z@{5G?BDuk1z0{i5L$@}`Ioj@)6o|Yri8Ci)h)RF2)!}jsZD_F&I;Iu)__==oTsiw> zRVI>k=ezO6?e&Fme_H2h*G6iMCP$x2+|1WaJ?CMX>XFWUfF{-BC>GmLnRxGQ#K~@L z940myZ8IsVON5$rt%hk05(yRC@?rp#*Q;Red5!vd$6kGr5p49b_{>@2S;`z9t>Ib1mwPwtS=WGC%E>>!@@jj-iXl>PxCaXp2EBDE%6e?r)*#=@B z-?J^t9BJCcQ{odVIwXGQ*gtfq2P9~d5V+q=r3bhG-=bAE^KDn;4bZO-@m?w&i|_kP zWEvG!VCZ}J7QIZ~h)cQ=3PU9o^YGTI2u&5-KXxpd83;B9jb9nPNq*VA)ZP3FJ>oVV zL_mUsf(V!fTO=o11fFV^N$;oJ?{eQ`5SmuL%fYa$DAaI#*`0fwqqEehVVXBAN5g!J zbK2KOyM?dz1*C3ZTVfni%MUb;a`ob}@!-jlH8j?{0)IaC!+6yMeAk}t9fHJZ$0*Ye zyX9$I=i)`h)Ntj>8_R`??KF)-(_Ieg8O@vOdi=OZ1)T5Mw$}z5G&jDag+9T;Ql_D-k$&NY-C;_{KRACJrrRBEB# zACcG%(5Nd4iGXM+@|3}xylN|*Y;;z%GKO<4GWIhvelmcNjx*nULETJb_S?kb#9CJu z7jlNOrA@_ejCpzvjGhgtHdL6q3nv~aE*Y@2^^gS>;2M*&nr)3u;Ve5H+jD8T;g3qL za*8qp#LrOGz!u3~mUhQuF@L2!QHh1nVWJGDEh%@qE<+=9z0;-WAE~QPYrnHk z!W8}e{l7744>6qIcJIN|EdKfaV|O~HLnSn}dW3?!h3(ipq^d=leC`;;ok=UiiW$w{ z-Ovfjznpp!uw?Y6&5pk$5eaW6PaSnwQKc_l>O8HJ7%RTfHOVCO5D+b6Z+_7Ab4iy> zf^yxam#b2<53>WLKbW=HjmB%m(#$*xvY0b=$0Cyw^R+7q9DsV$C9>5rhC3qOXwYdp zv-t-zG7;w=vAvg-pm}+To(l_4NF>I~w4PrYS9NB*Ruz00<^cf|Z-@ocD!fuokCl)d zroA+5fTjk-h!Dy=dJv(IeOg|=-a1}9`3{YxrRxyXAu(mvu3lPf=~d=%Qg%;nTuU-E za+9i4jePF`PG4J@B$Voz`au^*C7W;;*63|~BrYbOIwO)VckPGNAOmWa!0mx+vwtY z2S1SoJDzokV;#=-^wj&pi5BA9@7+1g<{2aDQzqTKYRM4bHQG0&c4kOgF;CKzL!*Hp zW)%&CWjv=^(N1mPw8R5r*Nn2%cqA`Hn&<;Pq!BeL@wB%M%KW z!`X~JQgG!}ed-;^erH3uSrNWug7HNo*1`LW+_)$(xk50`FmI6Th0t(Gz7;J#Ald`f zu!=OF6R<}6BhlX6dFD?na*_U!SXqH45SLIw1i*IN{hHTaW?xN62S^{uJud7$Of&X( zwITJZ;kV18rx!X1prNA+@b`Zj6C3**Gx;#V`MLcN1-tpwo=&LLQT^40<;B~diP(0e z)0yIfdlSXFVIk$EQn*IT( zn#=9P$uf^LrQ9AiHFdranYr_=w({)` zoRDLq48m`Ty*SxABdg0_=A3Bn2@UV8bP9@J>n})`mK;l~mg!CWGE7Vv6dI|VS4Dd| zME?cpj3vsFQ#>|L*xcv77|D7`_8a!`WqNDEb|%fG`F7l4=Y>op*--H66XKB~5fQYdkcxg^ zda&D_Xkax&L#s4@RH8;9e%!b11#;_$Lt5)X@x{G%ry4j|_olN0RGtf-lfy?=C5Z)c z@@th@J?F>W4>)@$RMv8xQ)xi2A{kP3$6HxhZEjV)rrda!k@e-O0$`f-1rMPR9sVDG z9;S{Kn~0E9+lHwOKb%tvhU5H6L&WR!-b?6qtWZf`@SJSuE|}RsiA(=@QRsdK7v}SN zXz%Azm4g9d(19t)9$zf-%x#;Eqb`gQN*Dcg`?c&e8b?Jd`@^7(MyoxhHA>z!=h=Okb$jba&q`;$Ex;x+ zNI$5xd3lkgDLJmQel0;NHf1!bTRFv9IgJl;D~e8w1y05UKLN$rc=)F8i~DWQzOn7Z&Oe z=Pb4Pp>Gs|$;qjaz;h$c!kRZrb}9dKclPgdO{n&9e#6Gi&CR1yQsmv;8&JUQeuy3# zTlgIePq;gSMrysF$Du~If_7;a8X&LM2V%{i%K zWDTa-ZKq>966#6@g6kZ!rFt_};-eTchn>x>?)3vf?+_by zpd9cM0VjvzqN7DDDKI_A66CnH;mjr`Pcz=X`bqJO2Ae{gP1g}1lqS~Jo<2WYA@bv- zb6bEiUdGnft$8fqF0QdFP=qTb5yb9v(k|)_Y<#zG0`M9ivNW|4J@6oY@n*5i9LI@(>G@kslRfEPu)_qEVFdy`yTosm?7B zA$wWQ;2D&hID6E134OelW?PJ)6KoEqhR!xkGBIb-UJ0lrsyLpVSYa(HtlrGmhTXbZ zOu4xBg~8Abgj>M(jgm@@=ycqkgsaF%$Add1dl9*}9Mb{omJSHBxz9#_F@o8EHz#bD z0d`!!Pri-?yS_I5K6uEBWD8p8>J@Hq=`WtQ$2zD?z?az>dI3#`SF*`CvGc}aH1^J$ zO8;F|ZCoK0IwdH;y`)4=^#*u^gdRS>3JMDDk}Y<|kd!D1&q-0VejaEn`u_2(mm~gb z>F`En({|=Y|taS-+^yLe%~n8LWar0$JJRuBMAYJr_+TNkO=JZ*IZ73J(zc zNxW#4|9(gO?{}05m6me1AuQv|?zEe*UZLhYIug1U0%K78r}Kt`gF-MCmXBy3YHN|K z-DE3N50PG5Hd<^_Q9x@zorL}9iY*jQ0rir65sW zsK$TO`dME7|inD*0#QUMC>j>i{$*Kd6W6^?B@uf zsKVLfQEioYcb4=9SN^|D{gcj45mr&NSnFX^=~&ya1FOS=+B>8IBzIYhMN8_}AOF%d zAg4n@?0jzb{>C{-Gd|GUTfq$<@3zCz@nLz6?IzPSnp$1@chiY~jQ{N5FRtsltL;Q@ zs(3UXZ@Zgc_wvVg53A6bOX>Ms9%cdtYR%yPBI4f!Mpcwm_Lu0gi;=O_A3Kr*MK_+S z(5_R9d1+@R0y=8V@PE0<$t3gGBhc;f-h)CK&^%3wcV?)58!)_fiywx{hX4DfsHV5~ zHa$L+fT@vr>m81rqcndx&u@7h7F{wgze2ws_o=_SLM_6}lB9@W zn99Eq9VNjpq4T&ao}wnytES1dwD0U4)vMX49t%x*sj0>Ojr%?S!~M%gUiZw8_@DNh zTBzSu`LlZ_-fF@nq;k~%g-3P$1NN}RdK0}Bn#Vn7?PrUAj--Uvh6Ohz{tIOipy*~| zr?FQkqV}34@;+JcF|L02^%T@i<5^sKW?wz$o4X@4OLtp=UPXrg# zdh#iLnAtCNZ}OshL|Q?UTTB&UoM?RU3zA>9kF!f zwc2Pd+g&Urwe(xp!zep7PBs1`*+s=jj>9}9zO{v-MDRyC(fxAOW)sWn%Ed};-^@OI zC@zj0s?y~K5wEV7Bm=kuQXjW$xT#azSge$~UTCbdv9%>QK?oh$-J;Y#h*uzSA29m; zxcEZgNza>tRp=O}ea8=oyz(xA#R)c-9$K92l+p+m!LYcrwb09hpEC(E1{1gxA|abr z>Y@R3N8p^2RQEW%C8;MFv$;^a~RKISzF;##Sdq}-U>UKq8&PLd)PY0XMnBYO?y9hHTNPgUHAab@K*#yzm2mNVgUg~ZeO z-C`@co@8pebTbN~+eNf|x%KQYSNDXXM7FP&b2RqNF#2gSV+bJ>dof^Uu-f3DnXs$d zGTMqg`x9Xsgda%pj`gh&Tf%idfQ0}enE{;$A7KHcS@m3nYwg^$Nn+RY3)h$R>v zxooI@-n-)N4-Xr>kTqx_)pqNa_70q{x}iRc&4?l*?;bd(9^Wdu{esfv8=YQLzqy6P zbp~lvbhqdrmmjJd8tm#-Y(fR@^_xHJS)a=1h7j8L5PbG<4~CL%kCbfx1qhOM8vYZq zcl85>W^=2te2bW3`3yVi6&`ZM$GgFck9MI6hYmdFau^rwu&0gLJNL$TN`SyG<4}4Y!nfEL2?dj*VqU zi1AM(B2S3;_|VF~OuF-?d4IX3{OTFbQ1Byh#-0_>*|8d)QSp7#5;vs(I<6JJLL=_x zd!vvRl*$!Tfe^B#TuKhT$z=lOnt=sir7Pl!l){mkgz9cl8mB3DSOKnSAc0y_x+0mf z&z&u@3vx9kfn^YjnMJcwaPu@Q19A)>2u1I!Yw%qs&98iK&eKS(5nQR+^sY66Cg6vq zEPs{byYcBVx@%90(`S$)&E;V(Suk_rL>T(4ShHyQTy>TjL>EKXBMfPEIeaj~i5$6z zt3$^r-VsXWu^9%^4~e&oN&JT@JYkzW1GDelcm}2oH{yw0Nm^?>Sp#acl}~m?<;o?VN#+;^+C8w_B86eTQ)> zH&8iLL7UjLB_%b zI1+BOf=KVzkP32wtuc}sdR%~20hqMgJWYcpWtHs)imRg z=r&?RQtL6nmDA51v%4<-E1YNBJ)8_bc@EK@zPB>25=iv!=g=e#DCDbe@T5oF4x5ho z-x~~7W*U6N*G`jCn;_7-vX*ZJkSDpn%djEFFY7yw3hH%?m(cXISojb44 zH!zRaJ(15uF_QUGy@Q}`(MSPr%>BILx6$E39xwH|(%XJI!i}2~c#v%6%eRtm=C@re zzFeA|ieyk44ou|HB=c;)c%DHRjYQ=vhl{{B3rUC%ORUQ9l#aUxu4K+DyhPkr@6K8X z6gIX-S&pXs?M3o&oi;?(P;@jf_#mI9f>ZCsecw>q?XZWIXT(f@A^H4KJ)r*UrgZW? zIKyA@U?AzF*B~6IwVHtdR)vmdx7Ua^xs=A(I-GcsM<5jM~0yt64497<)PoP)^{oEAkGZ2OdS%1=FR*) zTgj+yP0mlCG!!RQKd)8hGAvvdq&F;cVq=oTQQeQEyB#P>@(wp-Pv3f?k>)++w3`A% zw!*3F*EDZpQScrhK~y4vg1ZbFu{4GJwfk|obXo-C`4Y#vr1|1PU%lHy zc&cDfy@$Qi75ghxmo|gCGYy!W3N;T+s&KvlVYAyFQ*hopr)T<-J)DmAFKmVwPPq}9 z(t|ET1JpdC@m&)VPNE^~jCOc=3oB=pyYO8uznAM*PI4$hf;2v@6$uO9;Lb9G_JLZs zdYJm!a6oZhT(X>&pI(!)<)6N*U8`pL3Af09*O|6HHvVxCxbl8|+D8e5O39Rd{d#7e z=llY2l&5bHdXLd5k;}NzI@4XV!h3!+>bn&yIqSrJh!U+9k;3ZALv*G_Vm*o}J#%4> zC@a+nRaE+bRWoce*e1ZW>-9ctd+HEPOo)w4j?c9mh_?=a=OSW&3!wvVTW10W_ojs* zlhGDQUSCih5{m&&*{kp*=ivL{P}ohB5PCd*GU^PtwXDGltw79qN)Cd!W!Nk9#f@tW zpwW&{`p^Hv(pN^cv36~rb1IZVOK~Vx99lHNwM9#@;;w-NO3>ii7I%k)1Srx_iWA&j zLvXhicX$1G-u10DKW5F(nRV~k``VXGv)3lYIV#wskY&)}Tda=}t02qb!wPuAfX%lU z%Png)nzJY?z>;b&wjG={gVfvn*<0=D?Mn?qM}yizWTBqgGF5wAEX_UgN~UCbGIeDv zrNaMO#Mb`!-{jbXR{;2513Bs?dr24Kn@U>(uME?6W-!w4iT6zZB}r;9dv13#Q=K<6 z_4&IxI}C`i{4ZZy38zfLyKBCFp#(LgZml)$xl9d+fjrRx^wd;_&c74lgWfo+N%r0R z5vRTRMOgi?f!q;TS{haAF9Uf56f$h2{^<*N5M_P~DmH2Bi?nUP`dqurB3zK6$Qgjc z-s2hjs1>y>25O4(Wo%>rrEShMz)p0w1WgKgcimzSQPp|hH*iZ!4Gem*9qV*q!Q-_y zDc#hZluJ+`A~6u^99dm{<;$h?@R|Ng6&2WXx;_x=@<4XP{gH)ABnGu#KkEd1iahRB zQ)LuoP1GKpNVndT&M%_;YVhlnwZBMTrsXejuQ|5|&D-z)HB#N&8>!_xN@#4>?0; zYtoY0Gmp5cR{~`v7;q@ZPxfS1KNFMSTf4H)BL+k(md4Ez{CtEdDLpJk3ZHPz1Vmng zp$B>oTo8hhkCHTPt*XZcO}qalWoU_%)>ue zdR;Yx0aWFr2nFJl{WPmtkvKTNx~9%Wh~O#C;wbl#@c<7~$-{|PA%}dvu_6+uaJ1@} zl0`)h_=vw*$FpaT! zMvBIQ+gCmJIJr))^)0eO#qo_+x(}YfsGU||50x{qZ6ndxcs#B3m||2?B3^sc%s{Fh z6_;wjIr%6%wgjROIgCYkBn9&BFt|b(q32=ER9!(9GzN8&2|YU`5z^gkhf09;Z#T0j z6finC5Dc`xqb_DyK`t6%{ri;J*;!kCUZCt?-ioMz2R&C*(?HHpFci@cXKw+i(SzQK z&c{ly%8L*HJ_=55D6L&FkM5zhcR%^ZM~>V;jE$y4S3f+WVmJ)yKMYubBtF0(nW=Bd zDSivza6OFTr*HWxFqUUbn!$d?7L4Lblj=_H{(N?!=T_KK;**!Aom19&B2K`@rIgmp z`o%M1dD{unr5J7tuWKGGq9QF&bfUfK?jLL0Toe8Nt#FLZR&%1H2Vl|X`r2$Y=k`xo zR6+xuvFY45*3qt4t|GzR`@Xa5F9__6I;kPPg_sCTqGyX`msk{b8| zk|~?te*XOaC^#B=ZjUXbo}G3Fo~OHO5>uq@^2%<3KnV4V ztGrE{geVGfjQXphUxBzCq+{^x{_`$3)4CM5oKN7h& z8;7KX2FGCGy*my-{hD1Igo2`cqV2<{73v3;VMoZYAC)vz#H5=Ca?{tpQ=efGebLA6 zC+nqeL+nHG!g^_J8rqQ1;ujq3pPBvmh#>?6E0boXz(Iz}BrN+9@fb=LU0>Cdc4z!) zi|8UWZAdZ=h4tLd1x9Cdccjns6zYQD;`bb(QX&so1RKo#8&gmU5T(hArtWm6yT|KG z)>VrvEm$7L=71K>E7CsR(GXEO)ZF7J(Mu-P_};4L54KsE{PjOLZH&%4f_Ijimx%C^%fO`U8F7BL)B~BTjC1&5 zrG(^8Oey!);F#4dW`bTCqgCfxx*9#O1*=-OZ|Q{{^oXl=$N(guu-I5;A)#+UK>{NW zv24F;l;2+pW?h<96QpuSqE6(NmX^Y!`S+DRz7b_k^$p#YWVow2Oq$^=-y4dz;%;x? z6~bKuIk^*2OpwcEp*~cS7y$=I596e+oaqOiDlFBACdo&YLDtE?U8h%{ilYFbr`}w> z;bk2iUC1kzgro#ncmJ|;-pmFGR&d58i=;&7W+mw}hlRA^Ky>+*!hi9H)GI4%7Vi!Z z`ZL{X2acYX{xk~Mg;vEIoY#n8(h~qmgH<4pWR0yp7U@mE|19UMTR64^j8uB=Xe6+G zjGUEmOc?Hgi8Cp1ONQBdQ(dW-z`^fxDkFt;>}zq!c2(a5lcy;<<%P7QG4rUO6OE*V zvC}c&`H1H|xbpZ^v!H_9xSJ?#O%NzN7C0-wAD)DzeLfKK+tm!d`}b`hvv@Dppb7)z z?AvL^7|PdWS52#g3;N^|Y?gLA>Chjg&nj|sL(Eiw%YMJuS_VD0LyzT zk|*m$1j{A31f-t@Y~y6gY{^!v;J4ru`xFMs%r9p~jKK@L$Hjorg`*zesVcwyXno8f z{+gsgE<}{W9cjOmj%ygbA^=`1XUxBZ=+rAj?TW;OBa5Am76_Iw;ue!wRteDdz^Zd^aD*R?)FXB0cF7`iIvnY5}a zv`4TFBugrE4aX|%g6#_Nws?*Z7i0A8bk6`*c(?mYbtjrBBTLcDrugrvaVU2)r4f%35~NewUxd{8=!kEHuAweWo%bR zf8)XHNTNm=HFcya&f{bbwEwX)FWt+Hq2GmE1+B0+(gp0~j3^EA`%P}whv^>!`f&qq zBzqg~zRU!Xjnb>84>YJ&HjE;8R(%8h`RD#bN$!KT+Bbj^S-!^KLW0+-%`wXxRIfgC zY$~#V*AgsZ+LuTQ%91oJd9yhd6)dtg^ZFJR*cO@wD(bUQ+5ppY`w17-#gvSPkYLtW z?RW3a&S00i(t1{TEqg79Oa=GfUM04RWo1eW{#@*fCzT8*&m^^&V)e_PvV_s2M*G7e1Ebpr+eG%G+!tJ%3z74a(D7ecq++qcBA zwd~_Nj*j8XgWmG=vdiJ5i8n{Jn<4+*RK|^_S6;hCrGqV#64D-cc7Lf@5jvr z-))Hj{*F9)U+6Z(O(i86u_&6r`AM|no6acJ1~3WNE>zBU!ZxUi?8N~=H8P_ftBCWJ z8EmuaBIs(ex;kdsZ0(|&k}S@yi)DhU<8t}^HaGpKV_gLHl1X^>L~sqx7a0K4s*hBq z85k7wi$Zi6c2Dk(Uzx#ps;@M)Vtq&VB+1g;se3DRVEPiS^jdj4>n@+f!ohb4un}uV z*DFXDTkk}J>G}MA`IVV?%p40q6MM4Y2b#fb>WyF`7i_!N7Sa1|6lo8SzNlqT^TPe7 zQXsfw-Fo0P+X8JWCwRv)+FDxVhX(jcJkNjCL2N zCTKCN;CqAa>T_Sa(}!|kj#D=PH)wA%LdR#VXWlzR*aoa_Q!+SKDMxIwWA7@(cUK?? z=UmaIqTStjm;;OuF1AWB)$^9`)JGU@cg0$u&fNz9O>hDC_}K$9p=Q9?5t&iP+-|RK z&WWmM;alSbQGYWN%7%fc7}`s8EH49dx52Ey0&RCVy15_F4_F^1v5QJ}O#17>;r zbDD+&jUUd=0oIR_$c`5*?XWhx>@W#r9c7x|O6g2fwZmEfcB}0oXh*);k*!P&whB8SY(KdZbw03c zkB$|d6b?q%qRj%vQ`7vAHaH^08XU-u?2APUBRH{jwGYNlri~Be%3=lqV|IpGuxE}$wjSqi! zd(TB0gpTP0?||#h`^$e#_2#00MaLf%B0P+IVpUt802)J`7aiuiHQoHMzhNvaXrh#ms7-bIH&UO0q;Fz;drHXhH73vA za<}-SX`1`to=KWNA;Ala@4{SU_X_$wQIdtnR!wf}5pr7lde=gT$1p5-R(X+H8E$WC zInnPUX(IK~SCGDvb;7r3`(!0z+tsc~*GHYD))HkKz21NviAq%-sF{1s zn+LzumNFHxm*DS@8W?as)m?O0v+Igc8+9ay^M8ujlr6N=^!Y}8j<9C&yq2@021i*G zj}}QC2D+y{NJjn!aXSq5bRJWCb0t#R*47Y=Htdf5_L2E+3!z@q7o<5TQoTrfXRno3H*-K%K3 zXDViPgMyQ};cMP#%SF})litFnGdFSJ?kv%@$kFU@srvYI;MV9nPm=UWvvmb+S_81* z_HhJ5ECYt;?BUhxgTjtLg1D2v_)6e8P?wA&WTD8kQ?|HeN1f@h7JD3>yn*eSh~Ra>5->~zsGz1c zc+9gsp0$;Kal<)DJE~N2r3q9BQ^pCb6KhRQ?)K0=nh-=uMncxxrUc9IKz`n@L~Nh@ zja~Li`f?=!Y;sTQ?Z-B810Q-U)D*T7QK`Bh=4g~@kOtFm_$?|_Xg4l=-bfvJx5A&f zU?&7LEn7{nTSGwBY=!TutXb3C&8sX5-6X+Xsfe|-Zo;pN#$$tk?r-O>Yg(edJ(S6M z>?x9Zn*xkjo~a0jQ^8P(OSHkU;G^11+~i4OCzuvacYh`Eg^m;-9EAeMM1CsCDpK@7 z+NFLc+!pyQ0)b~v)Irve{NF2!-m>`#+TRt}7Wrpe&Ok9mbQ2c?*Iw351{tT+gR^cG z^f^0{`|#~K71)c*GyP5|2xiJvB?aW0GumJknWfoe7vPJ)oEU=M-PmsJSyC$l(YcP-`PlJGQ z1xM=f#JRhOZBwdQP2+M|w3=MCXpx)P2N+U;Og_Jac?srv&kQ?22N>F>+M5Q{uPIta^3EJ8`4lwWe)`sYo70oZtfi<0d9}_ z^G<#m+!~HuuZeGs|57Gp2&f~Z?Zv*R7e#7*nout;U;Nwm=5)g0rOI|2yvhFp;kLiA zm~Q%L4QPL9P~63Od}7Lxms(}%6k0d-+Z5^8Y$w+a-rbyHhR2h}z}Ebq3Ss59`w5I^ zUgNr`jYRzp{~<p8oysMj&ay!1j`?~8Vs2^I zBUL7NPoMBw4o_=mtie@N{WIBTXNW62U++#D|C!0>rZtidx45kvsnx=}nT>na;XBQvuQ)r{`C0#1qkfI&)OoZQafS*iP9j=C#>?1fY_%{+JJ*VQLk zjrq$#IzCf0Ivb7spSQ-NQK((@gEZN~89%t)bzZs4Cy$V{Rrd{>nD8tD>gLV2@%b^I z_C8yjedr>mXE5!l_-g99KZDO;CM|Y(&F_$JyL^u6z4%#mk>(af9`C)&!H$lyPMDUG zyqO)zGSZu=!nd4DYDf7dDC4#r-@fpmogP%jIN;nTLNeB;+C$~&XF7^GbEu{#Qq_u( zqh3z`fP^xZS0Zn!_kL@C@ymB{rpt3L0ktqNoTXap`9X9_jXg*hFrNwEH|LXua~xl&RL?~`s%x1C>(RoNtC#7 zH`@=;V-!_abFl?IGeQSD-F$eM80MeEP zL8S}qhh!;d@ zZ{1Hvqg0al2J%{WLN$Zd4BNx#XXAL)Rh+IGFkg#jqR0!rnYfm~UJdCNAyT`QK1A8> zU$d2aE;{RG27K(gedf-S`JM>Yv@_PkOeg-7&-X^?Od7|y8QnhM?P}>v9d?$V(0wp*iG!B zOY$~an!BX78=5MRL1%z%eQ~Vs!xK`Uzwb|M>n0`2Vmi|q-!cr1tZAE0?Ie%wbhT8k zZJo|vhR+vaHid6g&E_)P1M`3!EB~bq++CE?lp_?huO4zah)S6~xrswForGCaEg@tc zXI$*Lj(B2|muYpCUo;DB?PeG?2aBE0(tq`aMLId39L81 zbhVj}aJa?ecbHY0us3bpyYz#LIpV(~T3pK@r|`xcqRrUOI{jzfX1owudSlAOh2NJ|; z;ne;^R17}5<MAZhomHO*sxbiao2|vxhjEU%m}SsI#z(b0 zZJb7_eAFmQyC{~Lj(7gb`-Z9>?O}0N`jSxS_<23@6Sm+Hh9eC=YGg1uABFIn9a*4V z-mQC)Jy@~JB}K1(g|y!!S7&?@d*%e5=eRoGvc&POQEdX?Svjd?UuI3!9V;Kuv56tzz1R;@-kd^`6F%n z)nNRLq3hMcFo&Pgvf`6@4yT5`xAx3wRYJF(d2d3Z#msT524HM_DDd=co-Wj4dCm{z zW$^xY^%Z)2ZQ<`;#ym8w0YU2F09!lpcYnzsXAZ%r7Wy|q7`lo?KXu3gDLk)fE}pZJ zEtm{!>PS*eR5xy#Jy=*T^Ld}}*+XX?ijAlHv9{s{bQHaku$a=Ok2luSB3e97u76q3 zr#qiw&(MRw^}(~-t|9vgi?ey9<E%je<=lgCZlODl zTo+psG@tv>Ej6Y|rtqL|iQIxx$^ zqRPq#oqA7>zhNfJ{&@ZeDKTchJ&>#7L1MG%LPa1~1%=h>3DPeJ3}P95{FI}8Be431 zCo$BIU+$}J3;Da+Po}v0g^oH;xa*oIuMQ&vt@NaV)n7VWc7B9O-ky0nO<{GQjkK$h z4*sXF>s`tu#k-vM-Mrty(jAJk2HaHjfD;T-l0ikOSpDG&k8M8Z_0B{me|DqL7jIfl zY}2t9g+-1Gb?5MYEcsg6ZYT0wuN-+A`?pLqrfpIlE_EnC$wRlY^sQ56&#LPQx6w$M zo!(Qtf#VqKhbj6KQGv&UDorNU-NLx%KLv(}rG=kPYW7r)#ql%9?ZlVpJrF*;9C?cl z5U5|x`vl|g^a^SJkb{8Y@Jt3SI;vsnby09om(zqvnbaSiAErDg>R3;&yMJZ}1kW>e ziP!3U*ds#-L00wQQTSr5ovB_W-SRPz(3%Eo+DF&!=l*IICOKz(lRcO!`ehV#Zi+gj z9pQzHBnU*z|0ec-@n95GS9zJ0&%0ZHZhdnkxl^;=f?DcNUX6G5V#K3_^UDpYRS1LP z)Y6|072Ip;(s_0;!(KIQWl65h)Cv4$nmki@C1F9NNVKzZQpQS1Efg06(gWCD(`A6s z@lK07dEBi~aleexzqY^w;f`zj8~_gQ>cI#2e92DJ)!V&ZwxwfXDo-8 zj@=$~7*Cno==X(VU1*YQ@Z8Cz{qfWDP0OvBZ)bE*NAvc5t9FPUWGT_$%*W+JCqTsq z5uSF!ZBz#-sXl{MOlXQ=BPi+hY1(jKGy9!C0lfO^c{~Cvadq{>Dc%bt-c5N5iK3Qv z2l~~!nat-mGJq(dZ^j6RMx=fbH?>lP-Y&RT@lf^tSx8sSbUgd!NaIaJ-7Ys8kRHtm zkeoxRbQ@enrA}6tI~|$BqCr=Bu=It-9BC;KCBMW8g)ebwpTg`<1&#;k%mv~4^t21@3-Ys5ZcGvVle{j+Q<^EU`&~n4 z0|&yO6$(%)zkju;SuRp`cR)(&^tqCrZm9s2203W_XH6F4KulmDl2v^U3z-=3d9cc~ zOri6{`e6nFj9siPvi=+GukfXH6ZeP;e1hZz5exBiB=|sl++2mcw(Hao~Qm&)|+c$&QxL2GJpIm z_F|vz+$!O-NcMI9LG>c@d41^+SI5}Q0En39`K=AwQKV({#B%?tVGWxta7UXOQ)DjN z@pK}+zWb8dXuz`r1@XBU&9y7jjP-(=jLu;jJNOjY^_cki=5 zq^j!1aM66MF5a~ruZt8lQ?Z<@7)8~j>p0sVJOzULBgI-Sb<*J?9Tn~F>6=ry3MHTd zeX}`tXbypr|F*B=%U{`YBx-|9zGx!JdN=-CgtQ43j@cjL=bG`$csC6;@>&GNnE50i z+@Fd=u14BbW{cBn*>*;P7uvfqM}JrySl8oyuV3L!gaZ7p3}Dgl^Dcf+@i5kz-ou65 z4(w>z_q{-($65Pv{6K?pWKV!I$_MWez9cM}8SHd>6`MR)o76DZU7SohAToMI+iI+Z zT*kmk7*9~Op_S{23smh!bDmfcvE4mY4XJfNfZKmURhfVM8LNJFEomVT6}q+^l~ki4E2 zY`i&fOBFs6b!2_$WEh+l%99ev2JAKd@#M)%R;Rg-VBSS6?pUJ%>!D-3Q8(abPI9Oh zTOP3K3Ra=&?cx7d<$1=ZA$^!L#Sro^k)6cl=rx3((C+U?%UE@bVR)S@Juv1QaGkUY zOct4nvtCF^M0p#?gq(7l-Vu;6RXdq0@{OWn-}GRaZ8TrClKzKJORO?^xn7iNU; zB1=KXA8ouME@0#T0i(J3GXB#ZC|wtgY6%imp7K2)tj6>$Tzlj9-E-{Ld zhOXTWG3;G zf)Z483Y+uZDcAMn9B{hY3PY1W}PZ-f$w4E1wr&K}~Y@=%6qoYQePA8-& zrPSZve0wOBbkJXWyXDz(d~Tm8^Meh1tOHBS5CW*;^rs^}EfN#7K7H*K!cuYX2J!@Md8%XVEtiiFk5ylQWe&(j z-May-L$n0nNa2-SQ8^w<;ey?ttuy~N{Tz#7ABD~?X9W!+TT%Fl&xE?I560efx$Y~r zyn^{crCJf}oBGqw_Py&fEIPSP*W!dLR9=}gjYfbE=l*AqhPzJ}F8 zNm?bV^1w+G7>;gf5NCaOadq-q64e9~#`yKWo#4qFm^uVej~DPhs*kp6+0cPDktiqG z&Lme&PeLCo<(!W@Ua#>Q$ycum*WN7c#!h~s6U{Z5ZCk((MC?chMW%3)gFh;rFYRBgH6ZlX1Kwn*?#CWI~N7?Mggu_EuXfMs@{uQ&}tRBe~IaD$s$Ea`EmPX zSRWoq&PV65F!+qTDBja=%l>AvF<;JBG+WVHeJ`SbH)tt8#UCS4aAAoSK(^IH8owm6 za6Zt#iY5Zv{MdwGkJ#|kt*{sG6;x5UqoVcNgEzA$*ZVg*o9b}xedmO8zV9%C?IF9B ze`X#RnUCY_xy&$0o1@?HU#9h3w6;^t1_&&t&{|&WJS^qUH%!hGTq8hdW$mc*5TX6S zsZKjYwUy|*Je=_D7*AWQ1oOgb78Z2X=s7xZ&SgD*p`@}Qp3*yU2)(NN6OP_%|Bs4N zAU5BK2zF*5WUv-@lw?I*9Y8V*Cd!^HKi+9dRsCjwn6&S-7y5Wd&I3(vzfcP_C|`b^ zs#(a3v|o{w?%dst)CW2|$8^DO?W+cp^0laFs6Mvj=3`?;`g?!5x}cgX{0bd8m}^Pv zP}D^VK*w=wwWglz9`b$+K|MYa@Lj{+Zm3F!z|fVh;|DPa-sHkQTMU=Nt>yA<^AEG9 zVn8X=ip>8ZtR}pUR-B_UMZz80Q_5_*b?9?v7GRUP&-aW+&ly-;_h{>`aMlyJYhz{4 zaZ_bytNC?=LDz%<>3}<}eb-ykX9BzCeJ8zK#`R&BT0W$6&HR3B+!lAOcoKaW!&(j9 zj|NwQdnl8xweg$*eNf?M2|Ge+Vdq%m-zYj@WXq+`l$U4-yXenVvuy9|u==L(KT?l2 z#vn|MdBs+{A>)N+YPMkrJo zjs9{I7CS+;T;L<8yjEUQ;^@{_B9yoUKvC2EFJXfHr)QwdPoz#=!5FVQeOR&a`<9Tr zxrhKs7f)pb^&_?n(;fhDFRj#-pulnVl3y=L{pPX~N;8Q7yVZ}{T}ibKe6X+kw;kI5 z;CVP#j*|b@kKnr&I|pGJ89T+1U_i;>v#Sfd+xzKj0`|so!2wFxMdm>z^G;1wyR+Z= z*@^2pYICpM?YRyscr7X6LoXKl%L@FI)8OF-i;a>lxwZb(N2Zfr8v~~SdNVa?iCy~n z>_6cxKHc%KSf_|-jugJDlTka##AkTd_37@(m%Ym%9q#grs;#-Kut(Kw@a^7QiFOJ- zh$^9K(7hmk=hS;5S?;VvG2B!rWx@>4PVA1(@rx?Tt^DW0it47xj>SmJRR%emUkp-h z?gj+Sc|Uy_o>&C98IR%@m|7gp$8@ciHBWt5q_H5?_E(VYsZKL__HXgkMNI_!%^QV| zqR2LS+Hyf=%gxEG+*yQqiN$wBG1*|4$jC9hSbS^KTc`F`ZKGm7S&CjrNq5-$|5wW)!qc z;9%wFPhy@n$NLiq^B6ab1F>MaE4)LytM0*X@DPbw&CLVuf)kk8SU9}#trc?VyP^uU zil1V0^_9i09||(acDT^YSF9^MAYI?Df~9**J4D!*j9FjM75XlR|E9GzH^3HEJZN7& z`RKl3RAb|?rTLK!3Tq0B9q5E_Z+$tExp-xFPNo^@O>HxlCirtKh~Y0yC&v~4JN`4vv!Jl=Z6l1SdW;w7@gZ0VzHBK!9^h>pxF6+J=V zx;y8r%AND&^9nb>;?<$0B1bYKk!Q9mavhyAbrDRwvK>{BC^|DE#$jAHh+${jcuM|x zs``YcN4q(uJz7*ZvQg=bPgZN=M`lle7=@hG)T$CHG>m5V`vG?bsn@Y>`dv-=(6c4Y zZ-PQ0zinP!hNhA!Zf)gQnSZT~f8OUEG6y7T<@Aet&3Qp4f}*kfA(F1o&KSw0J~lKg z7K2yGKce+svBP@}*YtQ*V3@-tC9(X{;F)-}u=sm3jkoxgJJ{HLJB}kpko^zgUVO5bt+ZD)^fvF zp5k0OIu~nc_#sTiM3K)sCWju2a}PTR_OeNR=$8ulS}xBPOnd)Uh+W@S`OWlSh_>Lq zb1N6s`2pX5ff9TGp_b1lAIwglymqOBKNEdX@|sfU$UZphQM{cBzd@>Mek{3EU7PL~ z+TN=08r{X7Fv_(vPc}*ov5KylROLUy;@lkYTfxDso8D$ez7vu+G~jwG)F@FfwNg8TO*_vRC{$`K7aLn!fBu)UwED z#v@`j8KP`1428~be7!^Jr4|G7SWdc$XPu<6yE}ZRYF)zhiSeDzb&H-PKQ9}5s=0?N zj+i=T|Dz;cuEA3{`fZntpZ{fY>mTOJRx6Q*0_P&89GF8p(=kW(LeeD4`1Tp+E*j*1 z?AKH)*>0)Fh}fH$+TQ;0){x6wcB)M$#ReWM*azDD%6xH;^KnfaC)Kd z*{F_`m1pF|dGC|Eqw7di?M)EG5WKM^PlA|}Nev6keXZyd8A!OoO0s4Z5g+q5M8VQm zE{1EM9FR{_5&L3fs)@OF=LAV?hvhGAS7l4W6BLO+JT;7$IHvMp)t73_BF7yvj@@2Ku zF-)Jqx`{Q`S6Z(j_$DpF2VscF&dUy2^J8^|%0BZO{j|80QTX(^u9XK=YJcIeZ z<6oe-v`migp@(2=ud!A~@oNcRG!8^Zgw5XO+@f^un{}q@MGMP!M8DRnVYf7Xv8dnh z{npt7LwY;Z{$6PV(?+1})_C|S|EKXqPmV{M?(|g{S4CeP7Idn?@He<5n>ggDQf?Tn zq4&4XeT43mcvQbXGAs3@Hbzi*M_f@oGvFVYl)ul9BO-OY8L_jOUI58=jMChW3$S<+ z+w%J6mS%yOGK5~ie*gX*lTgdy`{5yJ%WFll)sWtzqo%><*Am~eF;CLTJg z2B~0u21%?OmpunGCO+$Bmsp$QMh}ScIPHR1KcP>mh!8Lw_bKXp_;p-XSYA|=D?i0y zTzoN8G3O z%LiZo&@iDs6|zb)&e~;Y7-&jkUdWTo;sJ}l-aOXV>uA-aWnO91@1Xi3=1nzyO?p}v ziOU~YcC*fJbpbut5_MTEy%ivp`FG#JOu2*SO(c%8%1GU_m&DwS`h; zF-wiSMj(C$htU-|pOQ$)ulR5M?z8FkAV4f$lKUKyn;riD%kfZ}%i!bH&8F9kc-v4w z-IAs6O-;!~M9BrivwKBGf}-fk&-?j-^$~1r6U0{PjsV_~w)9J1654?wEjNWx9hRl$ z%5*#?Zh9P=b3l%zMcs=yskJ}CR)HN2-~aWqgfD%B>u_^7PnN~4zTf_YGI(j~>+p0PVscS;fYnFz0idL(a zlp#o}FaGk@AIM6Ov%N@hE1xVxtH2A|{X4UZ!y{{lw9td!ps;QVm@x4(H zYqQkKy(Hig?2Oy9>LY?Qltz;;Mxs9&y$D?nuIRLJx(j8vxc7ZGsM-w$&|D#mP?jzY zkufU5*{Z9xZ*NlE^hy0RDRcb`PXQ)0UsL-`D#%U_d-n2vdxT6Q@cA7cxvtHS+8y~6 z`(G2?+|1Fo7m9i}%TXj~ZUx%APq0zj^)hxo_+Zra*@WmOPD)_M+5pGfijmE;qtUKG zsP>J8fE8Xy3FCgcqH)p3rB2NH8lKGn0n<*Wm&0~_Hr1rseV_AbW|92Fc7pBOL2{6k z-1-z5Ffvj_{pD!iMM3i)orxRm~=#r)*L*8zenHVN3f77#xWQlpD^3s^}>uSX?|3zB`LG z_RWs5{_<$mZ^Tgn0sqB1d-lY=n*N7XvVzJf3vdZh-W}l#?>%p6>msKc)K1ubx=Ei7 zvh@$DE&C5(qEqJOg7;`&Mbi*$HJ&T=E|p}0a@rP`-N=-BGsBj34I&&U;bZQCsQkXN zybqe~Lr<}dY4E%ICUsjdjH>>t&yl{mcz;QG*WSEk@mEj9wx2_b6oloi7}DNQ|JtXM zv=Tjh@;28Ms;C=t;{^Ivrb2T=cHeTjc+Y#o;TUYH%}Va4MX1WlKdq&t@G=*VpUpBoY&1{0;- zk+50NsU_&9eS-<#m;-sij3lySr<=&yxL#;@S@nV1wKXH_gLaAm&0O5Ci^^(;A)Z8pdGW3PKAnHhI=TF3$C zN2@H|-x%Lg=i?=`5jpF8-a>Lxd@x)8_m4wfA+4S37pa6JEQz6_Yg>uEu2r(@G0YD< zx1_-VAiv~$v)G~ZBTJd(dXAD*miGddMVZ4#=GnCSIh&+0I>k2o9RdaYq*3R38l-li z$yJP#$^V7zqClUpw|`yf1o_{JO!dNeOA~UvEu)%BG)^jF{;u^V~t_}`f zC9P`|n*N~o;n@d6N-LUY=*`i=3t#|2lz>^=<1NpRG<~=WbRrFNi3~Yj?C{d~f+M znw-a*8c+K`Jut1cziw`1TnRl?(;(9{Odz#8-V(l6V{deTWV=V-hnigB5n=xJi;nDr zOrM>d$Qa(F(Kwsn!-0ZdR8N=ECY09W4)`%Jm6sm;8uVeAm$E#IH6iZWf}(AVJG=Yk z^MAVEG1%ksLzfZr{b8knj*%9$4!orr+aKTs#Hr(t@Of#heH|kM8{PXF2oF>AT3NHd z&#h^6QLr%7cR1-hcJy^PROu%%Vf3j?oXuMpxuqg8_!U$4%meGM+1{1&b<#w+@cClt zJ6Ed)gDHo|e)wyf!!}{$PS?Lz!I?(JsVRPx)Z}7h_b0D~LQFkbg`XLTx2`Nv-Oh72 zMD%38sm{3TmH#W=Y>rPvVJ?*X%<&K zd~FAFOo@T^gcM@091is8?{1opav})WdQDsjG*ZKte8qv&3Z69&+aqcf&((ia+m*SV zEjCM_)OEuirNsuRmZ`JmT9OdAJ$q)ReN2MMlRn~Iockla;MN5!2=DsWVzB$EK9u}0 zCX_KEb$qzOOVxOsj7F1-jq6q>fVuM9@0!wg<{Qp1omtW<=CH>bSQ+dty^~+^29uoR0g#58$a?3eZM3& zNgd~KL2*2QO`TeX9uj)lsY4@DHzYdQ#J-*Wt7`;$Rxv5xK1Y+2`=QS2=Ybr9d!3D{ zaM#v!_08h_AxS_yQCps8iq|wIh(5|Heg_nk5f1##I4_`T|IBAZO*xAGX)|iM{(e=+ zMXa}D<&;l~`@{|XE#&6S4PkQa`5duZ7L42IT2Zcep8VxgSbi9=YeDzPE?hHv3ynY$ z^KZ%!ndp#QYT!L#HA*sZ`9&hbC9hkr>^7$On>pZ^@-A<2el^7H`S8z2Z9-8Pfd#p1 z(naN{Zo0`V%OosXVfVNPkw9|IV4wHR<(*Jho(o#@d5|-_e8T=iX}@$uc=p z>Et$3;zh3KMpA6Pz7U?0U4h}tRO!R^{ft8_Q@Sr>J)nFjTP_Z!(9iSL$RXJ^;3LJ% zYe*G$OzE{AUwuJuf1SOzHBDxAHBBO6lQT2!@?RH!PEU%+DQAa}%T8OidSzRsDn(_} zOgzKCxr94COUTAe@2&Pd%e5cf^Iu*1>J)+*P+-d+$c#dp-!6b2@pE5mK2tz>~ON#s4z%mqj3~EEzv3E~+{? zm|lOGWL_p6o;d#VgIKm;8BlX6;u|k7u-=AtA%%{Z&E9mda{c(zy(rJsOV1E;4M`DP z&(Uij=ppF9h-B%_pzI3*<58phbg}Zf69tXiBZ=66tAK;~-F1nR9`-z)6@SdTD13xX zgh0pm`<>8EWuE7TRg9Vo?b^2M%jqr!ZI`|HDfq~pu>Ar$-@BU1?bRz zh$?$}j(eB2ah7HtFCHDG3Xsf4mlFsaH||b6Nru)Kn~|Td=}84c z^hpLCKT+PPZ@-Rcb1=${1+tSWJWQ)XA|Ht66(N_+cm1k3YB^&4ss7x9&Vt0E6YY*y z_FS=7qy!mAisuLe@jlMxpmb4P@q`|Md%9TOd(!*G{_NW(dASKyK7OgLHY?$IYkU@? zo~)CPk$55aD^ZSoye98-vNrm&TDobkulp{#k`KX7s!`X3{iJj!Ns6!RVab$Tx(&}9 zvK$X9?60ZBcVZ}qUgZyB7Vo1O2E&d2SaYuK%3=q?X+KP*rx#hLgueY14d|X)Ac}6$ zOjG!O0M9@$zb2SnS!71UjU&||!k;Lkb>}HgsjZaEin-a}6rsm3LuKJ4+5W&&GgIya z)zZ*AD&o3LR-}qqJIl;A#*yoJ2Q|+?j9;}fI61|_(mYc`%?#vu;H0b1^+zrQh*+Ro z8dxDq^hA^D^qBwrZ{PDzN}i}Cx6(c|&Wy-S=2kY?-=CpJ*wBE-zvJg`?{h~xjKrp4 zks}MaSP*gLmdN+^CzxtYAwvEk&L&<26^OifMA)X~1(ECZ(wwA;kL5#d7(@_WF7iJi z4w5|X^<~i*tk3gnW@uU@^QvQ*-Z62VkmHGwa&m>wG5OmseE*M+QFF}V)u_l1zfgfq zc6{~vEZhC5l!aSy?Tmr2+X*B#4A46=C*pUBxF&ho8ODUHMTi{uwwxtTJhCZlA7NlZ zir)vB9m*r`r4AN%uJPO=7N0_4YsJ)m**{A*Y)a%ZZKNd$KU*&Rvd9^xr-dwuT(`Br z7q^G!L~Q7Y>+2qRWID0H)?6V8`es-w3i~8t}W%ae=_HjDV(2w#DG?J)XYs!YZj<_$=hPRxs z5!wkPRgTKYLehMV*F=zL@dTwOI%wI%lhQCj*SLs#209rjPNzC2jl$YG+V=NFEF^NZ z%3_*DEKpZlP1n?{i1k;P5_x7zvK3Ya3Oq7$#8$+NO)^6u9qGG7Us)1q7V;S1mdDI8 zkd&Trx}_HZH+E#BwbqwUQ>;EVnoqC{Oeb*&R)shoOf=Fa`f7OZId|o3h3>VByja-d znQWEXG|$jG>V$25;u*`QlpH5f0ad{?!Ky^UZ(Y_jh#Ai>xH4OZfcp3Fb$d zM1C7gi25(6-c~{0;0;w{>qnJ~guX8c*=wjTBQC#>hAv@47N(e+?WU_Jn0WgaT)$(3 zeqaU7q7S=>@#)H>AV?b(D{s_;O6eXG&zKnyabq!UMFG67t|4hyL?{l+iOWCE{-pQ*@QkSrkZiQ!}X}J52Pwq1Z(S(=#`56E;aAsLF6C^3AR**R@y0kCiTM?^dIVf@GhCUju8Kibi z(?2%FymYi`Zylw<@;EA7=jJtUg7O9t%{^L6TWglZdImpy!e;^HE570@zTzvs;u!x!lZB+upPr?@BMklH6<4$4M}Eaue8vAD_&)|&Xr7twd?NL9 z(N=K9+^dYf;~M*W>qFE8+Tf+4BNF*gvfC#`3V*cd^Cd{;wr1o9wR4vpP4)^7a}FJF84pWl`YniL;hBp{4ya96kOB2McUB1d-w*hlaKaM)~6u z&5D$6wVnDTCmf88xuX+BMCl--TZioI?Q^&%p0T;e+Ts|~YwJvI?7>_e-KlywJ^yE} ztD1{Mx}H&y^+}5$htiwmY_K{|O-pP5ZfcHr1r<^zrsd>P;m&X&RneB{KbONIG>?o) zag`X1!`T-4GF|b!bssw;Z&IfZn3Uc4GnHoEVso11j&zc2ZeVJzhe6RMoo~x~?`*NX zIK}GxI5XROqIC!CZ;dfl;Kxft8_wQ{A)#^aya=i# zqko_M1JOUJowCK51(uuRc;#xrFXv8H783U#E21@4kL%|j@ZDoKTuP?sJw69ye}9*) ziE*&d|$5rt= z)WmPha%RPSe|-T&dTVcEwviH-+qhZkV4OZe%k&B`URwD0n7H9+iURaFd)WyqAt%kt z2ggL9RaPcTNO^h{M+GBXvq$Ng-h~89mjfJr;@_Nw>MoeM=u~vpZ=Qf+M|{`dRF4bGR_Z>e4*(+tM52C$O4HRh%V{ zZW^HDQ9ydz+VR`veM~imVD{`b=Pzq;Q$K~*Ez?4Nh2+T=Y!BF;Xkawi7#l-_qvN}3 zXBm<SKWO?K|ijMiO5>%Cu~G zr-{bQ&sP?*S1>?PRjMr1!=$8NyZx-U_~K{(3}qt=^dnm-A6S(gX|uO4p0~Ne_VP4~ z^V2MB3cDcYc4u*c&6ycimR3%0W~??bR_uwXfd=x<@i>(%FfSPe7R5a(NOQMCU&#pt zn{p~;yYFHYWMj3?#zGV2-nO_NR~9N6UlkH2#(ln#vKR-f4UD*F7)My~0ApedCEF%J zBIj&&q>vY2jJE0v)Pl;1Z9g`etIe{~;4VtPpya^B@MOdJsc4#b?Y)lNrIARfA^ zJkXBDBDSB!FD%9%k%ii*$@ay;^CkB@t10dIm@G6^5lWK2A|}_A2r3_favQA^N(rG|k%FC^MVVhQsmSKZorp<|JEe zb`?8E7n~}$8D2gb7ipvMU~7osqF|z)T*OGv6I)@6dKb6Z7P4_5Wad!(cVkM}xok^p zuRcKMsS63c+kAO_P`dvPOD(Y!+W(&W5AJYIH;k~xSq4@`gN6S%kUlH@Ow~o>t9~1O z12MPd3k=8>O6O!`p-7Eu$o+T)1)nN1I#ha~cQHjYS5Z?V(CQmq>{Pqoq33?;sCUijdX zKll(rrlrb4QC?V`)5O8Mkj$D%65X=0Y8*(KJ6Y#e-PCt0d2G?z}4ogMcs+Y1}gO!c^Qf6=yq zaNa~{@K{|F)9_-lJB3UOUoG8DMlN0IrZw3d{}(^<%+MLz)E3&7g=h+|z1UqqmXR5* zHevV)Ss&aul7=JfGgX^NlKDflO`Ne$uBSz|`PEEV03MXgl~}n=?Y)#Cv61 z>#m{2*Ivk$HBP>-$e-UoCO&-%^K8#olI7)wqmD1u?sasINdA7AgW+^)6Q5$E`7M9> zPK}!nBM1ubV0uN|YV3b!t5#2fg}y?9lfo*HpJlT2x82|oEeAnO32)}^!K+|?PD;<9d~_Y zG)-Ix>)K*iimp~0s0r6a{n2B7cf}IB>@J#we?HigyFK%zj0N;jHe+Tbk1!a$(ZC*_m)`bFQz2H4n2F}d!rgZusOnib)``3 ztA*-)Cv5$z>6ZSw-a%iw8~%5%Vk~Wr7B4fm`8L)QIvL!6}o2I^Wkl+4gPx53(A0ohhRVPa#6R?dR(g~vbN-DY=joRx)1rne4`NKldu zs`bZJ+m;L0!$=i!E*(R3uzxJC+rkHRClah|guy+x)0Kthb9v(`kJ?|p;mM0Ad~?T{ z+TD}sj|e{4U#KJ7QyU8(9hvW>6Qm;sC2oGy`2^22@SN&Zywo9Ssb+^Bli{>+{Red0#n~N6pyVjEo_Kn zBlo2^LG*R1DID)x*D(((v@qNrb3G#-nw~XIv{MmrwhIc*4fMXD-DekUFJ?%dK>$n&G5jgraf``GZ2Qj=%ae zcG($!Lu0faYTtj)V-;cB%2ydXK0|7M??9N20s3;2h*B`dEvSgZA(2nYzCFffA3YhC zynOKkPkmnFQNJmAAWhyTOZ{0CdjE{FvonvAN0~Ubwfp;9?9Mb$9~FT6V-sG!YNGUQ zfFpx={$d@4{)U*!-N!Vsg*?fLNmYA$WKZ;S={U0#0^bOE+uav=s7!O&+g)P5A&DG21C;)%jEO@Q83SLgEF|Gj z#KGMDe{88%^a=Dsrmlk44VvlS@L=981FANp|hvK#GMo zSg47i$mKZ?bllMnucm%>=k$@WAK_?@R#sLS856PRDdQ!rJ(N>uc?oMHV@#rIs1vcz z9wS0mVz7K*h=N=YzJ*gvyp4emHbl<3#N@;Z%To1U*$YHp!0H^^3&Pim2cI7P9ubGX z#6m?IIW<4rMNBrhAU#8px^5Ap=;3hdCvINT!!)pvl1VYfA|H^jC+z2LPpYs@7TmjL zh^mvw!<$8ZFEvGs;Xtw?+7JK6WhFzDBb%ra`J1#nA=A6Vb!I!>kY)cAQ(ZmueKIH< z-x1N9$i-HKy|e!`S*Sk36JG@*4E2*ptC?a!T6LRxO=W^U`g$TqcYj4t$&|?B_Su!@ zYj=x1(f8H)N#>X4SrWE=Yjcy`*;!WR=GYLv_dP@q+n-3BmmVsHmb^&lq4dNV`U|av zRJ*^x;jsoT9*LBRXPo4+Z}hzpF`_Z{I?u5Q%Oqt~{B3KJsfGk19dvo7<%xbknTS(_ zyh(B@WO@HU#069J4CVXaq^pdQMIdIm(}*sMF`8qqH<=9kbErT5fs2N*gq4l4Acjw< zf|!6QmPYbPb<;sh-G+NMRaA&L@mH}n!c1L+upwu-d;b;}gkHxt&$1wfLbB)5+^^4c z(w1n7m%Q-DH?4@PpFFPKBgRwskcH(X;YVee%{v^-)X*woOM{C~Ftv*%SL7j5|L1A~ zh_=1RJ?9v7@|NG((8KK&wpT@4R#zBZla8z%XR_iYv3BRV=@gHF6ht25=(i$2UteQv zeNV`8Cq1cFMCqKzA*q(M5$RW=11nv0riNnk!)>0O|2=Z%!k*=gP%dMY30pZ?OsU8j zOn)#XI=7cDp_g0BP1Jg7;q~wyn(l?f*UvLAZ1JuvR~L8P5<1osN3O7g8h?3!ePAg? zV&Y|5u-?M(8%Ipf{*{N1j4?_dqgyJUEwFzaM{Wt<*_|qnp@}BSpov&gLY$-#k? zUx@oJ^)X!^Ot`^$wAJL%itD0WZuU-1=R@fBZjjQ`=uLel3?&rcABF1uSD zKlUrW;w%0K!2co0Lfg%Z)CA*cXo!JrDt@`Mh`j9x2b;|0d6R0Tg07}H{^fl%yw9^g zi^Kh0Hv3*v6>Er<&O?-~Bd|;Cqor@<-Dcwv4%ywDqW^U$QJT7FT+qVCKbENSa*B$J zDJ!d_Brlz!Py?*BuW(iA8BbHj$s7tr^$!8(}R+Vdi|pZbZnjs6%1qCL!1(VlLC zqxKmd*~j2qIDcZnPDE|C%EYI*xQM(24}z@aP`q=2 z7oHhJ^oYDtx^$_7;T#Wq?mWQ3#Fq?_biUA?=6R=o&pb@^9jouU~qK( z^kSk6(jv(elf{Af8{2XAW*o_NQ_>d4I~;6IFkcr$q@Fy=msRj87^Csz503k}+#Jtq zcPq|c^u;N$N%{AG= zL5AIrD0#(VTe^6vO-EQ}r9YQfmRGQ~w!|`bhF;lgR=ci{z-sE3_c>a(+hlXDg7jzCa8@$GJ#UIW={2+8jAcd&Ly5lr zGj_JoMAjUYGk%XfCQJQEGrPo10~ZXETIl#-@rI!^n&VBmrS6JRR2|hb5?N`Q{#W4y zUYA2p#R2P=iKG@4Q&3tWbf}CH@!y=6O1Nl#&mYw7(MV{bUI?i4a_X;R6V`?qXse>K ztduuz@~Fyn`p^(1panr zJXZ6-A)}90Nm;fQm=OBuYbM7{=}pX{AsS9|QUrDw$@U>d=MMT>&iG}w(IIAO2a`MVNw2H>LVCuMh{3?6vHN5h4#8BAV4H;ReLOO;_ z#SRnWbjl{ySr?XLzdwa^KV6Ki?a@mZphiYcI0@0|Z85fm!T6XOpl+2;K+fcmoUAl3 z^2Qz~-LuG9`CyVc!>m+K{~e~8Y)d27LZ1gu1JLzsVCsvdg(R|2JGD9fIC=Z>!29E4 z$oAhJLuROq*$ov!D&CTXrkJRVCED7U+n4mP^or+A?+AlJK~MK+mxDcF+p408w=m-2 zk9t@*N0V4qL7~hK2!BwJO-`&cZes40?p+hQ(M{IKKHF23RD~$xp!bxA4q5o6_tHBc zQLawoSN96{=UD1_O{&E;99-ORsXbuwogv#}XRel}I5+IhJi^6G_@3Dh^ismX0vm(b zq-o#AK}8d{ie=hQ57h?{jXK<7u_cZIm+yF_YmQt%9X%@&0q`Vt*qg1V#PvG1rWzFp+4jr@XkPj8o1C4JTK+1@=})>56`ekIiH522_&SxA6(zBBZe+mdc>J zT(=HJJGGAq*~Z5>6Xh|yd?bgHS2BrR>&HhaY_c^~LypliTup3nDj20l67BgmW?pNc zYplt69pMiXa>;yCLaDH;B_(B46hxC1W`fH14|)E;4Zn&hdcU}1E=G8_frc1wtS@Tg z^sOF7bYHyBNViT>4yT|h`$@2t5MNx!4d5DRY1y<5Rf18LkPB{k& zzakhivQP#w4h}rH<|u4P3$>Htn*B{y3rtD&cp~BnKm003=>FL8F<%=)fzvGvtetUA zYouAo!vVvL*2m)hP@NZY)|fcOke*vev9Pa_4=SrDCq2Ltcg1gcq-KOcWCfM;k`(re z>q0Poq$K|Chj~ILL#G2OX6s**qj3vMMKhd(i)a#tP->QR)a+QgA>odi7`ug$*0aLM zNhOrXLd7PyIw|th$s70TRT=5_NXlEOP7TEDi8(5k`NWn=l<~J%7ZcG;QF1W$7T%cp zR#VWnbUdm{qP_Vf+c;rk7)3~Oi;NueDfv6X4!c_e)P?vEB>a?(ehM}HD{P24*rt_Hw+?u)$E>mr&14p(}f=uMCJd)r)w0Z9D@bNE(&iOBZ*W5&qFn#FS4JP*d|F zy?^->Ip=U`i0Kk9g3X>{YVAZo%N|4fLid)NsSR@!*Xi>3rY*j4S!7j+_&~BnTBwR)58odXWm8mJ64Ls!9obMjH79UoWl7W->cj2HM2rtk!l2L?n7 zsuz68z;+- zAzK!|l97epcwpcX$|Ky31Zx@a_(xqFLeohU zcA->^YiX&7Qwp-kOZLG@=`5;pkI+gTp<-f-t%)kCz3*XVq#?8MvDw}9jjexdeiHFZ8iv zMfgzJHi8}qNre6ckt6DHPyGeYZG&))D;($nJVebnk*LbqqmA6&Q0hV-^FT!z&A>b|gc7|yQALtl4#l(5U!ro| z5H+i8qRXd6eEN5c^@rGMWwtp8H$xK)trG|+m}B7`F<_tB+?Qlp%A>6#Y{i>48pVLV z8x4|tum)hYuWqRTjMOZ#Sw3N4i$YJ9tCyKH1&a3*7SI?Y z?Ed+?7}>^?HYvjJcVjPd$JsjSBRsJ9_Av(f!neLEAz$QY<)y+#7Zp&RlR%7%g77DQ z;ybHYObQo{$9qpqe;y^izo6>o%F}d-F!5HfM2y>?!E0v=o_?c-sZ%78g{4R9CXy{J z%A+td3}3@Lytwldih)%mOQnzxC9+TguiT9I@h1~Zq8q6`W&6dG4@7>R?M$%d&)l+! z#-?!cUCecaZPuEC$n|~7Sv4z^Tw+Ph6S=eKQ>nC8Qj||gx+}g0s@(oJCCpxyQ82T{ z`gSX2Zss^&e1=;>3uTj^c$sRWk?ss%EPm0%)-hA)_6Cc;LKc!?wUXXUAMT#BL*K2G z(%C(>-(I%2$xf}C$f04&;i*MAN#J*6e zUWTg5seJQ>((+O=6a4U1{SI|4J+#Aw51W>Vr}H9(%)wjJ0M&*8jpD9D029kRc0hxb^`kg-Haq_ z;$f+Tnqwf5ZJUf73OlgcK>kN$A?c}o)JJ;br)+|;VG6J6XGP4k%1(n9*?x*>SQudc zrdQ18(fvQh2Qnmbu+8Zf`s(w99+Xg&lS)aPEtYyp+*h|iBTC5N!23XbrL%+vVFR6> zh*z0V#obCB4c~l%s#iY9XC=%X9X;=1 zilLzw+CEM6%t*fy`F&3!X>NDW(vjn7Pzwb|r45g+ZH*8Jj!eOnG|uyDS4Ouc8aOWm*jZdKsWde{&iC2Xdxxjl~g8#qH#YnI2z!0$8oIi^gthN6d#Yo*A{@)2#O)_^oE*_e8)R7ag4juH zi>3)1=k*vjBYE`VJ1868`asXV;w!%5E570@zT$KEN)|f)oH~;1vYXxUqrc)SzT&@g z{I@3y9dbCBPG^Q8E_OON#ubSKY>%b&qc_7!v13Y=zcu4=3hEQ72pDL&I zl{xn2*LfC{k7vDj_PfkYYRwE&&9Njp%JKbQe&l!m{ykT&UgYv6sT~((pYuQcm7jn7 zD`&3WL*2K4?zurc%@d4kh~l+QvW=Np5J6g%T%c!z)e!!>@o zmpnc1cxW;MO zQDN^Pb@-Sp(;Q>V{TnVQTNlwTetvQ-=y1EAp|TMCE}q4~ESSh% zm|dM_xX^=mqhD}IEhlwEwBsZB*kpCIl6<@CIN4fYkuy!tZz2nAu|8Etk@GEF%(XE} z9i;JtMd7W9I$A?7p=W2vwV+0trlbXi6IkzKtHB?mr`Fth7(sgPA}80{4<>3E%CN*l zro6=uTVB{6eDOTLk zbjKvRp6Y3-=2S18uRXE9aF!c?{+=JdJ;V9S7rA&scCUQ@ul)G)_xx`vUg)KF(Jln% zH?4y%wlQ1w5)Z=%+`W31?|%H5ix>XNw}1SZ|NOtHp<$m-ZrAE@WuZLW0CV1%+x zG`@vG9^Ww!-(nt?a*43FL;isyCZWyLjfso57Z^)1Ak0n$4aZR8TV{_p@lIo*CW;a- zIgCxLaZ4BdmsQ+ora3q8i)0}sb%`w0Cp)(0V5F4J2o=1Yjqyy)rb5_`k9bL+!rHLV zoiI|IZ}8&YpZM|21Jl}~Q6wl~5~gD>fRsyO+36V$vt~o;(de-=mJnUnL8*QJdq3qt|aE z3k8=A%F2~X8!3A;%`_(2V|C+qTo-oZhOQglMKiQXi@)y{!}o-V9%Zb|2S3Am{O(`A z~d;xcSaGSiw$g2@$Z?458d-(c*WPZ4u9R7gdTFGlA)h)!;mz5V{N-tVx# z*iCE1QzBfIG0&c)Zt5drcmm?PgH0A%;>h>-iN_AUsHTmubfiqDu(#AlSIk`;9hA5b z&_wZ=wA3n*g;I&uGD823J(0Du^qg`UyKF6VQt5S{Kznr@OQ&gB{7if1s+cNq;fX~E z>S1+s9}|`~hl{EAyo{UQOPq@)WcCO~S?x&2`=Ko^Cdm{GuOE%|WHr5^7twk8SN{0J z5BzlY0_QKhox}57zIcWUzntMO|9q4C3ciHY3wwB6HSN<7eSAw6x}c4t&oNo(xC`s! z^;Eguz|q_{CxE)e>9HAL)g?2(YH?w@uzW27K(PT z=fPD+>|$G~n-+iH+h90entfI5Y9h{dDR6ZQt#l~bRJs)6d=ZtGcBh8Y%I_zSg z$``{Y*ZJvhKk$dY{KTaT=ehFMUSGb-`S1V8FW>x*UzDsdNouA+sN5l44A+JWJ7rCjRZjlpI zY=T8lDGj@ujCbZ!?Cy=xWk1sLdyz!?^xTMuP{g$ZdHy)uHN(m%=>xKmyNoRK@C#(2 z$fvilmXU?_kH|tIeqF3|$6oy|zx(HJMSIS1QN$@HZTy8_e&EdC{=n5I>gc77Qz!X} z?QvGB-SIKH&tJZ|CgK?3PrOAuzqWm9V^6~2#t5r5zBp@HbN+e=Nv+ee%0llTt1Og6 zh>8h@58R19Sy^Z%k345<^v~<#o7p8h+VlPViP71gp)=8dNTcg$hg6W-xyz0iONJ9j z3HRo*b^@{06MV>9kYS~k@i*Q)x@3!*MK%SK8^4(>v@hg;y4Z$zn@c>r{sZUEo#U6E ze&oWBf8t**yx_8V1_cAl$3)IAV`aFKa_4K<*;ry4S5M>2&M~=OYIGY-N#WREv?d_1 zlFGrg*RidbP>nUN!k{!d72;hT83)ID7Xl7xdil%b=E~;XvqFYXBuaS9x^(_xw%B=r?DCZVB1Fd@Q?HM4bG?pMK(R|K&c)HW?(fEFHbP z&=p8+gdF!wEig{5r$Tkj@H^kct{nsk3OZknKBl|{6Ov;X&yg${-@8F;OOi-RE! zQ8{FE?})r-N7(U0wzf7!ei1}TkOK00FVKq@axwJ2Js**U%13CC4D#W&2(7co^-xCb zmw)8q_4~sACz0H|z{p7=!(taJmCnTbs^bt8KxD^0gC7wcq=!v2+K^3(xeGShiG;^? zA-Vd`_Ija%#Tp;ty%li|2_~pbq!yndP<#lf!lzWxCcy#+jcfey(=VJk`#ooW`kp`j z(|LY6XNR6k4c&{rm9cTEXc^17s=ny$_7 zpX`8XaDhX<*> zQ}mydzt3TBlY_BBniBPJQvMUyM85I;4`;Z1=1=_lw-@>A155Pd+NqPyl|RNpn`n!h z26m6sadJzfSUAIzG2&aYkb^`PipeE?T-f~KZ2Gf|@pe|jEw_{0F(FGI3DYLqt6j3n zUw%4%SQ@;cP4?Vb4!Yt<^?8i8wGJw;CMi2rUUH3{Rbey3ov~20=Z3P-{|=Go9e?{d z94rqoRTD;-`9sX@TyU>H_}c!2Gz9&+D3Tf}=SeTJiB&unDMFU^ z=2;(pP44Ru9(ooM5*tiOhBFTw^6+~z#HR2CW5uo_Uh(3FZwFOl;--g_EQ|YuskvhD zIFj>rCX1h>edYIqMCj?Gp^HNGYM{!gMp;>CSlE{CmklVx$f>*_!L1vpSRLKtH@q z^e|Ub;hBa7dbTMtn1>M62Qw>^48HLo&fpvYS+%^GIeN-z z9Fv9Yui|UyPpG zaO-h2uLqWymp1jq-&-Pm2{pIG*wg{%fKUQs6Nrk6BFWDf8+A3l`?C#}!L>B3h|!Tf z^q4I49Vo1}lp&LdvI@{D3SpR_rUQz@+CMjeqec$U?&zG$vWf z$U>&k4b)7pi}7fsDJ2k_i<+2dJLBRVN=#fVadAibBgt-DOcb%nX+)K@Q8lzATjcxv zUezJni^B}0hY@FMjitUNw%!4Fhs6*X5kX3%B{n)5od2sHnhyEAYFj=g9vzc~?wg^i z8&6>I1alveg%)AGh$veJ6mHsK5KvFeu=vgP0;4GgMA@sN=PI7jHplP>`%4m*8{?=9 zc#g4|1Qr9) zIbdRFgprFYj=^DsN5qmG?1-Pe1`nR8@z^E~kD@6SWVYx%SxEPoEL8ei$U-|qY}dMz z;jf5$sE_Pzx1o=0n>5(V%yg7c=;V!)ViXZUtw#%D5?QFgLD;!FDEcL0SF`^CS!jas z<}~6gjd>y$h=FJQZ>TKPMoo@C4nF=o@U5rf1NM?^GqXC9R3lX^q{>32gEYw&&%`9` zt*|`SOlxi!DIpHHm>HmRM+GfabL>J=$m-r;T$<T*DM?%Q!+3x)@qM zIx6ss*qUUzHAO}ivbA@@sq7?KNa%fkKIH)c=wApU@l~&k81nI`?Q^&?Oke7AV*Qjb zdNoG%#FxfajL*TwF1!1^ZB|$ z|6igg>}%_su)mA7!kv1fsuzxVU;{nNhcX+rBm75u;2Eqf6j8A8!6!7DsF*n7WWHQ{ zr2h&V8yAyJZb>^`bK4xB*6|q#!T612A!CUwG;x|NbhtUg`cMH)MWP)+&X^c!psl8e zma!$;(dDFeEXWplzcfRiA`8_YlZAStwMceW!Xv3b#3jO}ia(!tybOztsZ@GBz|`Ia z*H^+nNh|H!4RludqoSkFBRxk9U4^}ljwVUO8ZudrjU`sZ8u9V5q!u?&J@B?_NIL^n z;pp8rLB${mzx*+l-tUqN957S&id^kG*rgz29PlaXq-5g5(fJtC zhQ?}D5IG*IXlrTVUQ$C&-vI3yv7|@l5uZ}YNM{;(=^i+Fxe{2{!mF|(@&nwluu8-? zsq=WVXG{3%Ix1e;$&S1-H_4!CVf_>-bZ?=Li4t$ZB(jj0pMcgqhQ;%@7fZ>Cu#ml> zeNMxJz>r8Xl7uaK7Y7PoDn1FRndDTp(>^PWvA9my-qIjD43(X@tC36fkVLI{kDX-^ zV-~z5Tv-l_Cx)`hLcc~9+M8{WRTlEqxq)>+5}}22(lK1PaNh_m=JniiJ4 z@JJQ$Y7B{z@0aOYG||zq#KfkN`by|X?*ub@OH8$7k?Z1*;q^GOMVuj7^wYDm&+fcL z77D=em@FinAaRN;p&e~jJL!`=@x|oUhOkURl zU5jKQ(|Tn`ugNrFW0bXOUz|0qIVU3vi5N@z`&)dGER;{4i#-PC&G66cMwrVJenVJ^ zy(v244G1^7iB4E0>0KgbmdHW_V!WeY@{3ufh`DEE#{zzYm0HH$`1~DYAE!h-F)#DEU&ec~kOgKES!nt#SxA~g zi3Adf)ddei!y9NC+xW7b<-*TA|K<1m z+aGT8$6GIj?oTrG0TJc!V2|}0XNvuAqh+LqzOy&(VM#LCjX###SRunPQAs4k=TqD; zbXwSf{b!SBtW=!a-WO`4yKEcC)Uo0$JyWT7=?Ya__9y2-Qi z-*M^sJsxP9qGMomlp`C8Tv_DEPp|!jfB#10$VSolmd!DJbZ#*RpCAi0zl|%_gf4cK zP?7FOxSJ(bFSOCTry%a_j9Xd-HS-6oEcUWlVMDUN3U1+nBoD&q$)rpM=~;_RG!>F% z?t!yv9FgIz%pD3{J^I1Nm=p10r7H`d9VuN+ePE^fzVQ3M|E7JkT+;PgackENXK$lgi5BqmPS!HgV|>xO9(I(c;Q zI?LU!K0_9oWvw}cP%jhy^4JPvw;*B@rFi*Rcf@^V_B1Yzl&n08yQCvur3+Wt?~Wr) z*p#1t@xl9bH$&pj@8?AP>zFJQrF#y;pj;xG)=yW2-dh(kZAOOucPN@WV(bw?RP=GI z{_fc#hKWfdIk}9Q-Z`e$7ucD~CDq&?^XuW{NhHpXR8summ@H&}Uc`H{%0lbEnJmOV+|EJ%dz5vI(2c03W@7uu)=IiG(nwE59Iv9C za5XSM?}-{(3NNtoOCi2qpZmY4 za__Mp9+@JylCd2mN7p91#_h2QHXcRfw#^<9{iR#VApL7bDryUx6vWHKPNWoF zMixp}L&ZuLwTOI@q{`CAqWTW21MJmU;O8OF16vpD%GPMx*cUnETe6Vt7sx_sWHw62 zMJ+LwWkaTi25vsSxMb4?A zC}J`jv}4++Jdse#$U?3s$Umr4)t^rPYdw73RPZb5rR0-Uk9OGE z7@#dSoKQ`F>|SKkGA#Mixg)ZW?^Cqx^iav1rtDOJvc}FzGnHYESg6`@Q#F%)UbIzqqTT+A(3Lh?a#Kk4apt9^7AhK=+|#hf z((M(QwWCPkzO3D1J~zZ~n~s0$YoU#@h8FiVwYU7#@X3K5tFT=j@eS_2mJjd5u0sUjL&__S}im&*JulS0u_=->BD_Q7+ z&!6QpP7sFN68}$TX8-5oW_-n0e8qo<_-{)V+Jfya8dF_xQ@P8vA1`tJ`dywX$n)%( z9M9#%=h;&f6drQ-%0_R=)3nD25vgg1xoQ;ob^T22ZLz$(#ESSVFE6vay3Xq6F49rtvPF&6LDpKr zN%vF0+0Px1nr)<$$v%sdWFd*6kVL$n9gj>Ba8GSzVQY=m zb?N+gsU708vcl@dCY$2Ill$wNQ_XaxIpcog9=3))q)fr|iSK_C4z^gI9bm8|m;@&! zZePB^*&kk@{vw`|v9+VkoShNIB(jjc6=!Zl6IU}Kd&}qTqL)M#nmr~9Y2Wf7q48J9 zLZ^-)J6T!C6&0znkjQLLt{)$4_A*!;h|k3f*qetE(+{&NlMLj$6RGnHcJaj|4oYeA zhge~0ppXoME7+L)J(Y!y$U=oqM`R(Bv>}?OPw$AlCkq*JEu?|Q$q(M%S?giF_9cdL zHr$qrC2L5?6O%0UrQ>_=5pq|wk#l%OPTMFGYuhX@EpafAMp2MG7k_fVJgA1+#XXMJ z{w18dJs)q{vB@1bi(=;87_2Mcc~it|A3k}2Abc_cS3eoPk9i6^jV zg82{M%oO_0L;}IK=E%vpU>PfHkhEB~wZLey0Z|UB7`R4|+BW|Qve3Rz<0x{Slrc7U z#V4zkCfTZdGfg=s$U+65t1Q$jt1NUlT1I!2Dqc=v4wEt|UW5<#8s1}-orPM;0`xFa zc+MF)XRK2jDIHy7acPx<=^FY<1F|FYus_1ExJQ78GdB#=NU9oO zd2d75+~s3GAlYAW06#&%zK_kVclP)6+nn2MERHZ*6~!x0H6Go#$=UBzP?QfRuXlk@ zoeXmdZ^=T&M`R)AicQAftuP9kFKlB~$V*JlIueo4EUVb|(WptprMY$*0v{9Npn^s2 z)Df}dvk)CPNfvSqK;`uWOYhE4c#C~@7dmKoc?&yp6>dj#P%$a#)!WKK78u-dBdSi= z)l-gH==)+9)jkghuv5jlV4TJej&Xd4=~71D*zm|A81=Avx|Wa0Lc5df4@Bbb@{;GK z=@j)XFwvS!OQH_XeO_b#X5!t^DLbPjbVppr&cg|#_VlF;BJ9mb|tw(OUze<(h_4ySkOyydMD`rEskJ!9IpPpILD?D92Z4mgg3_FRl=vfwN0C>o*)aE8D~&A zzkX^XXm_EH@nSDREuUjy??PbPp71&AY%f%h9qEL=l|L#T<+P2=uqFQf&X-Ct>DoH$ z+k5PYJ{|E8R@j`cJthk|az{Of$`2|F?JNr$_r`}Xlr0Otw9MAJXyee!?$y)OvBND9f3(IC z_VgC!Qe~lcWT8Q3t6t*h;La_>R0_JMnBClz+4uK0eucGTxtHRUQ#M}Y66XjQ7Ef;2im?{9ur1DjP{Uj3hE7T5RK7;R)G*8)RxJMP$|k=?V% z?5Cdl0ak`8D08|ht1M(5-$2vsTQY<+L7gzIq9brwZJ;uFky&c3AorP|!5VDNfme@J)5@l!Ysy*!JezOOu0WYxg^Ch%p^P^4RBh8&)xaJjcrjA(ZCJ`Rr&GMJJywoW|MPK55 zlyM9Sz`t?#h)nVs>~Bsom>Y<{nku*cri7MTCP`vW7Z#U=4h%EeolTOx2`cg)C|H!y zIWP5I#6$1MLblvg$t0)wsIt)8rtPiI3L6|ioQ@KvceMzs8UOWT$Yf-p*!N_i+!Y2+ zjj6!i8V6%Sh69zcGkk`BJn)^U?u)#?>zFL$^dVVjj@34i>-)NJSv`vA@;=5kHf8r)J#pXF z4G}Mk?vq&*>!0@UIi6Z9dKN)|##dae-nSSV>)x`Yo zsw`AOXQ~@FC9;rn0fiGHW zR?DpLPoMmu42RoGY}WXZZ2ypl_imu>nM7ojn4`rd)^_)p>aL{B^%nM4CKyMQQ9Zte ztiX>9(m&SNTBzE5<^hd;88W^@z)Hcy(bGLiX`Y=mBV8kF7&XFWq`MhBJy@VylmVMG0vVy z{~CW&Wuey%bF#`pb0U_^^isvf&6R}CsV|VK#TSPo43-2CZY;-@`}#a~NFly$mO+v1 z?=B1pf1gT%pE=rQ_PmH{rR2mJbPKKdRJf>P_fQ=t_asWiS0}UEQ^%0Ktt>Q>OMjLr z-cD*bWwwz0$#V)N(s>)z;V<#h@x{!fkoGZ2;EyW{`94A0Ru|=uDhsWOII)R}PzTJ_ z?6{?tMM3A^K^8K9gsr11e$9ML(A^cWRd*J7-ul?8tKd;GPK5{>wuka4u)TrV{R=$Q zjUu?HhhAZi7KQJfAFQM)$qA<`H?g${B1yD$?xacxJG(y7L{H&M?Dge2|H}=oU(*!w z_=X}ekgM%y;Wv(HHH#R?FA0*_xeg|2h+YRX$vLmvPwM9DF6Z3B)w^5u&|#7F$MXRw=^ z%xL2E|luO1*!yRJ1+C_Fy)z{Pi)hbwn+?hoDGj5Si7=85~0C+J;&L1_IPJ<|8Pqq4V?C9=@Z*P}(EJaKBF=RH|S%;_yR;u~k^ zKmP6rQlowgS;z%tt0LM(f_!rC`6IH>fVkWG6hm)32vq-(I}Wi}lq?(*HBR7gl9{$7 zLhoKcU%>+N>}h(JKEDX}9>-)M#~ZkqX=9u|Ld)#wWs`SgA$tR^1=rK?S+bBVcb>(O zJ+#QmT0NshFL`=K4+ZT65;_){Ki+LPJP=pU=1~{r!sRoLmF1z1Nr`hT>c3w%Lu|MXP7-W`S$OL%0jLY_!bR*fh^>XX-qveGuz_&d3w|Q z2)cd?d#6-V>W@l`%P#vB+5IH;+ZiekMfaK}&s2hO$>?GH?S|mtuINfNwHbaWUNuA8 zA&;bnh0|oA2PPje?NT0N*wK>OdvH=lx%IG=< zlh8cJz$ulMR%68cC;1Yrtb?gVJaOftOvpyBiN?%;Um**H%E&^~($4vOEi+lV*w`qc z>Jvdwn;3WL@1Mf<5bIqD1lnn%r0)Pft4fL@qN^d9gqE6NOQ2>N>@Q3 zo)(6vIK0OH&GgZt{9-Lb1@^+u-9o`P9^0yq$wK3A$wJS9F!1^{ve3G)EA7-Jx?`_v z$2Ha0WHwBnlBd0mQHF9H33~AmrJHK_mkoX5C<*C`1NPQuSZvOrIMfV#6E$u-77|n` zQF#`atBWVu(E-(m{$g$iWEP1uY!8iY@IsrVe-^GXC1kI?)D_aMVD zSt#E4GB)^ z^%2qQBt&~cy1?pCE=dZPu~sp}zIcgVscnaIZ1<-UrDcY} z9T%c%r$0D`Of+eAoSs-YyzHKE)xVNgy{kuK`TuB7IMCZgOv$JvBLHXTlhIOW`CE1)hRZ5lPHNc=h{Ux6wF_f*0IFWCn`C; z!zai>bG4J1+VDyk~Z*u^!`Dt>by?AT~S z0v^u|gda#Ds%V0R-Q!u2jnijjT_OvWOJpHql=Tzv$r)kc?;#5%=n!ZlRq+TWxpRrZ zlV;_&kcA#fWT70<-pNnM&=3owc_aph5So-rYH1_25wYYth7;|UNBijFu`K)wB-^&t zM5nL?PM&UP*}G%mLmgj{>z>*-8nY7kx}!(_{jT9L5M z>SQeyFKuyFw8vD~*NV@Pg{+RrLeep0!qM+9HB%Geg0rRz`bMv*omx3+`>_CjdVW46 z3uS+jEHv_8NfweS3t9Ok5Ym2p$82C(J7pi)|9ywU zgJqUlf`K;`F0I7U`bCiMONo~-l_X%oDFx^Pn-Uf1WTNeYF_QUoNvU2LI0-4R_S zC)v?LHH(|4Nag#>T}+gD;rieTFXW8ym&ig=o25;h=4ir9?9g~5V*akhiqW`~8f{ohR%DtEkst+gel35_(*?;P7q>E5#<7Yio% z`gN==BZ({@la1Z!W*)G$*h-1fT|87ZunaDvQpDUsS~%RPBFokkyNAX&1XYlf;7_ur zBJ!7&(YHt>y=_&r=dVua- zbzYdp@p@?MNbWu)3zZKaRhK)}9PxjIf7+cLV6H5XICni1b;a}IyU6Jrfr%7S0!`4f za>XQlkfw>Z2H+Ty&0K4sE!PhRV-bhCWD{IGbwZy%fz44ids2z_)<;j+P@Tj9I^I^a z`3P%b&KoKAx5Y@#lU9NSt!=!Dr!#lC`iYUeZclV*qmj)F`7hOIm{oc;Z-+J+xo7EQG{$n>tgHZ z$dkZMT8_?DJ(jo6$=sLlwz7~LMwiWn&nl!sDAYS&ceuyabQN_G4%nS}fT!@ug~Es) z9fSJznc^e+@duE|LT@VzeMlDCUty#F6J!*q6)|6g627K#DAcMNFDQsIeK(ubHFmT~1-wg3MPPJ$ab*u~Zz?EKpDgz$vYlzEfl&S6m+&Vd<1d zR?D<3et-8Q$u7?q(fZ0A%@=0qzsx7GNyIkNjYnV6m!gWYxh5*1*~ImTbn^qBvo>5u zt>bfCgl{l$iYHh22C3@Y&T{>)kcGq;877laFI6rVIZBf^*#U~ESmA4 zVj|~DsYrCdL|2O^j#&hjPRR66HWqvHFqOyaFdJciC9;r^JK2K65f<9=sc==p_LwYG zCd|R-$U=5eIi!k!MY4tcMIyej)I!xKpTNqKR~%!1iuJ)fQoMxSHuL61V5^t|(cgm^ z_B!K8dHDow8y%E0CjS3Q7790efR(KiUiAkE@jZpTjd4ar%oMI~j_y+rQu~(Z-W?Iw2Sfk$Ma4;v$F+~Mvblij5h#W>=mco}1- z0A98nFmrMNQG)PQhdc93_6nJeb;eoY62>l}_%%Mj2k} zA;cTR(zLeAvXG^X!4ew$?-3JbhI4WWZufnNx6ht!R8P2$BzCg{;54Va1dOv)V?*tAH_F0;qWqe?QvEjvIqQwzb8&W8; ze2&2-1zaQFP{}@v9kG--JxA}-O^iJAN$uJ{om6c}C9Hdy9_e9ZbC(s7aPBYl&?;3$ zGSK4sQ+Hf5`($TeA3^kEcaoLqHu~r0=v>)jf3kwkOjGR4t|Awdhe!QMvXF#5Hb$yw zigUvB?sGIvf(UM3XW?W+M8EcCrkH7KW@h$1A!B#0gTZ_+UdrDT$)zWeLn5J;UGQb> z?QXNWFwbz;I8);%ca=9-?@S~~{Q*Wl-@~hPiY6&p-4oBQ2qxUvk~h2nVSYQZn(UgD5&k}On2Q?xaAuQ{V+StwhyJ(=sv$U;Q{ zc%MIujToOOSsCgrR@;+!<9eG*aw^<#NTz)H^mRMK>C}WgK>7PWa`TBbra5!;FP|Fx z&tQ+O*;Z&#!6nI^rJpH#;?g2?Ufd=k1)$*x(k7i<@oNum|+@FP5tWIW5~AIn5`yD_c9Ke z+E}Fz((wg7I1T9r>x`C%6aV}N%xr`3EuB6!e^P(PihRg0zl4rUI4=taWykBCCJUv} z5O2<{7jBqD*HAqzJUugf+k zkFdm6UldVR8Yth<#>B6JH{yF?3B-4e)W7ma_nr|-#_0sV8Gla}YKg~Q(GW$o5UgXm znUFCJ#@OhMCeq<48X8)ti1Etlkq(kM#;%yuxj`n!$5<5dH`SAlx3K|=>fUHa44$ZB zw!>auhOFw)!|TtuYZ!rh$qe(d-M2M%*Xqglvd3E97B%AnO8Zw%*tHEd=9?(@w#8M+ zM)p=^$=He*XJPx6yJ$#o!dd49*X4b1dEHCtw{ z;2S&a%x@s?6Nh!>uI%WwBS>Q!vi*!78&gxXI%K1 z_t;~5@HMXuo?-m$HC(d>sQ5J(yEDgP*DKP5Y#P{Ep`N$K=t(2CJHdEW49RL&(N?xW zJFuDNCFx1;u{T~sYmzE@a>~3gillICm-!FZ5KlpxvmK-(H42^nzyrA(TrvtFwt0=o z6UuuqT|ircI$A&6;e~1dX~TQWN|)}>2%ShFLc?zO9 z$NE@C)le>m`+c*-2(L9$8EJ{3+-;uwWf9c8$m+X}iSG~R7)%Nw)4~wX_*iV5TyQbA zCaJtjR$1-cPuB-oZ}Js#D|Gw0HJ*iIw0~qDm+0x9qPuH>wKXxnU);nMgRomqeY6*r z=QMHfE+VsQ+m$BoQmnU6%WM^_QqJ4AEjk@o#|5x#Vt&g`|$XT z9#3Dy5m_(v^$Ty0zdMG^-i~{h?Xihzp=Lt-{!qk5t>GlQKj-Y7`^bB~B3{G`?}uM_ zl&MUL-S46E^S^LG&Iyya4myRAk?1Z<-34SBKSM{+1WmVWO2e@l)iGBW$z2FhF^H zBALPdB!&6oF+?; zoVlI~a($dIK6@Vd>o?Kz@+PQpf~I$ReoPkfvB62$UlXG=%IBmCDDSa1-^+NB2O$=6 znAq-_pnq*&Jkn;U;bLEQ(3w7Ju<$iVL;Fc<-4hz9M*) z5z`Kr*%>P$+ffh0>rc=Qt>)Fx#{;m@*TYO#JF^G7LZ3ujzOuwX+XypLClz^v-Kl&M zA78@!vK-FEOLRz`-kW8sEsQ`NOCDVCA+B-$n2>Y~8CfWiU}Yl=?zs_l>KL*-@|^6@ zJ8R6#^bTr1MHY(HC)D7&XnO@|U0YHFAjYGKniv~&Zd~D>aX2moW6Yg2Ylp-1WjNvU z_$ojAvnI->S(MIhv-TyoyCnkMCQrOgP5JR|5TVucGV<&Z*4gZSMWOXW?my5GJ|mf& z-*^n!=2$JQFE3$iZNT&JN~%Si_+B#)L@b|0t-}-H!*o%%Ng=Iw6^R@94x7zPmOEng z>^|B$t^~dr67kTH93E~}@XFExn+Gp z%5@j9`BPNPBJh4Q!Q`nv>XetL-7rGkI*XXfS!8?us~ya=q>-MU!mIK&nuU|y`T`N*aBYIMa%Uo3bWk(* zz`J;qhA9zk?`_L+Hz`Nn7P+$2t|qg;r$VM5|)anPP9c!TbJklHN-cI=y;=##&{=@b7*iwEfCLK zp_}h)fP{G#D||_IP)AkS5X+PX^2epD;7bttGuK9Sv@Nz;B8OJ+#iw9|o_FIfq+(}+ z`RP7JmP9-^-a(PD$M*6TJW)=kcz9E0sb$#iqA$k}hx2#1d)5+V`>GSikTp{k?uezT zHP@70k<~mS8?U!*JE|7EhAJA9?mn*eByy;m22ws-MWP zC+tiKm3~h#zWEe|C%z)jkZAbgxvRbOW!d3jtid%ocS4IrJbX$Ih0gEIF*G3A%SE=| z3D)Nzk%fw=k8;7_qA8B<1(XX@^3MKBgibNn{W+cl-u*Kgs_xh%O*3`;^*bz2urbiU z;KDNFZ|C4(jpeQk-gx|oqJskulZF|68<-tzv%kqNAKR*_6%4<@Gq@!xTEXgu#)Y6~*64f(C7}@5MJ-T}w3rY~rUM{C5!I2j? zMf_u(h*!DDy-)RNk?A5|QVeb(ZybPCYP-no_db5{=>+yx#~6&fkGtg)Zn_&~N@zd(6W7)Cc@Zn_ zHGM*M4!apC5HfY^JQ`0du?wlATgdC7ur=GuwdC5GQlDaqmF7L3n}y+?HN?m}+axAsd5o3G5s{Fcc_PL+wG!9&hNgI9 z>~)`G8gq;%N5>c%8KfmUoYxN8*sEC( z@~Va=kznmFSJ9VZitCfJ$jjSe?(>G4?q2$a2kGx@qpPxjnu07!D$8kD+&DV!V}G6H z<_t2OjgY?}hn!gqt~p(F^z<>(SxraPYfAHy$Z74Rbn$@Q$#S|gjj=Jg#N&Wm-0D6g z3&G*aAQM&51nJ&KRapxqk1XxWpHnqZRDK~c*nv-1nA>>Pgf4bR=pWo8E2D2z!aJN^~TeSP$ePBPHbN@qzZu_l^0 zDw`0~Fi*FXtnM$d+VPrPS8MdH=-?WjPgc(?1H;R#@9eU>J;6+MI1$>WytwL2Z2ioM zO??ScYZpY4980>hIY0gEgI!!Zqq1+r^}~fUMp<(EvLo{51+qok_sc5cHyb?+6#C=& z%NeW;{Rr`&M9kk>)5pVT9w|@FBTvW2b zEO(Cn_w~5Hh`r?@2GW%AaFFMsbs`b@LYBs6m{<|*72_&Z7HSDPhn9^F7X#|3{}frM z+6&!>7F>H6#jAlu7Ip`jZ%yH)oFTSGL4*{yQ`gzg@K_IhU8Uq$sbKKUMgH&)hL{9Y z)3A2a2{_O zy6Kx*W_d?CB4q!FEM$5fEvHbt3kDe85OQ+zwgaiu$C+|N-37zQDk>-UknMF%7SWfW zi1Dp^C_J~tE2En7mTm?Ih3pEs>MY5oE;oh3wjSCRgzkuYetv;slG)BIqSXzt*Yn0L zx`eX!E_x=0>1l7E@}&`W7q0WS|EC-(7TIKUtsRqv2AF9M#a8(m-~N1ue|zA7V{$nq z?V=4m-Hdf+^Cs2|ou|L>(9j<9j3&A_j>rZF8;gwQI1s4uEjPdWC+_IlW1mt=wa`1s zc68PiQ5d9+k(we8)m`x`7@|{J{g)PB4(q8+_e1-(0Sbm`1m?XZ3-vS65{un)edLvc zv5e|sY*~8RE(cqkG{%|Xsr8H}&uwswEGD;Qgznxx`l}0R&P}7Byq3<U00GKRO=u zzd{xg!+WsBQu}KPJq$6r{19EIWc(_|>F6F}w7Y@&EPs6D|D6Xv{1?vN)5g1cj4o+m zce9td_5_Lw}O*47E1l1D{GE(1#|EFSK#IMPLVLK<1Z&ouV*&@(3NXKyFn6;UKx z>*DxKkMOc_T2Aj|e2o1yHb={-2)T!`l@>SLi^!?%W@c`I*>&j+=pD8v>*$O%#8OF> zyU$(ldDBd3&j^EkJq&d>Q=R2QxYHe!Eqt*{>||0X=C6#b`0TRO7Eh7=zw+?J%TTG^t8b%Kup=gstNLC|leL`3I`WYH*qNB5%*wjX{ z3rCq-*kS*RHdEU86*fK61B;(Eu=6V9^$D`j!BQX7S;{y%sB_aYn2?-C>iQR$l#T~n zYox!ufXIXrUgx(`*E%HPiBV>H3aCr-=fxvuOdNA$M^Y?re_^RsMixpV($R+kW3tdxjnD=4n^-8A;uKs+!-hm-5!jq!r_qgAUsawP8)Fh#LTc?8W3$V$ z@!Vf%XT8V(XZvS7u=BvBa)ovovql=5DF#d9Nzl{9RKp49fJ};d#~GYmV{QFCp>Jn( zm63wiRCt*YYO0F7OBibLO@!C4GV?`d#K+j2Y^E*J481@8nKS=zk-HY@Bz4Zdd+U31 zu!=fAJv9E|f9A*k^d~MG`4QhSePTmEA`8_}?q!9;GaHNyQYf2SJF$5p=0PG06}l5_ z_5@RF2iZ|PeNw<9Rc+|WB-=|HgQxd+ZXJQ|t9qe}k_`9L-O@r!K?#)^!lzc&F}}Ai zeAqf$b2SwETcIm&C)?yHANYVQv^2s{z9+%*k1%)g?F#UfI~oMK#ih0OPN3;W+Ju1|hNRZby;5}96Dx`pv>3c}*ZPs^d9x1XM& zaYlw(L>m)`(Nw@y!-}xFRR$#6ceu*lsECX0Eit;LjcZI1ubamh99d*l#8?Nr*wj0jiWE+=_Qfdzx=_5)~B&QFZ|MLMQrRfxosUv zcwRI0eX~q0ZyohzxrgNnFFe$gcrIsxQFtL4wNmw*A^Hb~=qL^%Ge7|)UEzy@-%v8N zg$Tu^3pv~Xe z|2{7i^ij7-Ag!WACb4NMYXbK&1N+_#5N8LS%*rqjBK05OAJ=P~$Xb4uv z$@m3YE+R)3LOk)gIolVOWh{e~mnNud3cD3rL(!n{Z+pV_y>TSTQ660rD*`G-EV3e$ zVY!s{h7^)=vxq3~qO4<#(ZOyeThnm3euR+z1fC(%-u#}^e*uubA+K@VNM zy^OS%(_Zn4f;ZU|inz0Sa+0?65aP{jFi?F--R+eGynhGxp)3^pLy<` z^W1r6?%X+N&dixRXWsWTXy+X4emmN&Re(9?2Vmg|W{Ju#*K7>w}Vh?tZ+xS5?PV^tZ4-MJ~}`xCfy zY#+{L6ySlgf47H)Vi0fq8lzNLd3PqVtDNg2TVp9i*12fxoYf1%C;G#C=^?pJlQFPd z^F316O}Fph>XA5{-*XsuN-G@aK3g@ccei1G*nE8Rmk1aRb{rPEe-^wH^Q z(nsHp!;xijT{~hh0@uZ1zYKoVABsJzi?-F5p#1DSY+mkz0fB1~rIvYHuEnG+72G_E z#Aq3x=-)>7Vbd^j-BnyJw!tFTMCJ@>yQhw0bHY_5+<1gLnfb_jcm_yKu(6o%^8ifq-HvT% zWxQrjA*si$UB>YsKP(;<1i#P|IM*B&dVs5&Bd~JRY|NUu2m3DOXrbj&6eRCJOz3EM zkC=+dfzjB0{voc*xgq`5b=*A}gL4U|aQ?;(Id|R1mE+s7YL*{-=4{0B?TNTAW1sYb zN4R|@25}J+Fsg?qzUeW;9u~Tk-F!g|bAAdQA76}B{&H@TKG$&U0WRJzM45UNp}b%F z=p<5i&PK?z5m>P95cXyODjVBWcoTQ`7!WXIj9ftkuwfjQ1k6E*^am%C?E6W$k+9zWSYN}so!ES{2_;2UsFZ21sA(@H%Cs`Rd@TK#wD**R zjWX>Bgss_$jVaPMXuHKGbyRe6KGsb87H@y@JwBSU6M-iyQLlXwX03dPykpZ5GUh9| zz4skH9=REdk7R1sNQz8ntwj0NtvF%y!H_=FF=^3m?6@w+#)C)5di+4f@aGV>$p?!T zOu%sIXAa*e*20eR*~(-*j#~}??$a@U-hLd-c`_^{YBEuIbv2B02f$OVb*F7Ui+xGz z+AR$knU8VzLNrb+(_u{af5!KoBQR#`LtM09oJcDumg~;oQJAjlg`k}&GJa9tz7i1; zQELnfy+Ccsug`vfS04}b{G}JXdQ8B?8C$XQY!>p=V;PFh znBnT?D~+pv4dI85r*4Sl`z(0h8Jk4F#m>OTzQe3xN$ zTsm$SHMaU9sTK{T$w=Mri@*sz@b>3F;^+QjF@Nx1uq)}wcCFAN$! z9R5oquxiH|giq=Y-}ws=7*~Xc_P=y{zNW@Xm@;-DcBKynfBO@9e*ZIuP7lH2_7EyeUf0niWJf|y5@_AjGs<&TiQI|$3C4?<5* zPidbX@bL1%(7_Q{zr+uQxzq9W7YpFOHVIdoA44Y0<*?jbi#>+X=-#6TzWMG4S)T=n zx{!zamDaY)?nuQQl$~3Ipc#Yl?GHcT`|dq4$+!!9?>c^wnRjjtV*P)@)WsXH?4%hhcW~1zgB8X@6B+Z+{%#jjhwMYU+;|)aM6jx1R9u?1g?_I&>d1 z7sHnx#*uraPhB!J#WiCwuYXs3`;`ZN=o5iO>u=&=ei>}l z_i%CPLKsE|V%E%kIF&8Sv42o1v%VAsH%{YtxG&~<_J)`ADPFxjq<@fq>qcP2*ibCl zbPT5-=OC{^j$Qi_MbSk(jt@f6JUxal+lKiU%0Z>{-x#E z5Hbv(ckhMoe%7I1A1~j0F3kOhTeU8 z!_(74mMLY>XiQzS4bkTck!wF$)u1}#JYpA(mh;RP=+&nWbRM3v5A_&0WDw>oS&!}K zGV!Ri(Q$k6XR^Usn%s8~ehrVp?DH z?A-@H>OAEf;VI{xzR-Ek#o!g{F@TxKuWHnOLGmmzfwaT&Tf1>8;#-XHosHh}mS9uZ zAS{^TfdPFz;3emtU%nk8=g|3Bwm%tH3*xbp)1?C3Yj+Y? zD`7@MVG7cY8xY|=0)xNTp|A7}y?S|}m)8(EkEmmHFLqtdz+*WkJA5iv?KNbb3PnJl z@6dlpASSQBhg*fM{;JeYmSwF(?c+EkZ41QQzWp(%yB@vtvQK2cdwC9#{Str$TdyMi zK@Do#q#!#Y$7-YW>Cq7g_}&M>OA~PTt|Q!HXDvrl!2ujzF%{!}>5iV?e20FM!w_`% zE*=%6;$C7jBF7HH$ey0i|Ds2qzP;eleKdyjUxKhr7jfoxkv(kl%>87OUb*-HqJx5@ zo)%*M))bsqw|ld*z*KSrX=mh^_8TSV4?TX6Wp)3lI|dIMg83UZWBD>)ES>2g!9L#c=q2MA-B0K@%p2Z|_u$a=Ec>G&03ou9eR}~g&-Xzgj z`T#kn3|Qwk1oIYzV%7CVNV61j0GMT*aPK4%xA-A&WOoeGeJ}f_FTC_}j*#Pi(0D(D zZat5Lht9`IRiVBl38$k1F@0n(JjNZxxyKc4e#v95$VA48NUR?<7`_8$Ao|vehJ_4R zHmL{vmhZsoc3~l5tCOMdeLOf3g)RPLG4wm>kL0-P-ABgUy58vBZz?AEZngj7?_u+$ zMiXo`xhT7N5XTHd;Wy}Gc=XlbCtZK(KPO`Rgyo1>B;)iI0hrorF8uuV zonD6(Q#~!! z*sGUbjAX9F?2Lv2q#q5%R-cd1NB<+f{dqb@P1ujPyQQ-J1*lEi zi}hpYU`C%Mh;9xGNe5qe9LGb0FzwsfSbpF(lG@!_C=WNcO~bnBI!s=dh`qO}TdtW* zMJRoE0te)}VeVknW_9+lyyuV!7%*=gqShF(XQ?l|zMGH93y$No{V`UiWmYc(>@ zZp0qH9vCJ@xo`gg1Hxt;4GL zJGh=L$Cw;&j?+bjBP`_SkNLgj*w}a#7cy&PpUAt7IY>LP2Ad|y*zda!(0B9{EIf1* z$>nvZRd*vu+lK>&nV2E_w$D#`=?7KY?G0V`p%^}SIl}hcLUK-xR@Y5vy0H?88wR7- z+`!Y_-g=m|wkogiW?Old(x>=YIa1`4vlsdvf>D!UIX#fTe>Vu&Rw_?N9YE(2l zZ6D+!FLf`L`wWw@qnz)e&f=O3q+0brPmoT|dKVYB`C;*xei%J<6*gbYL~c_ZicT#? zOxP$)_YK7Ai@CU2CzaN42^V7)!q=xCzWB)#KXm`aJ~sNL{{#&4UyJaV6kJQMK()P5 z%R1JVAn#f{VuEL4mR`=KU;GG984v2T@!;r*VTjmr0_W26QP|Mfrr&ug^~fQaZ|>DN z99=XUGkU1`$~Cl%J9_-o3%zageN7#vCNAS`rhCALi(fjOSu zSTEPRsn1*ax*SyA+=*RbJuz;^aP;t7j%C5aVVM34Jf$D$@uOU8{4fjy`-EWmwj?AM zHlWsdtjRULP5Os|)WbNrYKHUy{owhHjEAJJ^pZB-%WEWtjx!*9_cdH7wBlJe9dxKi zUB)RHql96`k8=?ceiG-TD`^P}IoRq7QF;Fu5|)iZ(9loe+1C?2rN8SVV=#|C1JP^7 z5=`D9W1YO3mVIZfLFL_pIAQ39k-@>}zbg}2&0!(MhQ_Ka`<+6!Ef|g|{l2k}vpjoA zUn-?f-+>q(um;OxNGogg;l?tQ-H*q~ zCH*mfM8xCr}XLG7(65rYonH8(~1*yB!S=aRbiHL*T+0?V7y}ZV1b0b*;O>D0cbCT9 z-5pLKxCc$J;O;c;1c$~N>&D&Pb$0GIGta$qGWDzfbX6^X*VnSvL}<3QDaK+npDnpM@+5o@nU_{c z<#(!|RDC&FmeWuT!JCvgPujabyw;n+TkwDL&(k@k4ePC8uHRe4nDDOeDIAT6$_L^k z@%4_5Imkcde-met4`((%>vYcV)b8bY+5Ch+O1hN2H8zk!+=k>`G2p2RDH&m8I(tn@ zdfo~3Tx0b)l)VnVH5!-~OySMv_v#nCe`Lovzx6~mV@hD%&SSuymLh?A|+ zAO0@mlV9*+33t;~dZZKSU*wb2cxIQNmeRF$U?H+ zid`eJxTUY2F4M4ko#EBQ^Wz^vZ>TUfS7lQx6%$cERjkbO@p4D%K3kaW4sF*xoqwEH zPYmlS(XRSbDdbat-jjQb<0QvMiqiLJ^n`x4Me21wVb5_ptQDaqp?uOXUkYaCjkacw z{m^$(9%Rh2?ya!h_MizfwvYUA82Gtc;2U$shrS5(Gd#PFuWvQGZ`o9Jv_euEGY#f{ zksY^6=$~IgrTiz6x@ksw?-|W8y_->coDQ-e%wlZzoChhLLMuoyf^20vLstikTW&wN zZc(F`7?LdWwk!{`OB9{y36b#7zs*QYToD*maq7zN!A^eN9u@B?0luYwVJTja`8|57 z9aPA%WpJ18po^a!|u4~F5z8As|((K1z4yrR!-It9MAto^Y-n|1->4I05~w zJVDd||ItdoLz0{t8zi#RkJql(=Z4YDwkh2B$F%wkqo}B8E(^nXz(!71v(^g{zHUO> zsnEiBZ&$-CO6*Tfq3`(X4A&s+p;Ck5MHd6jZ)&ICKWThz*^_Xv8x^-%!i_k?IL#uZ zS{yfr8u!Zv)t@X6bM1$n_k0g}evI;3bh(8W)MCR}m(dW(Z+cPo_*N7|Z^+aVp4vxQ z={v_TtdAM_+28-wfLOBqI&~>eM);(rnX1qUinw-}4(6gj&~+jfh}9ZBAYjWlL!UMP zx3mvdrr~(;by!1}XF4}$`BEt@R5#`T?`TD~4u?eWdEUSanJHs|wgx2DAzv)gx38aW zP%2r*$WXc|tDI)uyw9D={xxFX4%KH!&K6*NPUAL!usz=hs^obwE&*BgD#B6lT}?uM zhrRGcdKlS;8`9Gi)cN{#tS!3MKOcr>Ry*?e8#E%++glQGl18B>89gH+&q0km&(2;< z8_xY4YPc+XLFc6w?~5~I6ur_Bta8bGHnk9=Rfv{kx~bP!rqYK6z9p8Y#+!Z8()Z;w zV^pZ|cWm)|__!~4iFs2!KaE!<7u7>6*Zj}#DWy<;pT$SX%AcDN78e!~C6b@O2!;{D zn9F4UAfN2cAtJC!bk}^~ zC&_H04zlYksKi+*wiY*J6~l0$$HqjXSl_+Yh%SJa9xJ4dq_4MZ#-Sv)bNrA2Txs@x zSy>jl3lD*Ug$48ZnTFxOcTgI?_glQt9^;GMSHuc~!s_YJDCc}D-(vd}NTV*uF81L3 zEXZ&Ks5k3|?`yfb|Jtf7ZnoFy9SGd3dT%0lpO=w+nJ#W*)_P0$Ty00H!QBVVNM5(M z5oscS_!O&;hD6$)lAjkh^)j+QmgzdF>znr7)Bo{e|vS*R}@bZ=Rrpb&tAiHzNJvE z*&}I>v}~gH{jEMsN*G!cuVHXA{V)sme>q*w*|B6VcyBh^u^PHDo96JYD5#8Vj_RN^ z|JBajrjd06w;7XWFxdOj@aS-4mUJcL_!sXnn$EYz-i2AOT~fOtE}nLt>X~RpBF%ax zaBj^Vp?J) z5?ODo<*>rz*Ra~@-VT|UJbt;&{)hY(0#o1MHam|i`_f}W{ARIYF}vdJ@+)~_cxd`o zITcek@wL?PA`Vo&@%~bu zQPgs15P&}iL%YjeCT)zxMkM^Sq#KZzQ5gehghP9AY&( zaMTer>gYs6!KA$7XK~0%Km(DwKL5x&Wjp!TeYX0} zR3FdEy`xek-57U*nN(PIo$}jgYDAAxbERA_dm|2;pr^z00&WHo)kIb0kYZMIM$u6CP35KAa!%1GS--^DS%g@3s;UzS4=?OjR?G;L_nL;J< zE5Af^pgW?Wk_wsL$K24cU%8aW2SFom912bT_3?ogk^u`VqZwUw5r2uSyJDC z{8dH`^g)0u&wst(aT6rKLov~C@rdP;)OUD?A%E1B|10Qqyr08YzRef7lO;F@0Yc6{tu(k4=-C(kCrEt)&$OMWW>)uedw**`Wa%RW2Jxa15@2$%ZuHA2YK zt2TjdxOWSJhoop-gCEXJ8JZBWhDO({%o4+6I~n$TJ&%-#qXnZ%cwH->NYySrOkFuL zue>tK&P`vUtGo(fY?gTZFhhGYgF&$~hDh9Uj$d@M%W49vD(dI_4C&G3sS}Ti;4N_|=-US;Iovv}=*(Q=Tn!$7RtC71 z+q)e+H>3EpJ&|0JqBEUJotHO^OTovp+6`GR@EH@S)&mMzb#JTJLg7BBG|c8JjzvW` zJ`=J2C>YTdy}e@Wm{mq#oxr@cuW7#zu0GUkke^K${w`K4f#@4`iKxm z7f2J8+1{*0=C_=A_;~Vj_4Te+HUV$v1u`!DI>(wuHObHDUW}5cWIkjnH!p0)51X*= zZ)GrUWlXI+_^tNdbfEFR=W*J5$T$geL3bm_&P0PMJ zZ%nO)MxiF(6kBh99!{;7oHl3Yk|Oh8a|EyPjcEmkON2@CRbt1j^$7}peTWuN*3Z{5(TUqDY;^>+-*FwT5AukP$QZNFNRs(HpTRin z8kf6^OVB(yZ|3IxF_ib|0~N0${be6nXVRobtcwe8wC!=CV}5CK#cns3!G(AuZc^{K zU?E+ft1Xwb9a$e1fwsi;?a-`Jl!8g=+gfZa`k2*gu+4fuwZK!X)i=%FUvnA?E60Sq z}MCR^i(a7&Y%h#%<6Zx^DyEt2uh7 z5zn<3Ml+^!P;bgOdwngX5HuWIg_{hgtO=RK8_GEVzX6HB0=JsFc4OBML7k z5T;C0ttXYd=y8!uP;1{q$(#Z3aIp|>%O=?V9e8oVy~pUP^L?W{gLs(NVT13w56adw zQ_qB9O_jv}m(6?|5|X>UXu^K`7;nw`^_z}WxsXcck1sVjcNO}zq+&V)1LK!T1Tfwu zSBOC$eR*nKvFaF7QJy*wQa(|=SP|dpi$9HBlZ0BB*r;X(dDBEw7|y&Q=JzCv8JcQT z;Vd>X(dUggYmQ#-&mxi>p{>_nZ4)W(!uF_Bu$=negO|gyZ{_1Rdwi6{S?wvNzMg zHUe#=feY4n#+SpIuQlu5+%e4yH`xf=_E06v^901R@?*M7LA>*-`_f5Lh%;R^xgF@u z=(&cP7?Z4fpY3$N%stY1+la&;v(!tqMRJ!moH+5SVx_mSj0e;GS+kwmAP3Cb4UJ7c z!P!w+!00M1mdg_f1G)BWh8;vdl@8`IhIg$v-4B5GvW9pnoj;+M#qJr+6||j+VAXOE zmc%TGIl0-woxh#G*^Mzc%$N_0azZUR`hwdJcKLnlI_rG;N?xBQX3d&=&E@{X9f=na z1Gq5Rx^w;EImlRb=>U-zGDi`C)o96#zCg+Ek zg@XZPwFL>tg)azf6LxOMwi&->VCe9A`Y4{unfqn3!r|xDs!u!#%CC4YP%P1=>SMh; z-nlXAhKaDrH)&tK=oF=-q{Qp49IK$sj1G^x2zB@t>=ImKzk~LqrY%Ofo2w@Wl8lRB zbtWmMFHusm6^_!et#9<$SB%#on(0#2wS3Y^FB(5VSe*pPS(KK1HkBepK0AwzkB^v7 zv%yYC$Z3JxCor$rIIZXf?^f%qZ)^;l^Im51ikZ@uv%*EtNh%5mHc#IsY!&Y~$EBpkBhVmXpj=N2n!g>)oRMoP|)%^yM;C=;pixuKUr5bHe4ZDnc28J)Dwr`LpnaQ~- zxw~)?4Oa$?1bjOCVtMCetB<6SmC`aJ?pKu)rZj(LkWND1581?C6qO`C6d&SVh?l)Ms?1w4JdMca8FaKsyCY(aluQ z24#>H$?r;jNnU1h(4L|A6%}bI=*l5TLm%Tea1*6c6O0uPkWx#nb85$7Uhj5^HaLHn zrZx_GY34E&Tkk2+?@iMV*)&7F=H}TF@T7>U>uDt5BOi-ImCDZW9GHbiz*zag$O*}V zehFOZRDEiGenF+$FJ+_5paY{#h(CHgS!V61j$pXMq#R^|lkNr!0}}lVw4!QaaSzuT zd5Y0bR=m`iI%wV?iz6FR=F8sFP>cQ6j393@FfDWJ?ZA+@yx(&t8B;QuPvE6ja}t|~ zQ1zMYe07pD$fIoZ8_T^5&eNuSnW<@8TfZXOpC*12e#PM^inI?Z8T+GY_eSf<#ro4A z?T5F6FH#%df;T2;)=QfDCFHZ9QBAsyn$eBDUN;|IDTnOfsO!$SmMXh=Y)%5}n@L%+ z(fl<{)&UIg3G&R6xH`tY1wZ|8>L2c6Zw71I7-=Lf-u2KHB?y-0pwF7cQ7bd|iEvsG z<_dlHopg&QaWyJV^W{7Z%4g zDaLG!=i4XPme9rX#b2mrDm{4mVIdFYJ+bm?X-e2!D9_|g1aCNBaxuU&^g)pmgmYY) zhJAb`k1Dr*yutJ`xlyo!sapkJbJc-Mp)=wsia8qa1S#^Ar_80M;2 z#@Vi%A?KGe#I$+|c0g;nRsDcg++Hz#duPtnEEUbpOEct!*WWVr*dKoZp^tUeYTl9+ z7;v;JqEB9{FUH+1|2(Rt9-n4Uq}r_JTjKjv2HH}ExDo+ly(L%m;ck5)yjsWvh?o%jFiwNI?EtVbpFcD5A z$Zztx^Q>As`x^Oqw`84I&%z#scW!P%T@4dL=^LPh!K*RoXva2mqjqVSssM+7-;Xz% zT7>dWI;Ismr@_!w_5`z}qHTGf`0jW)k7zV{%#`{9BS&);iZt9)Vz0CnCf+}|87D?| z=3>ytvPx$Wj?Rv|$D8&)s_{;^*)~Z*?A64a1YP%uyq_qyvICUo z%qMWQ{C;feWRymfDuDN!duhz3-@|inH-ic1gsL&*P9M*r`ON+Gn0=Mg&&{odFV^Ym zVh`eOi@&cmOJ0x^_uo(owPWW^Ed9(4;JYssyWO|i*;Bo%$<$v5(UpAGyyVPH*NGT= z{`fkrW-F{RmU1Y5G--gRUvP0KF*9hRurTiIYYQAi)Au9In<#?~(XKe2wfy{~*xrFS zv$}k@A+E{#W+QTU&D4)G&&j9AF;puW#^rkW@r!E?w2{k{NbZ7-i6M!#de=W(QIYoG zx8QUlj?zJKI`!0J>K2VJ-mq0#V#s$RS5ghz#=K8lQmtI7*!;a$FkS(T-nE`QTCDTy1p6)9PuX zNXP?giG{lQ6R!=%Kx>h*`}7%SDM;$EZ)i79CDS=uGRjFNoM^H8kUs2)zTq;?v-c$5!DC zExohZD#Sde!um4OzLu%ALF@H28~?~v6|?#GGb|Sy#W9L!F2fq>1>vUY(DWlMGkSIh zy(bN+r>tHY82`xr*2k&Q0sNLPy~(3|^54W@Zc#czxLj9jPU%#()uQRne5EL!J?bB} zh>NTn+nghZ)?v+`qY35=t)gTn^kkKji(`Cyvop!HjS{^I=|rob_IA;cStcZ^LX-Jg zdmHoCM|Z@od5{l{fby!$u)^NAOXr;9;5dzx^)@-76~PR?MwZY`T@R_E)YL{HUZi+v zz5#OU{Ohvw{}RM*HTsZ+qsEfWMm z<)e9-D!#eK3-xcln1Tiw*fD9o{*8%v|}%6;^$26O;u*FtRiX8;<`7& zjKADcx-xzDt$>UclRD)+ePoDh(?w)Gs1u83^j`XqSyUE(7S9E-$ik(Y!7sZVrs<0# zsZtjdf83CpeanO08G#1!9ErU1bc#$YKKoXhylX5?r$pf^+b1U*-Q>kPGvJ0jy9e@1 z>Q5<=hoDpkn7QgQKe-W)R-^4|Wobe1E|17bY(W>?Vx|)s1FZ0g18E}LgiG4Ho=4xA z_(^GBw^^~0vx@Vh3$-by$2fm$X72dCA#vX5y>;I^&_R(}?rx3xCY9}%Ufjzy-HWK8 z9@^3>7Xie{@|xXszIypyjN-P8Hl$oGn~O_pAy>JB>vG{K&z$S|qk2BTFgUs|8^$|mIP z_ZD}qVRG1i%08N=su;=CZhZz8u=StatGhT&-#aUr(QM7%)!VeJaF@{KD^NP~2F|yk zdBf|D=r4vMX|^70%B~t~(s=}rK--h`TL_C9`ePjS@#_11v1<$ZIXC!qqkWD#yvZeg zh5p>5lU_)kGuf=(v1Gjp@G!MEjkM+J{yj6$7E%T4nP`5Lk>1U*Hz|!KRF?~MqD{3} zU=lfHXw}Rqf-%z1z4ZYvPuO1BfcK^GfTqT&y{7sQ>qhP^^^~~QuJA=x^Z>CCzh1Cb z-EA6dDcX};VW!27#BVH1ZfO%#$DpNchom)9vu3H)`V`sYR+_iKe^pQ%Xr{oV@=#ye z{?OoB-d^Jemg0MVAfJw;b=6KnW^P5KRj;&FdkU*q;z0tVf}=09H*=Cs-+St|F0~Bf z&E~tAltU^N*RSy3;s(75sa7;oUxLak&3S2O@EL<2w8r&3snseJO6$cQl&=s4`1%9T3J6RI_GCd97P~#hj>ou7Em)TS`I>~o#SnU@u zsqM7J!=5FX`o+14w!fDDfeyw;7`<%~#wUB-^dS}34^MU(hhm!t`0hM=RU0*@>bf7T zz9$(kO$?+EU`F<<%rz8e+}xxVx_ZKHX{~%Wo`h^)Y+VZY25eqsHlf~e)$bEYJZ%)# zyJdPVo&xNFZhYF&@DdS4ETu?A&sJK5%_ z=P5*3z6{>#Chx)|IX%;M2=lKqK3Y@L_A=6)>Z_F^hSrsE!M&Q(_HHr1+!a3GJni;W&df#?IrS{Wt^Y~E#Ueel%oXbo7vJoG!vF5axtum3; zotu0CtYw5TQ*G~b z)kJISR_Myu3Py9;sFgnrfq9R64&4sTH97-h-NlQrR?|L=OF9pX1FI4)wV6}#77}Y*^bcHxdd&ZfHN_+Xc zFi+ChY*-E7k)=wlgKWv>)xD**Gs9U?t*e*d)+yMOKeB>W5_>RJ`$#BFwx;CB zSgj?r)m@IR_7TYu#)Fh|7h>>uTGODu_>EBSeYtD>h@(a;8G|oz=!lMF{ zUBcexhAPKAvidFP-gdZD4>L4Z-ts3$|Z23mD=W#D70glg`s)zTVGkGr98kXXc%y` zf@n~p+l><`c>cV%LFg*--X2ExPKl*+J5NN6@jW?C!wAjb=P|MA zPR~lru8mG}6OZ-%abl$BzPjs0vEb-+2Z{_~LE(6hHE!^3;XGLLdTwtAxL-=T%W}=^ zyH@1XgMU~k#R{-+Xv;We^+1oFYlJeL1hw4k$83Yl29AlASC=!HbUYNNmxj*puy z-=&%n6!{Kb=^Kn;<`LBabijDM;7DN3R;)DH?>6tr*`?}$aD|S)GUJSBrZD%lK{+^x z&rIY5jGv~pjL<$1$l|o>;i2#|azG{DU}E+Jf61D*SB}-Fvu;Rouv{D=q(fBnT)9Yl zWWHm^l;ZUPCyp~-FXg7!^TCZNJi?++un_NN34_^vM|>>T*V>2u9?LU=@)?hq)8QQJ zx}XTxnwI4~vTw)lQAlBABI1A8cmrr6e?kRKdgt9sZtf-1eqH-esCs&+o--=SI8hb_ zQsR|yB|UIyHaN1QVceL-?VL7}gdLfxFRCw8OuK5?gLRepekm6l2S$fxnFMFlM))&zX zX3@Mb3Y1-9SyKWJ9~Y`=g_KAFUvtAwSWRfNzS$E(Lemzqt2!=6lF(;JUf?WYEmrDG zx|kDA2Yq7nlqvNpgo*6i9eQ8NI2DW4Fes9MozPg^%lQ15oFPmwW^&F<^{mPV;N3)# zi2|P;>0;EUtUb)uJ;Lv#iEuKJOJNOS1iV;ZyN{Gy6~8BplO5@_dtdsX&RKV=$Hz=G zJBGrs0~q0Ms~x`dryeNwKs9$a7a#YQWQd09@;@dV+-ygRwE&hQ&z`~V7CslZu=)nh zda*IL)8FKdcXhPb^U1q0r1@&{n5BLGu?NAP<2awab3!p}_5Lk3u3A<$qjnnf(pioISteWwq!~e1 z6N%&@?9IrbNn}`5A!;fmC@nLjxH$Iv2p$?5S~$6jZAii0S|yL*(3X)l%k=B4DUrFC ztgqR3rVglf_f8#tihK$IUba5}X$T5kB$l(N=IAow>NK&^B|OmAI`&4<7<>O@9Wb&< zdpfEwAvgv#iKi5r^5@h0;8kOJu$UiB!sWFqtJ(}6;HU7s#}( zP?k{D)ikhF?fbHKQb9mnLreuz1@yZjxM^B(%jJOYP9q&U#S8IF?>gzQF1YZU(gTD@ zxf{tR0g^PQRqsr-6D)uSIqS`wo*aKFJ7hB%cAq3dv`Q5bt-uNn0to!C(Vyr7yiMB- zi7glA*7u~TMcHV9Yz)0Q@u+wTo^~gIZ7g(`D@W&`h)2m_n=udy!uXF>axqAo5v zpGm$*pos+y)m2V}gh@OuezE%`0ku`CxM&6bU;ZZr>UU5=ux-QlZ_&jps7bI}EI5Ab z*jA6BW(Teq8184=Qew4IqM~5^&xoVn`Hx%-+mUB|xNa4jG3CwaGozxv%;?c;efl}f zq5zkr^g_VL{lkv`8X6N)hYTjDDxE)LLXIr)+IAv0_N2^DR+eqcj@Vp!7P_zV>KaAQ z-?B3N7j=JrmghRpYZew5Mj}u7uvl0(FUy%TWyX98cfS<{Hx0!)rlvCOXM#tlvy=ZZ zr1-A|fuBwv(kU6BV)obKL6(aL_2s(Dn{u>dXbV%s_!EN@LY|CI7_iNE<7A-jT-p0?IJ}nR=+;}+|HM2xnCpcew%-Xq}gxUaqU#wgy%ZNL&m=YCj zfBP3@1in`}&JZyb|1)fEj4p(g?N~N3vc(}tT;00CBtplLiz3wD7vD(D-D((gsc=gV z1myo6<+&a6$Ly=y}If8i+Pve$?Bu- z+NWIou)m0%jsXfTIPow3EQ>dqcM!l{uG1vPXY4PasZp`Pm_^Zgk_6nJRWIT53bZt& zz`;ft)63TX`qz1K8}_1R)L2%}sw7}RW*`WFyY7H8g4#$H;MG`1TMHb^y)o9FveYPA z4S`KHLmm#wxrfK}^6~!0UOZf1;u>Fr+F1Srib=@0s{_@E{WqENQflw8#5_yAJvL z>=9S(3S&jTdo5j_JcI!E)}jA0$#;H|N7g$vBk(VjGBM;f>_pM1lCJ^;!h?)Kd`$Y$ zG2KXk+3;_{^RB%jx-M9HT3}_G4OA3g)OIh~T6B~m-}~_~-I2RX#)a?t zwFBE9|KfT+b_<86LQ;ylFn3wCA>^n-5y^J(kY!-Q`(NqJD}U>`1jj4a2R?4vA2Zh$ z=g-W?8&aqN$|nNP6o+e*V(DAQD=YWk0dODNS&gHW0RjfH; zLO^14>su2{O_f)*bOkf262PQTLGY16dRD%*!kPqZ{ruHmdqz~ zHTKnzz6KBhLlq=QJAmNF6%wrKI7z_VaKRDjcqcR4K3<%uN2P#*Qgqt+%feQO5V?7~ zYXKaiNdFfTuLZ7e$k}rglD|4)I=@S1run_nO#vV8an_V^kTho*8U@?$L}gkx|D*SE z6d9QD@gF(ZBYVF4xWv8tfpIPnX#aQUNEM)?xIUPUKOK~_!}h;~o%F$j-h=!yPj?FO zTW0)yWi0;OIFDV;=azT8LVZ$z9sbf-_XHTRxYyoUwdB$ZpEtMti_cdH3Bu-XrBDqb zhc2jSg;0U~o=&HJd-!m;(EM>o?})#DU(RQjy?2iJ@T3W6Jb&9N36lI|bhzNRNBotd zb!AXnDCj;D)~8GQ7iZ%O+HTDldYqOsFLnR+!RXJ9A5%`}Qo0#q>aEmN9ePH(w;!T4 zelJ-zhdvrGzr|Jl6RVEpmpHWy`aL!k^qQX%xlodO|E;|8WVp)n7rmcx{_&_kg1J3^ zlO$AE$Up7F$0v@0E>??%z7u5**o4#{>>>tNJ4f;uuZ*Qz@R46RSX7FB$YK9&RpVg! zZ^3y|bqR+zLp&4^$OQ?i>U9BK+rY*lzLiA)9$>b^BmVvnzg-@YvcK7QPFAZc-X5?2 z4%l=_7|n@WMIywvMj|z8$mn0f8z6dz>$GPHEjbp!i;;9W6P#iq?>u#ipyF}q$09!U zpybKrPII0NQFw43?Rk=e`<@fs!+`zj%kz9K*BeT|Gp^yHPyUO$;-&_i!X6r2?Rq8f z%lJVT7DGk3&!-z5X9vlkMlE5HO9?~WShkT?z!B9E?P%OucL^8!us(Y1Jyc1jrVssF z%?5~YaFt^k0E=RzyFZ@W?*7l5Up&Pffc&EWd-WqDhVc?pM%+_!o%RmzU>tLjxOB`-T|7Pu$yj;Pf?qK0 zT)hU&d8hCt_N(hT9LiC|j&RQ9GJa*3WHHyn9}cki4r-SAsoC^z9>_%iz+zLYCxg#doN zlRJL&4{ux+vjbSk(BMJtcjdP42$ccfR2ZL0v)~d;?w-C<;J-_7U#}JKLG_UF#~%ZM zqmik3o9fmw!qfQQK4U!!9mV$~=&9XhlBl9^EU<}6ZtwG}p;Q$hf(MR@lXW2l)~_Bc zmcU+xXeFlF0XIw1av~Z zcq|b4b1k}PWy_De)KN!=hf*fO55G82w{_> zZN1FmO=s=L;0U{02N68oZ!?+R3^$`%XPyTSO8*I5EhBo|yW@6tpOSrw8Be4BjT7G{ zzef3Z=(Xk|LH_;)5U#E9!VMhDP7~b&KJnytu^gRiF3_<+^92Bda~o1vs`n!FYVc%w zKq}*HRe$UUtcwg4W>kRzfU5OBsL-~`9`)amtU(q4b(0dMN?9T0t?J8wH-ZDC=+ytI z=n0Y$MZ|<0^a7ycSH#u?O`r%w`7ob7EwOrdp_}KFQTICJ$VLC*Y4YbR{fR^fU>xoy z%0scHrmY*6_hhi_rPu%Fn`6-*9b+f`kB*6ErH<~nWxdA5HU9&>efa1Q0pxFpf$4go z^=RTyn718yr8lJ;swWQE^wq6Bv~J3wg8ftef#<*D{Hl{-mXPPo6TN%uN*eN~7Pvi0 z+Yg^|UD|-44NEbEVL>u)Y%+``py8C5&>ogqaZ5roW@~9>ET1}*HOCeXzVc$a)3(Lo zD;in!9*- zjC)eI&Tb!+K|zsHim~>tKO*d_+l~w)^9p{lw&mnw{L3;XAW6?XytOBX86qdu?UjeG z;|Ic63=Hky`@v>R%O1t;e0Cj&8G@XJb3}jlnB(V9^OM(cKxVaMEmxnO4EO~x+hniE zOkZEu;5C?)i`HMbKL>Ds)9}6nuwlilkSio0E`=Ky-cSu{EWWwK90$_UH6STD1|Y@d zu^gb@Gyx*E5jkaq<&8U$&4Al6;P(CP0JQi?KE3^L@i+v)kLaFOvTY zTxTM|Rcb-qdWg4OjPy~pozKJ<&4)9=AaxB<#gnKyFq;1iIzO}mV2`<(}eK=X$y00F$PkS0b4SQ6z{HO$xFp4S5f8+RXhFR)OUZM?+JE)of>4&q~ zq;ID9usgfDCLC{khOxXslJqEGkzy5tf4TWL-NFjj<_{D8PBR3`YTE$vC^_Ev_U<46 z;2p!kka-RU7#($F3cYCNFW3nmcef)`p!+F36@p(OjG-%#{3pSI{~-YcOIDB|v3Y~K zgBlMO*#-X_9=#0?%IK6cpWQ?i1neQvaW{Fvu_qZ#kcP*a?wr zkqke|o;uSSFWSJ+&{A8qq5K02_F)!@d|Ps@oW!vIPccEG zt8xrQ*$~OSwMq+tY;mUsQC~jP{{Nl~(6bsKsfoDP<4@D0FUQ8MhW%7jH}Ajjk@W0gwjSk@a=JKCRCKckLhCtTj!PEmJzx5~*e<)-mL!aA3MdfVCm2?U9J3jtgUXhdEMw?PgrKARv=g}P^kWt~emuBHcV z{>E7iiTaU3r1336v!&*LD<5(6hM;Zk-Bp4c|HeI3Z}78MK=zjN|4cVy-}(S@oFr17 zcQJg@<4@27`mdlYe6;D9ZxFukuT-R&$bESKKf<{^0IIR+EmQ11P3$b^K$?9Gq+-pn z02d=%xZ)wO7%XoCN%MwfF7o{)i;!{pm11{XBhdB*)P%iTSwAQ|n=K)EDjR}4yKfIN zVAsfLs(t+-sHd>BzRScv@PAUM?HOR_?&nD%E9~6qCw1%`=i`;S2LW#ZTihB4N+5uu zX`!GJtUdiFH|^fe18|#R)=UW6^e!s)Vrci`2klDZNzeBh&n}bZK;cd29=|Kzh^Y3n z5z8`m=V;x9{LgBkemKEFe&+tCr%xJ(e8%6{S#n=s{XxojNd|_F7XScwv3S@z=$9tR zTSy|zOwuh}0aQd@S&Mw6V=rHm7Jd=ktJigdghN~EIe6goli{Bt)Vv?uj{KYi=5~0L zCBwO`H~E#F%5Lv|6iidu#0udKFNC}k`~1VJgaQd?z`D|_l$5F&<9GHu&jw|~r~j4V zcm-f#Wb2HrD@5+CCrtn+R$>pX{}hutUZF(L1IcP{PRR^1a16tIwwy$eIk6-t=&*&$ zkb-`A!#$Q1?`x$a)%c7v9d$)(XgmfT_$t$&Z;5b!Jo4|od)TB{{!C{# z^CC(ipU#H!vnF!+AzJS$g7Y%?8WsIL+?c~2lb#`uJC?Mau(Nfm!~RyDnBKzaTab;b z1%pC&_mFv<%k7IpY-kSD=4nhO-%a*SxLsz~z;O?c%k^8PO)VvB*A^LhE{-|rlkI-> z_oaGKFKO)8)W4wZu9F}J|2IwBYQ~`L58Jzj9y7Z3vJSz)g80}`|EX+KgFrf9AE2Jo z9i&{yr(lrBpNZ=SA=bVe-47JcV{#!XV4WeX@QPX){; z$^H)Ac%MQHJ|F9q3aCW;Oumal#=)hN>&5vY$YDV3l2uT7d{hJY?xC^4Y<)$D`D@y7of!8Y3F%nB*5iBN;tZ5?^;;EmE zN)QH>9etlD@dF;lMq@)$h)95%_W)vQ9##|@8jF-A%a7a?L#tZYjDD)*qj}Jjk$6r| zK(8D8{gbV8n|fbOSKF|7ib*=OsVLLxL6$93985Kp9%Mpv zV3^gW5FGT)7Y-FzFMz!|*udRAF{p-IdJffb_qTv;HTLF$Z5-mkShyd}H0wsJs*vJ_ ztW-8MxuR0qb=eb;49j2snt8+oXb5Vl&co# z^Q)c-D=pD1+lz-&!^@_pY}6-t>SW4t8kMGx2!>Sd#x`|=Z`0~*&1!7wVOy7BX&2;o zng=Qu_IKvF7Pk4GkHv8oNBo;xrN$RGH1Mxb?+4?w6EcLH5E?Vau%4CF8`?NuoA1(A z%0pB6|Kqy7=J0X71{MzqZkFaRf_)*1zr9~_t(JtH90dF^MbMv``Ue&$`@90b&7)#E zCqr%**^L^m&Zu=qSgqFYPo-jAIR(kTBl+%Jh$_^6J(UytRGzXT?hp11<~jj4kGg-H z^QG-CI~NzuDCABpL)Wx)hRP)TTyq}c%-ZE%TA?V%R&&0k_vvEsUuD~$Z$^v|&NtlW zu^*0Ujc^u&f=lp}r=_I&x?;UDZQzINpI>SVo}XN|PB951Kp;}R=zBhuV)>7b+{W<{bR zdTiyyk==1+VGVKmAroB9pC7w_Mx|=!V1Dvt^D&q0hVHPlIIkcx%sV4#ILpFZ!qMt( z-(t_t2C;7$RX$3S9u{PD1~%YaO0>wOa-mI{GYEHddHmL;6OFq32;Is#d^EJtUyC^j z^vd39wqGkCYaKH`cacryM54b+@7l3Jp<{%dk1F0Bb9}Ey=wR{ZGn3B@*kSi&HJ+v` zh55bFK#{fGQgS9U)u=aeZ4lZVPB{3zBAB)jJY`BiCC&wrN31LjFOS zg4eHwc7hE$j>l7Qup>EX?3S(k7|Epmd<6f0M+GVnKtr1sd49{8)4Cy2{V8GSIs^pu z;0zVy7y)fCZbOS;6lWnjrG^wW;5W%Nd${9@vCp18olA*+QLQ>v=54{iGZ2=2o=?K~ z8(NT{*%z@d6tX|ijYLnnM)rTv^;ThVHCwoDa1uON;{g(aOVFUfA-KCV9^74mL-63i z-Q8UpXo9=D2Zx}+&LsbN&RJ`(fA%jO0fOs+*Kou|<+xfC(??h%=o>-7pG*l=rZ z9KF%R#-=i@`Z&zMDqh| zn{alO^&n!8<*VLQ0&L^9An;t~_}(l8-&f(iU2!Rv%DymKQ(;>Tz6IJO^W1^>{mrAt z&GDjMCK2;%N~lcFh7}Yu5iHnSlV11k(v((8a3Tx2^pq&F2wQx>J+game3Lml5o0It zxU)T&y}#}Rz36u{@s{>-6y?FL;T5V)I3X)uj+o>!+V5`V)m!96H!R7h0R{3&9PJp8QPXQe_AJdCo$zmr^BIhODanWzQ+(E(%AT?MV0HF8rij+4qG98#M3z?Fa$ zw+P1w=ui0==%>_Ue3yPNR(UMIg9?xgZrguzx$k`V{#x$M*(qYF7B60V`$|$3JHjq0 zKHT)r#&##FotvADmX}bw4evla4i?Dwl&?|;`37?WF1=7P*BqNpgxmz~#4p4Q$XbX`_{WQQaCtH z{poc&iB?-uHKyM6xe;7sk_l`|lK_1KOT^>&!R{Zq)&s^GoKgB}Tl7PRSMR8#)xUN! z6kvMn5Gw+TcfJK+=U$?;6t!Gn%a{`^1IS>_cQ3VgKgQXLRwkTV|0=obbDx`fJ?B8 z5gSKe-)8qTO5^z(jO2L5wgfS@CC%Ufl1XM~1!ST6Lx^u|qV3f^f;L|juJ{Z?XVUs_ zW}1r^&TsK^3iEO5*fC<<84xYJ{uusY;$yb%`v7X_S-XiZkr<{GRIs8{&j0q`H=XXd zKvsd)c&RPg1d2IZZp6} zUoPuA4>MMG&q7s@+*ZGgtG@c&F+Rv}{Oe~s)K_0sqH z&z%ILQV|GS-dorMb)NU4P9UgsiG@xJ_cLi~cQT*YkX9x7pbHgw%LroZX9KomutmS{ zpJd#*8rRo-e=H#hv0tdGa48>3=}mVG!mk#>DT@LlC29B+vEAfVB!)gMXDK1pt zog}zMdHLN&Y-t^CQm8nECz!fD%@Z|PQA%06z)(hYZl830eLQcDV+EU^H5;>blgb)b zC9}4fPz86Jnt`S#hv!$L4<%e(0wSC0icsHAcI2ZwBXw%vIqt+X zqZz&UB?Lsg)QS_kTiTUHM{orNYm{nU6!D_n7Qj{xxuEymSoT)k#Z$-3TGeT|Hnx#< z_q7Mh@lqL>T!_9kV#_S8evNh+Pc7T=?SSY#c!W*wp7#K>L8{C}DsEEbUv~y$O`;QN zEZ`l6fYP8pc}csKsM>AB(e*aW%YD#HXnGSqRp)}iBpBXs!k5iRd8X5xJ*&AD-Wa2t z{9v4Kslx12-QmfAxSH)dN0p31Jy#$msmjZSt#QFAC(cvDlv(CS;7mY&47 zrQ|v5g6fB{+|w&PKeIrqJga52%F(HK2l}Z1p2KNG?qZoeJmKsY(=09aRmtXkAg&)K zLg{Ei!D?he_!-Dvn8;pMBn?T<3{~gQe{oM~X!rIb_U)xpAqRW50{>v3Sf%{n)y>OlxMv<=W4chHZaElhKa5FN~k^HTW(@bSFnoOZZZC35f zx75#JZCk2*ZG}8Ad5Ot(S9T1Z=)0Q-bp%O{jhMkFGntJue)s#40<+Cqk~A@2rtQl* z*y*Nd>1&OqgoaY;@IzHhlz%4757zQgIz!p5X za(jKRft@6gcyw@IKDLhZ(k+y$VF{y4;Hs$w<@mFk>NE~&HZTv)cvsW zN0jFgLnfx%E{DOx=&E7p`kO0$)oVj{ELCMb?$DlSGG?d`X zImhC}=!t}2us$P_+U$+5ksNj8lp2%bt2HaD54|`}VzEQ*E9H&|7~1xm$7CkLLXr$3nNXrN~ zAi{fNKV?fF;So{;2(RAJ^h+cK^NnM#cid5{(w{?D^vO!wRDksg+g6<@0Zk2mw=vgy{Vja8?Vu zd{a*T0FrB?7quVM$G5@fFp$&>(O?d9{#el8XfY4I3o`n$ zuayg-!sFB;U7ayqM{std8*@#LWg|v;@s=5n8U zc4rAG0^w)>+&B>{zO?vB9@LIIhZMK0q4zt2!vY7WRs7R+ubd)&+jzn13sj>0Lc=@v zi^~9L!4rwV1*xC!;K+tiGhilfG$=NaEvZg3Ijlz-)p%ztf8G6O_#@AM9`ROjHVXOz~)l$_6wy?*E6cu`&GsM$M2w-!5d` zN>r@D$Tbmj((`e6pn5kimqDldYFAR#ChtrEPR`EwySL&DUB0L9LfU+gOgGdvPJa|7 zVL4#8lrM>|$G6RW^rFq~=6s{7THm<26;;_~!YK4*!23|I@>LCQ{pQp#MWO5D_RbJv7;gI^L;X z#dz&;Bnw%OjZmOWQf!IxGP+Jx8i9FL8Jwl>cv3~EwHOfiAsVj%5a+*|k1T5#rmo|y zqgeQm2xQP9lPm{S)Ne4`DNPMVhTg!7iW+X`hjBS+><>}gO}WE;fo88=FH=hsauL+* zFny}D9Hp4AxUxcd*_$2ci>yQZYPGDOk|7WF-6cm{`Z;8t{b8+TlOBD!q*j3YNW)@= z+Dbk!&_p6-zh(z@#5hGBu&q+gK%6{sI{lp%sfT#0zclm$9-g7+bscH3S0KHRS>=?& znsz|Ne9&;{FIRb!s@1x}p9!H-VV;%tYoFlhd3-moKOfzF|36Z~X&@yWcA5@;Xaf{! z+o-5cXCbu$KYpdq>@Eb*sH?ABJS)LQI_2|Hg3sWNFmH!}!_nMhddHSZ7>7vw|Nkzd z0ub;5qP%zXTVv5a1TGlx6lOr0{71xK@FyKtGb^k`xbL0~21?PM45hq1?lteO1aKZp*CQ9xXzsQ5Rh(3?PzX4yq z7+R*dJ5x=5U=$pEwaXS9h&L>jG5wM&h2~&}iv9vKpNEKr$qWl|R4Z&+oN0ZjPI;-r z0lVSwAcj`JW^<&Cjdy@X3>>c!$$BYB_oFnq5mekQG{S=t50G;Ixrbe7>Z=#Vx=NY4 zF6!7UZK9$9ssmp+v11$jLVd7X?BE?-4uF`nv8>AApN%f8pQSVcNpLx)fU{pECVg+= zPw7vG-#;RQ*byKFV&I>OaN$JR%$-Rgl$^>J-LI5ax*^zNmakGmLWvj+Au97!CG)yN zCZ<)L1}ZFQriG#pEw8tuMm7v;u>@BX)N7du3(60Jp7xd0D<8>=hcC^jnLn%sY4uizAb@J0eZfk`qUxrddFq$V9YmCgwQT(BHn5J5LEqkGQJyJH8TSA@l;$on z%CB@(lsw9#o6q!5S3Cg$hdoy*JdhkNbwPlX1zpHQ(K!_2;FP2#ZXNf76Rui zEELqRt!kLz{Sis)Ne)L;9x(keU#&l~5ZwQZJn5qq-PK#QH{vq-7LKLCk~5Gc>Vt9k zl2UP{-_7b{Ml#SqeVfuf0ZiXPxs4vk2jzaAQBAV{+B6b@lMdN@K={OD&AmxuYPR=$ zdHyrR>qrQfp5DgX&8L&fRG<0?xWno&5AM^80JfN27Nbz$$bD*AgYZ1s$(_}iKIvnnd)im_E!Qm9SQo+$QMQrJ@N@SN#IU-TCR}&7ciu~Ho{ZLHbW#_I4G(}2HjUu%(~27xr)?vA6-2^AQIh0i1+t{PqnbayNF_| zxPRSV&KR*~RnZgt97m}tZ6IY8x;9+iE_RNhC}Ar5B&kY1Btw-%XI+nmf?J11Lj;4aBebo4JvxGQ`qZIs9Qnh$o0bJ1|fc zI9bVE*60UJ-mGx(21MPv2}{rJ2?eE`!Gmq4%itn*6vF*|`6QjtKOB-J_HnrxohDHy zYqK7(LQ%VG)?Os%Hl*FY>v|BIiPr(gKgFyMFr(5Ap##!!YAV?xD>WTDNnhjO$U&?| zRG!@!hAmS{i+>mciulQX8KV_O%M=aDiOr0xB;j(Sw5d5^;(eFX8i>W*Ms z41PwmiCv8lqxqy}1;_pIg_M9h@`In3?^fXpFx$#LBa zxXnu$8IxD5FusDuI{p4Q$*BFD8$1h!DjPgp(#S96bi3?XQHQDFZa@KUxR zmzH{<-yg&&HMPuQHlzl|{EdlFqC)2BZf!4=Vdf)u#N-ThxatvivfM&XvWw!s6n3BnNZzfvweRJDOkRz&p-BN$I{) zr7BqZNOgCYj^BM7jws>-rwxxfQ3< zW%(sQ!{j7nz%iF}`!=}|CpJWj<_mRc!UI+Bl5*sE_giAZgTT}ANlb5E43`6-RJK%O z(;?E(As^^L+^XAGD^Q}s`jd_on)v~te3~6fZ=!+ir8lAC;Ba9b6D8U0tc&4vCqIab z4HJ^XxM43#Lpu~vSVk)>EJl31`q5w5W$^SOPxHy3k(oa{t3HFt8c4I{o9ojy1}KrYG5@+79lNy9qvv-=irOZDD(@>8*l``- z;iEpD3*R>_;tUBSL~h}ibI{S_Mn${0;ny?8T@rjm_M(#RDh%aM`tXH3qHc=`xW$}b3OAnTO|^u9nnXPu`y|A`Aa~7OhaB1Y2pxTT}tN9^*At|n0sZZ>1c=O@g zw@2)6Tnz-IiP_t4tv3EbMFLdWVxG*@gnHouD~x8q zB1eVegkJ_|b)D<41DM)RpAr|=PfHp#)%RqKIE$R;Esax~Uo=#Y7e|weFkLL5G`ILvsDyuCh}zF*th*j;o#)*&#Rrl54lQcEpE; z-6z`@M<9jOzRoscs+QA}?bS1as%3>Z5n1-h(Tv%1hgeAaGYlY15RZnyJIs?TMZD~) zejyN^(nb<|5|=slxDp1Sk=!W13TD6scHDjLa@|Q))z;YWJ5B#s(l0r_MzUtlT%eFD zl@edC6Q1MtVc-DHAqKZ;%ij+57D|SRA5NgD<2K?Kkg8BteEe+`SocAOzX3%HSSv^R z?XEDxeso;-Xe4nPzRO0Z+O&_sy}TM_11Qemq1djuDl04#;?^*!k8-te4m3j;7=};# zWUsRcBw#Sg6D%rRPR&Uh&(eyxE7D)-|3Nhmx85HxEQV8G5MJUP5iSJD!djmo@w^{j5V=^{j zDVVLaUXSFdWorh8)kYP1%_w`3abA@|xdASjPnDllH({F z8K&sV$B+d=geQNs7)5zKE{4FEyBzS6t{i zqoNA_xDjR8pPXD`?2PHcyI5IFdN9M&*oX$v^~4n!|6AmEu@CUSL2iE&UsLr}Znj*~ z`3pcjany}RPoLHXFb07Q9u*S{&FhuUpnKYKchjuDDi}m9Lx%m&=P}n@`18qmh2Jj^ zmTBa=M(yTrDapP4Hu5oK;4=TBYRo2#>O-e&Kh7)es$_5xgP@%l zM>XkAPVJ0e5Qijod;*R5`h)TkcSgwX-6`jc-lm?n+O3XcQRj47q-;A&S5#7P`%)9p ziPy+3C>HW7ry@E7=azMW)cpg!ZFi!8pCTl;ZTpRdF{9{Jj#i^R3)b#Pn((($2iZEb zoqF7*&)B7CxP|@gzIQbEwt`m*1{)fwd=7Z{OnOjAHMg>3Tsq5kPWc;xhVrx;pPfUE zB7w`Mt(L}IAptZJrphWVjNT%(7>Y}~zSigSLT1qx`3DEq zvrPGgNXZ3#@q^4Hi!pUq{9l|*8|QBGKGnmfqp5wjVUmsM3VyAGQd0dz$pmQZfv%pM z8i0xDE~9q$aJT`^4EMs=B%bJhy#R>=ayYcV;3_~_Fmt9=tq>3@Mp^xFbd+xCL_m=; zW!#VP(D5j%Y_R)s(o!qD7TBb$UN|1~tQ_>NeX%@aqVH~rHDcOcTp4Xv-DUDcBu4|6 zZfY_moh04NZ`xAK(ElZs(>1~FX8J;9R#6qqrAmBZf}%#zXWZhF1BF$6Hwx;lskbEt zfpz1rVl@)Rtts4A&3*I6DNY6*Wqho9gcDZPgMSp$D$mcByjB5ra#oE)mz=6mm3_>Nz@oow`wUf(LF#AjY&>|vB*zHi*oAI@EFC=)xCp)pgXI#c5IROnt9 zIB@THuMx&4qN10RFnOLlu;8v-@-Xe)OPot;b;(|+a;cy}o>BCD;%l?JxK@qQCBvnD zf;+RuRC}Gxtx`YF3}Vtju`$`ed76_p!xO{N#uxG;s(BL!;j#zT;&gK&BeDU%^mgL^ zOl`9T@E}4qjn{WTA_2&s>j5h602Qr>r^@q#fnLkl^T03wx>jMeQDcC0s#LIeWa_Cq zrk@J%5T$~r!s&9*;=oI#?TSxDJkt5kb^+KH0TrbQ(vmjTIRl(nQp+C1XX?j;Lz5yf;tK(XpHb>p3q?+U85~AuV0<20X!*}b z5)s*Y1z2hb9+L*?0%yVmre+ejca#HSq@921MP@)dePWM(f;P9;SQ5P=Kb~iLgRiON zLk@^=6{B>EITmd@<^t$GUS4HvU#kA=Lsv33hg<_unn$a@4!o%YT;ww;5@rWYM>eXg zZ1sMIzfw-9xAHl6oGzJLOW05FcRm9k9aY}=418?fPwxV9+&BN-85FaC@5aOM>doDg z&p($ctf`JcLCS6gqvk+QlL1J|Bx5eH%N9!kfbbQM!56`-+J8`#@s4kRun{L7!~VZ6 z*?|sbKt)j?0=hw<$Tk-3ff_bk0mK9YbWKLyyhgt?!Y@R0ES}%dFveG81NIt z;{a@59N=doISN^cpT=14^(=-jD*ja-zNcn)(3IQKGE!ONWY)h-2@9x;!$-xS%v?R^ z!2o*IoBd3-=PVOkeUUlm{9l9#(6(Qw-9CfJYvruc3HJ3ewh|S7!uyfJsOVMq-rK)f z#5EU^MnGlkkX68o@BNk|K7rM2GI2*GNu;^_Qg(S67dOZWAo0wn0N`10|hzgwYB5`lxAVl z@xS8|59s`Tk{TRio}ya#4g6YGlE#Q{#=ZeKUUV0ELP3I@*rCO;32dFoWy`<+bj3!G zpr`jPX<&fQ0fQnM8AJ)P@&*#6sS74RUVbJR?bNpr}(^*PV@pnd$}3{jMX;fAh*Wz zkHtv%S{-(_U|(NBcjs`#Fki0{$696fyu-D|ZG!k45<4+P?#-KZfTC zR9gn{j|Gm{*0k@#17REmzWB{Uv@RtKD zKQbIt{spY{&se}S(Y0%6cy^iF=iFl#xA|5qku z>qE0UXvJw(T-tV$pdL{1|B?#|eqJyMJ-LjC_=!jiG3sj3n=0?G&bND9??}>>by|Lf ziDw z#d?r!-iJJ{3{z%%;Oq1_oCxI}*Zv092B0euJ-J+o02K^IM$|_?K&Zb71nQ9xko6U| z(0y!}m9b$qpT1c5Ukn#muSI+^JIGqkd>l>)9=%IiFw?$&pXT72YIi)Ug9UKPC=5iK zy44toY$j8ttRDoLj3mFD1ZEdhmH(py$3%#u8a==SWztTzM*|TFA9*mDR$9!n3&A%cHmz{#%0dy%005RmJtjO=8Zt!Zf zgIr2=YtRG{odtS`RTE*Dl{9if}pn0=K`-q+Souv^1XDq@z zHve_E&6YimLPqi8-q)9sm$1<;X4sGTUnU<&)k3(N0Lb~`Xa7UlF|`ruINtBR|MdiT zo{?!wCxG#X>&?oH3(om8-kF%8dVf57*3Sjxup;zJC#OOS=A7|-T`vOSL2FC-!SY1o zLHD^W_P@!sdj$KuzLn+IBHca=r}LWeS0&-(A$#mIMu9Ssi$TqrQ`3WX7BQPi{?DdY z&mVj6-(KlZQ{MwbMk)S6&%zI@D;wURKy8)S`wG&!rdc}yFkHRmQpg?zKU#4qfHsWd zC)rO`|NT@ViTMu1mYEZ=i>{PLJ+M&P6$db*+S=(ad;2>G{ehx_`bR-ITruCHhCb&9 zhUi+TBvW^|A>VO|8bHe704FftR&LUU|2J&5n*YX^cU0xrGoihtAYn>eHoWkz(OO;N zfuQUq+B8D{Xrup@g_`YIx2SfzBUU0~YNC%`XKyZDW2z^9!^C-v7S&|C!fIZL7P3kv zoAG;D`d`6aUP$2R6RJa>sQ{sx=Lc5|=#FcfVzpZfaiO4hXNPES3wGXAbi>DPZ?6*eD4)G>sUP`tS|~Vn zc_zO1$kV9!=0H|t)RZ-+2%uypj^A9G)0QtYi8yQcbN|U0(e_=)W?PbN`?lo~b(UEP z;}p?xKF8D=lu8Mc#CI=_n$Cs~F8FO*&^-VY;KRaF(2~Kso6Dit<(RP1gATWR>}M9BG5zz4g+NMSo2`oP_VSp%^-} zFa~g4TM`M+APE23zdVQKgWup#B46QE{aXo~rHDgS26_Cry0*yV(|2vC2EOyB+IrFj zXXR6|&0AK^huybaCc-7~Zc$nFGwIplZ2qm|IQac-|Dh3yr*G^b_P zzC&++*<(CZTb6>cElUo4r-az=G+-7E`uI9$+NB6GJ^C_b=tzg1f z!=-LJq~~d1{ZBjt8Wn#*EzM;GYN3MRiM;1<=Chz9B9J_&oSDTY^SLK~gz{vp-;t7@ z?oXq5c?`-uFzr2VUf--k({J)lxTMu95f!R{L*&Jfb1|qkH&YX@&G)d&)PqK%4uTZU z!_EWn&A_c!OJ_=l8FE3;uuB_gT(u$4+-1k zu%|0d2xq44J~}e59TOq(n$j9-Qo7=ZbH<4hqcU9a7^)i*?w>NixX}nkrSg1u$^S5p zQ8PoeUQhf;5*1ihC^;X3P0oFP{*!a;w)E?|0fD`F1|h?~`Y}T8$&asC0_}k|caeRE zl=BiMf1&AgqcRaCUDy#&EA#9o-2K>SNN@1TW>srmyNsw8|2HMzb~D$8d8@{!dy&jL z%v#`JeY^tS-EDvUaxiM_;fBQf{?YVePXH$8en$VYeGoW9GkKzv1|~)J>_4ll9uc0q z&zgycME|oB%_LFBq{*(mHYa`V1b?yw50P&kzuu;tO-IzzqbL(NUMvIt<_;Ii=C` zKMM)nfLwj97$1uUF>b17xm@oC8{e{lz+tdew(GD%DK)R?I8*z2p~lp#ZTEVYob!a^ z>Pp|fO;F$@#N|}CV=a)OV2?ZeR1twtYA^EQ3U)NEAC94^?NDE|a}2{OUtP zN_HVWwT5w;t|fQzqYjOSbS7tO_VG+;<(<)CT6h)Ts_=Ce_L>Vl66e6*O*s3#I_+4T z1MwM32PC)S9>eqAff@E{xl8ym!M%f#C{T@)(!3w()b{RuqZcpe;r%e=KBN~JP2(%( z)2}2RH+V6Oy_d^!tLuwot(j!)$X!~DYvx4+>`y*dkKDZ9N-ge!o;;#XJXTJcPVUa_ zz$W_eWcZGVE|RuF5<+i+T7VBW=lAHf*RMCw$&VLElLc4*MEEt2WUaeYZrkXW|6sn57*F^{i3 zP7bV&_B3TeX8?thAx1#Hx&_-(Fr(Q0x}_c(7BSJJ_rgi5YuWD?x#(J~l z42mk0H58ds?=*grKRvPEE7ah!x#lN$W7ne`u6=+y%Eq9~et(U~HZ`4HpZ_YpHR7-*z7Mtv2DZ&>Ad0HQiE}li&~p4%JQ~F5^P7e<#oV;Mv+1! zJ>*=MBE_V2*+1X>eEg$`^B#%fPdUD4`SPc^2d=lgA>A@iC0pJjbf}c0w9`9a_Qa6< z3bRo2w0p;eN281yH%m5EEdPTrq@{roa=E}KvZX4RHzMqI&%R{O7gz(8TZ{9@XGG)` zu}t4NbDNIOq@V=Bw2N1I^`a(uKEb(rvA^JZ&)9oj2Dl?X&}ttWzfsZg8~D!l_K(MA z!nKulOKgy~OxlOl$=JB8x9q4fvpVbU#~@kaoZoz#BEK+1KeZBmBz!Q5Dijj+hJ9_V z(^D7b+lU(t<79<(&gzGG?1CO?I=U9lmv^U%x-ng6W|gN4jF9W}cvzsbmf=ce*Pc-| z5{k|-W#rxNAF<4s``94&soBZzYIR$0&8p>7ZINdAj_kUw8Cwlr$!Cs#e-AkoGY5*O zu9M!PF?v5X;w7TsHVIDmhW5Yg2pWu>wdH(^Tl8+5`e&Gs=T{zHS+cG(Y<$}hI!qT* zobkuFG`{StX88Ri59G5JM4Jr#fC9WM`Lm4LLM6UW=6f>Q&P@@WfCFn-n2_aH>?Q3( zyPiixYd-2vvZC0#VzK`Bg%*vHkG;Qgf^9=0El!BObq2hSL3M9%08c8ykx{Jc=NR}e7k*GlBN#zv&%5!+!|i@ak>Mj@saL#m_}5~M7i zs>}7dukB916Yz#@(&}`Grg`)hbw)Kf(G5t1(4=zP<3J-bAV9(MM!e)m$8VJBb6QGO z(V-C(c;6V;1SY`qDIJR0y(9 z*S&|t*_(YZ6HrTC6Ji;NF)XlMT7P{n#2QnYph<)5_XfEF4Y5i-tb%+{xko5?2+`nk z1q*Zid0$?LQ16MES*f_87lU~ToxFx>h#yD~K3)B5vT$>j-fAeb?lDY;YuyLZ>s&JO z=Z5@Fci$M03%U;9)){22u-dE*+KFEXW$9q>ps&uTc7&GTf0C>*oHv#aPV?LSOo{s7{NhitC3K+#L4t15rnF34xQwtk&I8$bA@YIFQs0mp zE0#*8dy4>t*=6!eYD;oJQY*JI!Ihp;__tK#03F`&*aH)T#|xUI55OJj4^90))oF7_ zq~9{NGlfEX>KsF8r||OLP)?!n-z6A4XgegyC5MD!_d$&Mw1?)M@pikzqSYl+)J!Rk z78p~PxzJ2F94f)9EUA1xq%C-(eG|7@mF}xD0Y=5qkEgV-Q5uHF^mIcjZlvBHHF18g z)4q@v|NBqzd2oNb1L_^V?AOrcSJI0PXbnoV3dJ`>PM-wdbZc`M)E(hS zYkA=;GQB6y!K0Qr7woSRO%gESS1s@OiqddQzBWVCNUR-_w`0h%V#eF2!3fRv;=RcT zO#QsiddV+sB}uz6YT0F-;>O&t1e70+#Z>$3GsZ#9in796i-Q6k?V8BDt2)@DN&VOg zqEllGzWf%;l>5nf#MX^-+**}%lqbsT(dsLP})W~;Z zPK!vL-u>ziz4AS&a++)Igk>F~0(Wzrqvelw`L$WwqW@Hce?5v^hOn-Zdqr-4as*w7 zSsw4mAV4f@4JZ1U$GzU}JMk80RXgG`zavvlz;K8QH}4MVZM^v>tGz}@p%?^mwXKggkNXhoec?epVt+hWeLW~ zl80jP2=hd&Jk~$%mXRiFNO0DSVL$-u@CX!MCEo-SNZBBoNa^N>q5XLz;k!})%SY;- ztzQ|lWy8X<{T~}o>I1h$D5YyF1E;g6)oUE_c4S!l0$3>Z**u75n#3ro!)**yvT2X- zj?*H!U=jW*RpC+mhL0o654~dz8m*s~ha=APE&{~ReSngA7#*a!?_BfVc z^J@~o+qxdat#Mz2n~M6wd7}(tJAB7oHkn<&vzD8veLYdDJpJbE7pGNV=b8R8ADt+Y z9P+-~am0XLofj$0l4`ul!(gnE1yrNYiH=~j=Bw!GmLwQh>ha!g;7|(nX&>Kn){EOx zt+X?rkEqJ)w*h@UaYzhb2Xr^C1g|Mr0#GEisW|f zyi{ZsQXG)imgMtR;Ob7BF@WAnf;hcj=a1xid*8k))ndk-q(X=EpDBT>AaJ{sucAkT z%RUP^cS@#eu;Nb2Q0a$<;+imOL>T`z)&I5hC8^4t%-I7<&K1QmT@-?_j$43}N8@dq zHjSSzfFXENb!SVJw6B3JD5G-4*F#Kz)B=7ObJ;RDMC*xaxtqSs_2 z3rj5TDKMbxJVBfXB}Cai#IZo_mYPfZn};>?l#C`x-`fl?y=`lh>t8z zDXCo9B62)fCc=aIuf;1)8mVT(PHnqxv<_p2?|;O}Sm4q>S=KJ3PW!A_GO2*G8B$}x zpl`}SYB*%Y;xJBI`liDxbebAOKwucYEM|EnC8nM(8}ODB3E_FQ#@(6y7C401nOndT zZtB|g{&U_9`smf!J@Xpel7LNw%8u8#QzpU=kI1Gh`7PE4=0CgjtABQDw;1)pf?-@o zcK%H4R}o8YC6LSrpYbQ#m?$z`A=4wLnf+=Hhb`SWweMIY+FA)p{#-awAy_v{$8=RztW`sg&koN|ESBPAf=Jy4ijUe8*{4(|Gk=A=i5z5W*zTW!8x?i zI;jDcPY+yXGuUGORE9~zwmeU?KY5z@j5+bYqjwCckI zJ)ENiB{TMQlia&Ur!=}R<0pFQtgRmfO0gJAY!&&^l2VKtqtB#xhPo_z2g*Y5z%wg! zjUR5`UutX5z&&{Xifnh*!mYxzh>*t%6}fJzB>auNa(mBn@uL;l zp?+?&_JCpOv?Ek2QFgsMQX`3_8aQ?!jqFl|+U;8-KB$H@e?v7A@#uOOQye;;MuGET z>-(CFWY>Ab4QmjKm8Ka!aV~8Xl-?B+st2CpG-NURj$OW^yr)|vuN6?8q;$Gu`6@Qu zi`ihwbIy`Gs;0fc?)M1VQmgCh0?uT}NFx*C!w1BC3ol#pT#(AW77pd^%FEOr3onJ6 zy(5@>KJ;F&BiZ&md?;UTkF+4YtMustN&lKb5O^?y`8}Q9s30HpnjrJ)ok+A{l^_VVca04ZP=D+-_j5u&u8GqB)BUU-Ff3|N5J-Y?sCDoAA9k-#^Bp7={C+d^Y zy%%qIGwg}5L)C&kbx<-rB)dkiQAa)S8>$UFNtLfo$1#Z8`=zC`dlwTgVGOxG%(*;Ww%Tk$~`XxV;Koh%|t&3M85oG`sj3j}8_yUJ1#{ z39*{-q|>=32E@9Aqja_GCne4Q&7wp>GT+;k;97RBvsexTl|vKN;!as4KagVmT(k7( z8sX0+GqkX`y)j)6W_#t6FmCa{t4u~3alt0Mh33~dP;Q9LBxd;0HDx=IQRv5lll60m zVw|dl!m51OLa{*{9{$)BT48BXa6LVYWI0OZbTtqE1NepeC|_t=!z+dRp5dC;g+g)= z)1yz}Anq3s{KFt%ikM_oo8`JQ^bJWqoUYHpoed88&mCh$1iPseT4u6M7c2a5BuV6m zfRut)*HgTm)jj#)*QfvZ`Y+&Y!4pI4Vc_(qEC|JHn&6(KMPKFSkYmhe?b=PWB2is^ z2png_SQ?n6_R9#Ga9t8G4UNF|>5f_rhlZD}*tY3K3URCDE5yQ0!@cLK07ZRS^^DN< z=LF9espr!9FbI41e@t!ogWe}_*~vtS2X1pfKF*KNDa}W}RQV{;)QVeR>L}V#;Q=n( zW=xH&5S-a;A^$tn@3ZkyNHo9p!g;JgE8Md@R)Gl@dXF+m!<@o}NJSOPV!v%ydhY%e z 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 Hn}_~C1ui{P literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/AzurePipelineSetup1.png b/libraries/functional-tests/slacktestbot/media/AzurePipelineSetup1.png new file mode 100644 index 0000000000000000000000000000000000000000..89cb0b30357dfbd62c2b88c44c6f6b6a464f489a GIT binary patch literal 510310 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%0A;*jc< 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%0AK;j0g3Ph#MA1tM%=@Xyrk?`X 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

x{OHLZa$#lF29^re|XQ`RhY& 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

&(zzYP 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%0A$7n?2jSEMKCw7?Rzi7DjAHIkQ6`fps9<_KAT3 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 O&)GPh#h}2C91i literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/AzurePipelineSetup2.png b/libraries/functional-tests/slacktestbot/media/AzurePipelineSetup2.png new file mode 100644 index 0000000000000000000000000000000000000000..a5ca27f38a81081152a31cc5e3d39e482001f567 GIT binary patch literal 216637 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

    h>v@-tI=l=#x6#sJ40R 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 b{{PR;WWshXliA`V0}yz+`njxgN@xNArY1n6 literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/AzurePipelineVariables.png b/libraries/functional-tests/slacktestbot/media/AzurePipelineVariables.png new file mode 100644 index 0000000000000000000000000000000000000000..15554ac3ab064137cdd45ade6fa2a3f9ae621ecb GIT binary patch literal 131133 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 z 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 literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/SlackAppCredentials.png b/libraries/functional-tests/slacktestbot/media/SlackAppCredentials.png new file mode 100644 index 0000000000000000000000000000000000000000..abd5b1e2f04823d1dc23bea13467425403ebc5d0 GIT binary patch literal 195440 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 J^#`-?{{ugNQ6T^T literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/SlackChannelID.png b/libraries/functional-tests/slacktestbot/media/SlackChannelID.png new file mode 100644 index 0000000000000000000000000000000000000000..f2abf665f46be85a9b52a7a99d82493b2bef6648 GIT binary patch literal 55225 zcmdSAbx@ma@GcyrP@EQfacGM>6ennlTXATRq6LZt4_-=vQrwCLDDLj=9*VZOyM`b^ zPx`*UbLRWw`}@osW-`N*C%5k2yVvf0ZMdeo0s$@+E&u=^P*QxO1puG}0RXg|r&y?O z@RdCOzCd%;Qji6dkJId-9x!cW)MNmFs%X5s4^L3fIL?ZOt^fdG&)*N)fK#C*05GSn z^hQR<%j7WQ$qyZc*{?OnX{8Gt?P-xdfo9JZm;TQm`WuEueQ=Ud{zE^ zRt}qdxKmTQ*wE2BP5E5y&?8i3$;S37m=5(@ekLOGR?w9)~!V?A+qtl8_Iz@|{}^Z8Gr}Z?Li@me#f+uiwYr*x0s%jXzEZK$rR5#5J3f z?R_Q7RTNE7eL`aRcdHUer!a!(GFmexABEZyeA^OU^UQ*bk{vgujf|3UE9$Kllz-ku z=1#bVY-N}hXpg8iR-e3`-ioBpPnaAU8Eib>usj}YnrxAwr(CgU9i;|p7$toys*=PM z3N;CRqKuJg?1zwQyUnQ9x;rHx__}>%#=B<2srQijI(x2BqphEosGZ`4)SO@59j~_< zN;ERVSp!;HszjKOY2`KM(;Oc1#N`X0k&hHNw`~%X4|?=MNb5O_^~0tOF^jb}RiYlce#US2aTyEu*xy+wk0+D_1?LS?fCFMcnLCPs?^={;o=2?s{5cE$tr7 z^_fpkE5SwTjW^Zq$;lnNF%2)H8OsQ?#;z@u)Q+7iq9Fl`+v!YSS;T8JOOF*EV*gi> z{HFeAk82RTN)#^A6BX~I3`PAy`X@?Lo0Rp&K}a$e-}}3Z>QDRpdxixCn-89Jbsk?h z#H(_+i~LiKaHUKQumtH@Bl>ybj_V+b^&Qr8*L0k~DQq=QsX0w)rTcgQ2*BI)kn+BH zc9;33cvReWA4aF_sX$CUcvhLqUON6ki`(71PCf6si~2YfG?}kTO*v)t{WZ_LES1P_ z%0+P4C_T_rZ`6QMg@q?EyDU_BtD%II;Yq2CEfXx@T)E_VnI#xHRK6He?Yt^9Y%!MpM$~lwHh*KJInH-t4?LPC32C@U7frdBiBH# z5IRo+K52|pf;gnb&PZ+0l%;Gm4ldA-`Z6(y3u_Oz(n>pt?FVTI_%(UkcrxUy+; zf%*jlwg)>G;P`zI9;Z5LX*wn2ud!dM;R$O;sOC#qC#ePummZfH%Gng@=D$5lGNR+= z`kZG-DHMQ^07e~rx37NW3e_3Y9P65T6OtE|D6TZyVHy9tA}OKDc%ay-;O!Z?T}U@L z;o`AcDX0K%pCNwDlX7hMub>3~M^Lgv)DL+#E_Oa;}|t)J?uzJF(ppIPo$-v5M3 zrC-WY>$GcyG6Y~)@!%$d^o=4&lsC#fSVC$N{5z&$YJ z{n5nPJ$#8$Nn4ld{xFr*F-f2Eez6WB0Yz z0FZ$y$|~h?m-1yOsGRA4Q{l&Ezi z6H_&g8bh}$t@A=eK!aGSMj_YC>kL$9O4}%WA8w{C{j&13GXpST-tPGa)}DO&R*tFHb#6EDi}XB*pADK6ihUa!8^BbahXe zcPeh44osyCw!z$Fc%nxCTF#m71ElLp~dIQ@?Qfjhprt&oR7&J27DK61yo-R1u29zrHFNC$yT?=gBC z_@4xRwFfY*78w58E+6e{7JYG*)6V7}qXol|Mi8Kl_C9cCw7v<>JJ>F!pl~9c*F(A4 zoBaO-?nDM5j=|6bFsZK6eK{^bXGql34}Qkb+AE`yHt8^3oO|WEx^|9E_31%hP|m%s z9KaEYCp7CTt?`(TwrguzCC6|)IFn)@hR1%b%4qD~FSumqkxUZ+E7HpmQPIfAG?ItY z=Z3WqYVN;lDd0@2SH*fwo7MFTK$@cwwvzp6{afK9CboQI^KU^hJUzZS&n}0*dx*{W zAFw!>IA_2#Bi6)cH4Ev|(%Q;kw{z*z`ia&@_>GtWU9oaH{odI?t^ez{#&~C^3x`G1 zjp)kZ=`q8(AT>gQkKC&d8#N6?&H@&L_(iGhskum7qY#>;QVgR*oZjQ%W`PA1CuCFrqC6mNz>`;)37e-By(aOw8o}+8%f@n9{Cd>W zRaz`V$O=$%E*-_(?)Xo4Ik}`x;o%hnPxs7e&p0fh+g5zBl`mGKD2RBB!oes=_ErJ` zcw(Te+-{=vwSlsnPpowI1sxWLMx(vR@mjEPjJGfaS|2ARwm~I1nrPep)vRmjbSmG+ zul+dBpmH=svo8tcJtlgddjY=AsW}6EFxBWKU|3 z)biRv-uwKllcC!WjeD4JIkaIk+2?0^f>&AYax^3+6drtFheEYP{~f}=BFgH7e@??= zdTnM@CMc!KHq@RBTE0e889X2E+zy_OoXIv{iz}jbCqMVa zzp|l3ywUNu0n%HbO{dKv5iOk*EzL2HwiP_1^yjj^lH+K(6)}S80D^QX%q0 z^~A!iPWpG4l;=67dOsMXxWr8|?Il!%4x^$nv^WhRjq!ioCF=Em_^VF_Rxun+@;@yG zMj{iTiF7Yv!s(3E9vNZ(e=UIN6WWfg67Q@(OPJW@NSHiVYmNqQHG$cnw_n;i{&s!L z`mYdxZWHHk45ckg@ACH*k;X?K;I|ODO`?kvDC@~p`Ql15ya`5PMSJ(9DD-bD$Nyq6 zAZS9H`xF`49K3mwAwCiiT9;73B9vHA@H)1DWrd{)o=LN;_3$e%{w{l&0a>>^$0_-D zNB;Q_gJlP028P(A-xaw?Ax}DI;iC!i@IwR^e6jQfzIb@cs!zA2^$>vFkR&C1^s=Ma zjQnpDsQmq34M1p0!$|CGDLDcP1r#3yvLYpf1putD@sGQJE$SGKIgE(_tcEzLS?aAGM_QMxxO8GvRF)3a;a{HPG|xk^GhQD-^%N|pY|+~jFYR|ycYtfY`|36f9FvS4H%uA$=XpO3VIN) zO_UJ-86+V-#fG1oWs@%)Q(zv%{O6E7&QS(?s697r(_~&7%(=(CN8y&R>1s6WIz>J7 z2x_;Xp$SQP#&Wzhe0y?A4!;$XbJx{xy!o53_}{_$S|A-Zbw`DOY9`eR=jtpjHmf?? zsx1T0_(x7d3eUu^X)MD(jP0$qe$m2B5GICx2yMy+pN!Z3PSxlW5dvu+{mb$6>AL$a z5WURJ)e%fbYx|xoXY`LdR{gsH56(BD;GFD1SaT@W}ZODP^@qpjnqcife`<%_*_`7XkW6yXtwy;O+yAEDdM4d3%g8_68 z-5)OAv6;ieW7!=ly4cL4RLi{{4&vXP+JYa48-q{R*+e~$;y^d+ss41$UPeL+C$S&I z^_7cef;YEP)GnHxw;0`B)E*!cJ(#dBUg9p9Lvi6KMD~S^g$=hdOKoi)Rt4C70+3;R zwVN*WpcXE<{m?FngO=C=R2fI^cy;V+bU3eknLb${aTiE&oY~mAwlJ%em~(FabbZ&T z&T8t9%-ef5*OM8Ck0Abe-u=B_%lWM-WxH#luZNKGxM>GxGVLMgVZ9mTf_vwyX1;i4 zyc1nSRya8P0MEf-I(d#a(LqMylNJ3pF?MIxkfg``(WZd!1BH|I^SQXopN z)OMD}JdPTU7bsy-lhPRh0>ZXP)ur++o~WiDWW& z`BLK7vgG8n(aE7ZpSKEwcmy`e!k)hCU)v__Zpf9E&C4&>?lTXAo(lZ-8(R_8_Z%4_W$TSbtmt%AHt zhR7sS{l>b|-lwZw&o7qDUvA#^$+PSDWx${3zh~i&-!nnsw&oQGG24(dQPMkg+C-0D4ZU1#}s{Ffi74Qld zco*^zKnN+0gy>(=NL6G|(z?D~UhvVm7#0C8|TkoEx|2eWv_E2g(0MZ)p>BJz?QH?&7=1_mM+db3wnl z;wfsQ24KcXYCbu}K3+-hsy%jG{v2frIIGOpiq8ivfa}HqyxCUEN`WtDnU4QBulpQl zQjq0Wdm6u3;Sz#NHcL5WEZennAgeJGdop}%fwr4+yT&*)ZCiCWL;Glv7!Kbl zCYdRYFW3R6RQgA!O9Z?D?*y}8IHl7AT6O(Nj<2Mnr<<;qM>jAmg;43l!Jx?s(y_c zoZ?KMpYdKy);De-(q>ou9(prwJ&%eeT`m>C@NQk?^#tY6q<6T4-p3!?7Y=HzjkA_F z+o*#GnP{eG!;#kL9FB)LQrkAT*ZV?**a_`qWmpUz?#5je^6nLCm}XjqR{lX8ehbMg zO1m667~3v9*!MZ+xuhqhXOdzURc2YESls+=J1!&&zKPpjYCQNty5FL_^hPyKbyGEc zvn}H@+Nz_sXh4BPHgDIP(J!>z6>~hVv24=O`+n55)X_@Iw&A30X%KqQaqxrucjq6{ za5uJ6Zl<;p^J})Cgg5QlZeMa81WYAeNW|Ixfz*!jUcqw+x&l2K{BwAIWn{6`mPxt5 z6uE$ekV^&ZEStsVCEV9iPa#R2DraaV8*t8B5oLZ)lhOe;7Dm{clOx22*3Zp9Uj=fZ zB^ymDb5;voQ%2-|*CR1DuAY4wZ^EBn@ueKUi%xcboTF)#nlo!IUGY|Z)L3lwo2z18*nC zw_avZXxg6m_dh>R`?euSw>3wo**X)JWofPg;l6%zzMi_@v8f&D?p<_NDiYYED|mj7 zF~4oB0JBxaMQfCDKFVq{kn~@jzil&rS$h;shGa)x{dgdhffNHh%M&ok{R~Xk69Qix9lw-ZnB^AcB5XhEpvV z=S1n`?Lyw<=>=TlxlVJQ!8uOq+0TJu;D@@4sqW^Z->qoQVS7}Rk?}O?52f6{6l{sPm5KdAFBL%`G z^_+j)m2B%|{0L`k+GLiY+akbL08D0UQ&^S;$CMf;7m6S5-Aq|E1{aX*ln>?uSuB6m z_dKUP5PO-??yTF~f>S%XT35aMvj=>(fAL!&=S*0oo;DV}O@G&pnfU{=2lK(Wt5nia zD6D_`yT&^kZZ_4mW{ljYH?-B}1&eN`jpxbbL(`GBq~@$URoy*XD5qX*kKLGcw8jZE zq$#}b57J`BX7fuvjDcC)@`}WE3~gT3wHa9Nx^gO5g_g!Yc~8{hGtdy>c5$P2BsHH= zJR?Cs(?N;}G??)}+)vEE@HMvbSYNaf^}UI!O{wbJf>v-Vpses!4T1HbMfq`KI`OHl zya_XoShry00eap$1sAvE( zRdVA8$J~su*V#TnO1*7IGj7o6>Iul}45$*=Evsry<_{MDrlNdeh_*pK!H9aPR z!5`Z#wsSDK0bD!;WwL&(=(RzJdp0IhFevA6w`Pr`S1Bu;6R%Z_6s z&&(nbkzG2$aRLfnC3TH>7N>={z9&rAyCR&;w1U1F+KbR%N>;Hv#>SqxIh)e5SlsMD z;j!tIPKR|*esSIxFey9LUy|KB^n{Q}PtdE!LeaNk<4DD*%(hnr=HcQj>P_nV`mty|CmJTYV1qMI4pv{c)$g<6TU)&YW>q=SftkfG z(I?~{KRC2_ZGE*r5v1H-jZ8EFFeFi&G^3MONw&>62F(KNFqk-HRiq@>_k9e@B;MXs zXz5R%utz1!`soit=B{1=5=2-se_a#!yf(p>?yVaVP+R$s{W=8^^l8yg_I%mx-gCSQ z7J92O+?+9xcYCMdCXVSuc2j)qxb(K6bNGnVpI;#5IVxbEy4Muh#nBU)z|l3=iVLLc zjk{>rZipXW*dCt!ZMIz^?9|&_wcd9E&=@7?u=K04H2AIbAf8tqTu_m8o0WyF^28B3 zeSAlX%`cIE&%&d4@s2t#ER8q0afmbmP|^WMA;URhm2Q85MujM7N9maW)8d;u%O%_W z@uov&H*`Cfv)0*9QAXI6{k$LIE2&hM8#PQE#NPXZK7Fy=tyJWC6*wj-+_aI}1r8?b z(RTxQ)DeVVY-&bADA%Itt zLqAFgm0|26{#5RI!7~=zD=|DE>V`upraBsi`=uaSat2Vi^m<;2DXY$U{!0_=IYvK& z+!c0jVWElhm)HrMo{x3Gjfi9K(G{@bEjjNC&6{MafkArE```}ohHob`v~E1k8_YcM zY$(e)%8E2BiiLyji>fcDSnvH;8eMuUn@hU8G%CBhh;TzP%$Qy?QSLJSKq4t4b34CD z@~@xIP-PtovsemS^Z4(G>6l#DF?NkkSEU3t28zmbbh^96htHX5HwRJ+?ELQGTD zyfac;SZ6+q0AEfDMxJ|DkH4<#G-E_dfFlgJPfj8|oXDGpt*}l3inSqrzRozYl|SyLg2?$Kp?yYy){X5C(BO^}@gG0Ssi2!TDS>tXk~z|# zQ3Hsi=Y;lv3~m~}xi0#$yojdic3|k`vw%0PY8ec>O~(Mlw*#j@!qb}c^#*}6EHylD`PrL)D2H5oq8-h<}X$>YVroLc?@I}Kuy;OnvQ-7CsnnO14U zh2dD<;e7T@YWM|eO$f*n{MiS`6v;dJ0T*X^5-7)08}O4)WPL?6Yt7r5i!#V@uEg4Jlx2*xy&0T$8B> z4yK{vuR|hpjAUAA(!B;P#wdQIZ`~e^`$Cn?GZ$_E4UXAAP1~;S1J49%xt^wjFATP^ zM)`0xqRIZrH~SJ19uEDH!F^s|6cU1oFf)A7j&0Bb3C4?9wVprlO%bXb+Tu`93OdE5QI_0JQV78?S@ z#1&>P$<^CWI;Wm24DA{pHitU|4l!vhs=*{P6~o_Or?l78@c?E0^}vl1@13ekZ?3a$ zP0vF>?S~JzaIrPgH_2$ZrTb_Vr*I2I8^;u87me`oscDv?-P10sm!Q4mOf{gs!vNLDbHC$nMlo8oKYrfs5hvF`2Hq*{WMPabomM-RYL{B)jLJDy z@6=H@HU^4}*Sx?TAtlG<k0&KXcJ_vlxJ=O})&Sf$`H0O4ZV$r z^2=u1)CMbiDE7lKW6-Q?1(FYHF2pg)i!lng6>He&ckRkpv2}X4R=@S4Z(VI=`<4Pj zZp$WxKJXery-~uNeldHN{X4Y*1nwcI9lg{h6$3gx@B-|qte|nfL2Kt(fRJNGqE}fO; zKEy7c2V6KS9Ze3w4`hkrd5F8e`e6Ptw?#Vsifxc4l4-@%vB)%EXD&yjW)yT+5?Q!Db+{?WW>7PH|$)u$Mo75l!SDvb_v}y>oRI^1(YCD$*zCA!;nm~!9 zx|#w)N2TEcqDO6;RBqP+xcuWraq)ILMlbz`w=bSj8sL7VTTD0{a_dYa0FB?CX0^_~ z&YtLLbNVSVA=6bU(0>o5Vw7T+kj`YCHO(v*15Lhm3wTb4s*3hu~2Hp(wzc~+_7>Z}vTJMY4zifuC zo@>exRj%uUfWmFDub@*})ttxdZ}kacln)dX+tfDP8@8iDD=0u~nJ24N* z69yXWgKQ41>l6WU06e{?%m#tMLsRc{en^v

    pH|PtJYLG`N~YCFY96BX!MMqWV4( z3wj3}Gv7e9@=K(Wm`X`17lhO_sxxSZZDYPs;_{)NMz0IhF?26?w+&wNO5fh59*?|45%05X}Z z{0j!3B2Aa}aJ5U_TDEUGkkdqNM0D*KjZcA`A7=0nDrT@3_3RA)WURuUl}OE5U1PrU z*=4lGCGM?L6h~jFF=)h@;i?S&95_%%-_S$ShdoHIa+#>g#jG^9cQX8~%*Se_N*gS+ z{W-nES!vw3@gu*VUms}tWGb(vky|yt1?P%_97WkN@F%2`C_E)0AvsUvNiqCyn2 zGH)9hY_`hQ=;ktKQ?-Q-!&$loHfXvir;oY%VEd06O|GEYD5%>yZGLe0s_FBpKx7c0UyKN>vUE# zkxoapMghla;vD$z3&`n?g-7aQg(EH20C0m@f{9p)(<~AvcH^<4 z0oeJ97z6VT&6m^oQDJNdO*jPuOE1P@nIw>I{>BrV3Bg#hphyjB@&?gmJ*muX2W&=^ zYXr$lZs<8R`DYP4f@81@s$kr)C4fe@6 zX>cAE)%0NXMqCXaUQ83=2`mUSIZ5%gMpi!cN&oS|*#-;~Lj0MwI(;2dxwIhlR zTqd}Y;7hCPz{zB6MR@eV%+_opSrZV?CE8xz5jMks)7rdD^v1a8gjx9Wx2j>Y zA%LCY;f1U!o+f5t>u<{>e9&b||3zg0{)r#Euv!6O zD^z+r=U#s#8kb0)V*ab~z>;84!_Rn@mJ-vOC_Lj^p+c*#y=`H0AL4e3itF`Kl)JSvZ3d`abAm*N z5&!8wXbFdjq^%mT`HX?-3_We=mb|S5TcwYH!^SdV261(HpohkpY@(op9?7U}c=TCv7psBy+r!Vcbi5})n=6S{kd-VHpKAHC?9Fd+eAqU*#9_{E7 zyLzkMIf$o7t6vXr<+50Qw_l;)!)e2en5VpLd(3$URl(m-*L=WIH~{l5T`IsN8#|=t z`pak7(JG6`->bsXV7hfrt#OB!x}uDP;*Pg z5AT&wkohwoFEI!TN5xK`&+pn*+StsruI`i!v?bTETL&Ll)klAB}$_VDI&ug`d4C zOWWzg^nh4LXI&DmhRF^6wyiSauAIE?wHN%&D3)~K7V=L?`Ldfd+#q|+?nu;^J%*lw zlIKI-*x{aBP6t5z2A~hyQGgxrP2O>Zx?3f$oM6wz6RGB#v>p;aMMZLWdeKPeM_+vR zSMQP`$D(KfrQ|*Q0w3-j?_tv433z+#xl%=b{Dk@9`X?P)9+9XlqiN4eubknt)j)%( zJ-nIMzWU5v&?i9;j-{A!?1}Y&NtFQ|yHD#DtI!X@pZ*|r*z|=~FALa75Erj-LNkBC z??2yfLbtG$c8U(RMv;J4%H1ER+jmwXDFg4Yi?01(yh9U^ao1k<=gb0Tq1XbOnVEmm zo74J7)?d<(RB`Okbb_Ko(ksx_Wj9x5+?VcWd@kpi487e#lOFri_27unX881`eE3&7 z5Um04k89_S%0r)-0)?69(!`F^1Fsc5&+0qNyL9DC=jK?6<(}VZDS;Ro!vGjbb^?1x zB49njW*onPwVLYncvi}0aDgLOp%u@Oij(`3Y#3R%0pq%nbKY6_1qQ`)>g}rNsP@UM zUe?HnuDH%>`$e7Jm}?4KbTTHaay?DPBrH#I=^zzc3e_W*wCOT=6Ft6XtfSA5DNl|( zks7<(eg;ZU^{}+WM(5dw-jBXxxNgTN*H0atBp};kVF>mNuhLih({*4u-0ue;S`!f4 zvrt?2kE9JW6MP}kx>d^sXVI5he9LNe;7#~RGRcabJ{FcUzVBDWa%3(@=6;)OZ(mkL zT^C(R`in?{hu-vwS9Q?_)z$akuA#byA-Cm|z=*<)eO?%N>(asqS}#yvNKG>G3`U4n zULl-T#wU6Be60MJLF(~|K* zCE_lZv?6=e=Q+L@E4jz#z@2cdo=uw&h)S2bfaToU$GXGCM*n*bjBwO>WfH6^KIja> z*oUKk3Cu9NR>HrQcs+66BG$7xRHZfz*RtwSH1s>v7InSIK_}8nbLNfz2Tij1eGLcE zsUg`#kTTs9Dkv+15k^bZyzT-4U^dNDuN*Np4`rv=(-(oZ54S)A_ zmZ?znO-mS&@`80xzxbNR%a4CnZjm3Q2q6C$YnXgIEJ9fAr*KOzlL zrqJ#PWeV4@!CS++kt;aDUMK>Gw$}5Q;It7QAC~0-IoMGr3k@PG6dR)4qSV&Ui@eIx zgU{a8^IOA=L_H&aWE?b)fJMTZ^w)0CeZz24{dWC^T3VAOL~9f8@{@w;^rM|GTH@X0 zTH}J$(40zsXX%x`QiZGy%XAXUG;MBqGMh4Mq5Cvp&oVJ9M?G0I4BE&hek*xABHFP? zwYnw-ESP(;{A1i@92v6-n(prILWctlvrJDuu7!utRt%^_C(x7a zGCdJfizo7+)x(2P%#D><@u$SK`}#&VTmq!&ykxsr{_&@?+x5=*o>t2)w!1eiXXKE| z1vQn(;V?c94Pi+KK<$M)VlebI6tBu6Hj5mATRUW-cEIkL6PbwcSf!L9{>9B3wFn&Q zf0TCbNz$Zrx{kI4K$#0TF{{g+dX48RNxRtda!akchw8YsD_w?$*n)Ik&oO~NH_x}*vS_qsU9 z-JTNw<+fUyT9l@KaV!RWx}tyl+6PBj;K66kuE&d_A#_4qh=Dk6gvf_&miCwS$szn7 zdelYTZb~so^(HH;Lxz`mmJ&wF7hl?pk4%rXywbnwgD>EcESher@~<6O^8^1mqs5;z z4q^cSJ2>~USoHO6MLqKTI|v{sHGPHC*i}~Nv?A&5x^@hoi2vu55%_u{*g3wE?|rp( zP3~T_(eR0iW1#7q*AVJpocJy!*;9UDcek zgSra5aG0)mopIE=Ge@xnTrg|C8%u=s=3L>SxI>6lGCVO;v`mBFO_7PHg~*|3r!EsU z-B|;c{BP4G5Ql$i6iihi!n-(FP|a)a!mNP^22H)r%2*nny3GuQ*4i`skwiJ~!+1I5 zihNAv5JI=_v-;=kfi888JHQ;aeREz{QJM{B+vw;VaH1?7X*i%pXrDsJwq}FIMklU4|#+OHd#g@;y>ECIH zJduO_`l--?4qTom!3Q>AKt*=FkwvHyBvhS2;&t)Ej;_R;zRf%{prKCYt)~p~mY!dw zLH#EU!gueXX2A!=ht%F2l!KwNQKS%VP-7NHV>>w__x2qwN{B1%%i-E^US(z{?1Rt> zmDH1t7d(=erh9X_1;KQ`9uKpFzy9?kx=Vx*;vKo{fnXSI_Rg4TI}?hAw4gsP(ncw7 z%M>}sqeV9P9%2`VN=Ws+73B;B>u)6)`soNs#%RtX<@N!E#iEo$B??71uk&F5Vb^0n$OmWmE zdNHATFndrIPB%^DQCC>0*}SuD^180XC?tSCwCYw{tx?THB~n{Wm2PQqhZsUo630gw zhg>fQJg$YjF?fvB2Chuca=^czX6+STEe&HwMR&~am`z8)udLCmQ>U^%) z>LC@>Qrq4CXT5>{=sCS5MdTvAjg%f25L(xNK5HFr>w_v)uRZv*0fW_@#mwC~qa}z$eQI@&jJGk2d;OCg+7%WQ3ezr7AL?1sL%>>KTn>5N?x%c)$09$I~6jZIq8pJ_3&edn?uAN6=Trdj6svUa7sD8H_s= zYVR`A#MQaW4H--`CD_4dtw}}Tt0wSW;qUtxv$PEPq#54&M85^NMs516l7v=d@nqS~R?ik$G zdvPemJxVuWP*bfuWv0CqB_2Zoo5Y3{$a}2Awr=QOTCdCErz@T$3Ox4*t_(3BD>u%?FqD^~LzIKYCrxR1T3H@gvP>UjW zX==eYLm_Q$2hds;%YM6z#=L3c5bO8ut+%C`D)-vLh9Rs+upZN&TTQOnFHX}uLqbS) zx=j??{9Wub?oXyww#s$;9%vwukA^2?`Lqq%#n9ZO1Sxg*68{9{=urEsj5ppOY9sQ2 zo~^5+spe~cu#~In0_k?DlXfgR5MMIvnilk2w0b9o&!T^qfh_%>-q~_P4xh`zUozj_ zwnr&bB)qG2SWRVE#GYg5pJEOG6yD2`V3;@qeI>CZObb>?Q>D@eNm2`mtU|X5xS*RS zw5iEpQHA^NI+klXU%2?t4iOg-viYk?T1>ladxgk=``w|o^MmtNi2cif&m#;Wm$V({ zZ2)H#Ql4y0--}^gm#Kv1(DQ_@tBuW}_?!FH<_XDD2O{8Pc161dZ$Ce9GkrGDlNiAA z&g6IX;+Zq@*U=3-XxZsKYS;Wf_Y%Czl15_#HCDDhKHR!s5C>~l-u3p1N*4t@KI~tn z3fM$7OWrZ;jO5e!v(#H$9WSvjetc-pD~zfNiX8lX*#!{=&8xMZ(2Krri^0uEp9wwq z`gRxY`p8pqD{b;Ch1$6*GAa5ccw?}C$DBSv{xWwuqowjDXZAeFmf$2ul%fUt#3`)nRNwp;;9VnlrK~Tb?!id z7J_VWt#?D`9j$REdv`9wk8WsX`hN=`%Io~cK#%MQ*V`)Liq z+!;-vx>Ww%*%FuJvXM5bZjo{lQpjN+h?wHX-aT=5opts5N;Z1I*ty+{XDRR~TB4(5 zs%vLmMmDE^H(JGc6Zfb0eOkNHTPnn-g?hCh7SH3wYI7tmP<~wkBY{5my4CG0PQARw z29`uMW3O1A-ju6fVNz{9MrGLH-$n?*_rP~lRprnxO)vraD3ux2x5@n<~(kel%u z>#JCNRb}rR)bll<@NIE&+Kf5rmzQ5jxw%QDf)#~u~b8o71 znls_j#P3v%njY9$s`3l%=MgMik!IH~=S20Blp98!H#h=&U;ioAu*P~QUoDMv zdA8y`&r*wLvYl>;Qp7fwW!K#kTrlP#`U})8tPU^3cAv&}d$~WD8OyHw3r@9pAFhKq zhUA>1L5IxE${rIi(WXbVXV4U1>^j3eS!X27hJ>)W zPhrXtIsP~d7-6(pVJ%>f&nf-Znz)P-ka9!PC7HP50{lqlZn0UFy6D=2iS5RYk1e*; zGF_ROCvP?WsNxUP?TTwJ+sLC;Vh#_5Yp15ZE{g)K?){>pv!`av!AcX;foV8&3cRVTAz+rd@r+0wws@E(UQM}C=RTg$F7O8=Q3WriC)R@4!U z$CcbO&BHAb!o$OqC6-UDP_TVz;|WJg6`SRNUE*Ns9!DyDk7zO!k)b( z)nisqEp%79QWqo0!Af9_kcqv$Z9W&k*eRMSu-u<6(rPtuL0xS|CynKfu?+m6NeOHs z3WgsV*5rMR&58S8r2S=Blu_IMfes8X(n#lk(p^dqDN2Ln&>@X<4-L}N-67o}(j5{a z4T?y2DhwdKZ{Fv5{(FDlhadIuhPh|0b*<|<&)+Es$ervqUdOl-G6uG*9EpfbO?#ZW z(yphb@3J=f-CSUq;ZnYcUu?3q({HdCX9|JfYfI*wJHXq#_V@9W9>0XAx!e=DEb3ak)ApdMTG-o3*}|N3lYR zx=_FL3+qX`kv#5h%?h|YFfL~~TKtlv&ZuZ}7esiS+fbsK#kDn>nk0>mEnl74gcK&> z-X9viMJLwod>OriRb>7x{;iSkn(RmlecFZd9GljLxQ(E8{&U$Vmb$+N-VPBvXl2f{ z`&x~!m5+RCKAl`0t8rgPc0o|X@u|4Z59ZRetMyppQ9SN5-5g6bZ+GJ{nx?ZZDAJ*`+l!^K+uEo8 z@9Oz=Bq-1KVJtv@w>;@nO&{K5;9{z4oqAUYaX|4a_%4dN0wa4hst1AhSZO((a2lMo_RsQLwzd+`VZiiQW>e;=|iDJ9Q5rb81wg@IzpJ9nh>6*I}nTVkzXfyqa zQ(7r;7P}b>Y~j>-bvL@j-e5iToK?55Xmcdz%>nYb{bZDxHlT1JhFn~_L@BpWP3j(~ zwH4|M2Z^=o$~rEWeH!kPjwowqtMvt&^nk5Y^$>}8|ncXiMz<*MoEEGt$h+~mpp z79|~4CB){}n_sh0D8%Xzd^_Y>)4{@KKPN348Y<xmO3{VAZEq!j)5-G1&f4@q1AOOH3*C`3ONbL_lvW5BDpDwGN~p= zNMsG%j~IQv9I98rd4UUE-26cZzWC(HQ_@1g6<-Z05|)d2+iMS?-|&Bbk^n=A>M#ym zDITt^UaG*UDt=YeaXUS>f3d$(+Z_x7lZOytE|6#^^A(SlDKZPaWpW^6I!-_1t$zc? zti%rJ1S_4``>R5PkAFAW>0I5c+}CJh1LGc6>Oc815d89AOs*y0_#6&fgoMsUF8T97 z+6UUzjDBrMK9@~7^v2bJ0lXqp&OU`;_Aq}Gu`;4~X<9Vqs;5fdK*RY#AusC4;>lmS zB&)2$1Ff@YXEUA%h09v5<_95Qf6z>Lyur)`n1RpW*&^?RTm6sPKGI4U^@ezWGm}q< zCZ7BNAGZXwylUSiTZ|BW%7UZZ^L%oqUB@wo|9K9~Rlr1QG|$+S=fz)+a%R6L!+&U= z9sO!o%@nYy2*e=|6=&wqu-9u&@by01J6B4CJ7VBbvk1Fvs+Czj$5m0PiJ=nIh8wh) zm@b)9-okicLa@TgvjPKu$0mb&CqqfvPIet}Z|>D)g-e~EX-D!u(|!l3X6LGt>C}02 zUoi_CupTsH!3J{NDi(E3Ax)K&Mk1PrEQ7|KFl4fd(d(_X(t%3GV(G;wEFq<3PQ@nm zzpiKNoDSG9@7!;d?nAO$vK=Y~JJ^cVRZW^u|9$h?Pm^F`kVp^Vb0(G@2^aas!tr9A z-$r=WZOhL?b48v_ZkX%>;uD9C6n>qNzQ57ty)B`D#gK;GbDkVZ3<_^XcG?465t&R< z8z%eFrkDeE|Lt_CiI4=^2&Wm4#BK5S52w@7+#_cbu-fBflV&m^3TGO|=rm0`><|(Q zp0c;lJZQXJcB$`$cW;AQor%;PyD?Y@Y zd5jqhOtn|zE$)?OlcS??qDg8P%-+jUmre+U6@UGDOAyyTfcL~q3XE0n;oG*M@w3q< zZiv#<&}OPIH22eYxJ84-Sc$VDJXDUl`^q9;h2Iho)es(`%b}<-(On0{&6ML1*62Y^m?iE`l;r$tYrUm zopq6w;IToCgCEyvn>rW0ulaP$aJuThVKf_hmb~}N0#R3ZLJQmdxt4Er#@2eY-;Jq)J@;0k@~P7#f6$3(sU4GJXYXBajdU2(J85Vw}eZKx~Q|8zpqyQ z$*pEdR%$zjws_V>mo)ZC9Xi67BQ89Tur?(*%27L!lu@5e2t_m^FJp47wAzUs*|D9! zb52N^mvrAu!O&03H?)_tQZ%z_%^^D6kN+1ofa@G^dL_Ay(>+n<{2E}PB<12cv}qo> zLP;)NmRBXyc7e_On2e;OH<`&mkH>uC=---K0P)G>!6r$H%INv|UBV7uCr=cnv5 zhTV&IJu(EqezoC|Cc^&cTnNdU=f6Mjt^}!Q@?|FjVL(kT>*Pv(H-=jmIOYb+& z{oMaf%T}L^aYORM#p!8;yd*aQoXAjRySf6&{Nz_(cu!=Df8yffkcCS{xIU{1Q`Kzu zus5W6<5y?9c0TZbk~6R9ppbUD-{tLF1Z90rfALqmB!K#2lF@)Lx) zV=GdTHm`PFu25C^v|ypw_mH!br$U!3&~o^LuDf;+`@tlmU|=;ivtQp&1woBC_Qh*y z7c1*r>>qYeXqGq^@J~`)8{dr8duYv#yB8`N&iz$s+BEM@b+MF|D7|;F?6`3gfmO6; z=kaSBOC$KV-!H+SXvjbO>}~K+EjDi(bg~ca6|)Bpt5-S>p@nYU%G`SaX;Om|OW-lA zKDMC1*rkW>pHPP35pau8C!fC~`_<36hp;b6=Cc7N6HBsN`Mj)%=-;26+MYI=`3c1C z3&if;(XD(>CDIhM*-Xwy-oCN5@RZ!tq*uf>^b@GtlgyZW7yNNgj47Y%o%#EP`sqps zM;pUY6!=dMXItIE6dY?2$?dodZfqy{-{=Ssb?Vp*B;!6_Ugu*0PW_$wc1*)`izJbK zoKA=@Zn54FWsQV0SY!=vfN%R+&oEf(?!&GR>SU0RcIcIl1Wwee-M?mPW}wr^cOY=m z(}1gYInUNeF(EL2wRd&>e6I-D0{b@yjsM~v2+poVp3tSfhK$keJ`;$rJDGixeKsGV zKBy?)E^vg(US`HTxY{v`j^fyo5STO>xnLY#%xHCyP5CP3B?~zM{*u@if8S|s9X&Kt z$H-fjQ;XwSrx)#e6G$o)i{!PRYb!?_Fqlv=NL+0L>3!T#VM_X5}I3T=ZpNH&~-+yY_4jL>+DthGe-jb$5 zIrNu*54#&o+;r9*2j7MmaVB}RUFF$_P0GRStj&g zOkZ66PN-=g7lKC(i6{%N)d(uZQdnj_8ws=oZ}!j8j)sL&ncoGUU$8 zDQF)b6ghTy=Ffiez^9lsi$~ghtElpyE;Xg)wAzl(XfR(V)Q#{Nv6=cH+W^4G0EATS z?3k7@<&I;aNpK)Tl|*DHbU%S;^2R6D>yAG>*XOQ%gyHH^V_?tXa%tCdJ@du)axX=# zc7QUY4;p36J7GT(TnJbiTe&oSTO@^lj;X_q!9p0|-Dno6gUv&c)F%~3`;GiI5GS~x zUK@ml){CktiQ3yLiLY4kG9aQjy!TYaZ$P*S*>W01%2nYETb|xv*5KZMmed1DNsI6${;B;|28H6!!~FI?OF!Wf5l4AKXu*T| zFS3WZ)9PEO&~ERvOipkC#5sU433npas+VgdgU@pMi|Mm|)NsaH)f4gkHU@_h<8J>p zzK=bw+Hp_cfstNhdS>~|KRO3pQH`ucLFXx5-3VnA^1xoL)`K%c6vc`BAPkuNE9BiL z2&@3rfcn ze=y3fmmuM~C*Y{W(TX^Lpvb(J9PScrmYkSDZ&G>0gW*OC43B28TMT|AxxPAH_dNHS zmk^zFIU2cdK4jzIkTqf{bQbARsYP^Z!uFABe=@|K+Bm4Y)wW|iKpQUts=%4f*u;oO z(wQ=rKnhbRY9HZDrP0YN8CB8+NMLMj(*DWMIn>wsdxG47_=1LKg`Ap?2(Ko#pL&lV zGwm1n!o6+9s|@7$SHz2Aexj6<{4U~;>d><}K{K<8QaZ<%y}zz#o! zgjx+ENg8|?UG4@J`<<)UBwte_Z+StmmY!>YeRhDXsOet&+Yp$MbIxEWX<)}e$k8+F z@i7p)m1-t{9qOP?H({GCz|p`?QnhWRXrZNI)efo+yaF|R3}yia1`LaDyS&$FLSF-) zVQ_GW{BH9|Qp(_yU*Ix)c^opJKyUT}i-e`fL{b;WTIm9=^rJoe&hfUvg3y9+q0gWD z5Y%aTp4nrPA-8Kf`6>`|KE!j``wci>x8{iG5_Pk#!sP8U?sMj$(2idA)G~0$OD-mm z?NW;~0`}EvA~(l+_pcuc-TL&qCZc&_NrLo$>#yJE9xS}L#~|-Nf2AuPk@ZU727C-% z9NO%Sooo()9?N&?z9hk^XV}LTt4q#h+>kt2l?r)j8DVjt1T0gU9_;#81-!6B-eg8a zQVLPaDz3b4m-8rb*W(FS>@a9IB3hn6vg9Okrk8Dp=zN``9u0a)S;Mo~i#8g&0xUn7 z|66__rMkcjS1Z(1s1bj!qHNl@CW;h?9sZ^>Sa!GRS)v`;rrB(QBQ4_oF#l`H-r&h4 z6(TS@ggI3^5CT2-Kvj=NZlZI@URO>M?!s5-i* z-zumvq?jbX6K(XDgut;|UPFK)OcFh7nHOaR7ZDGkU9z8z`Iq7EcVokWcT9dfxGaH& zbXaVW|AqB|pdFRR#P693Mp-{$FN3lYCe_p9Ti;T=E}Thzt8d9>vQg#e]Ff@gi$ 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 literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/SlackCreateSlackApp.png b/libraries/functional-tests/slacktestbot/media/SlackCreateSlackApp.png new file mode 100644 index 0000000000000000000000000000000000000000..157e946392cab1cfeef624e23bffdc5d4e1030bd GIT binary patch literal 102270 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�NqQjl^x1uYwJ`eB}5^W1S&o`MtLB*cL`8w@z z-@yjyD>Y$?GGFee!#%AP8TA*19=^3WF+R7_bRv^J#wnCy@Xt*VTDxC5sc8zH?c498 z%|d)w$PXsn9}fo9#L08J1i79k%^!K;vUUM-Szio|gCTk<>4xn7=*O=mYdyG?w~x_Dz|=al3kL>+T1%jgfl?2ZmbxR$i*kn=h4i$Lqc*pV*gIDu~vZtLAF5B8Rh_ z6p~c?pEkf;F*C^?M0XYd6rZ-zI3eU=Rdn3cm~>|u@@2y?ti*(>uv z28j5MR>}m_*$6rreq%Whs0bRj0xd;JJE~B&$FV&yPJdyfnfu$%lzW=mvjU}C7f;YDSNibD2phtynpL8OJk1luC z^0r0jn{^tTYYzjRphU<~GR1TO6ZtPZD^2 zc|YPGY)#%sAN4buc9OuCOIho+yv#FTw^8rOX@vZp7+HEvISGPi?Sc4W(|g_`H+)|pk$jLA2ZRQ}bSM@T+MiH7lgp?K*pgTaUtdU2;ZVDvl5z$f`~tl*PNGpdw` zBC5}S3~oQBRyWz_=|UmYsWlcRI2_-k9?*$T)1BN~IM3qU3pTX@*}@?@hb%#4@WstC z22PJ$Pihm`52x09@kh-vLCn_lPw}#8n7j9xeQ~||ENl~h-pUUx2j=J^Gfo8_)|h;? z0AiSxtc4&p_gVr<*PeCCuwTGm*PaAPU&D&WVCPM3pjlA!7lJeso$W@n+?p_Q8fA~5iER=}z4?FX)UWnZNS(`TflLU)+f3 zNRj>{SH%mXDblvHtKXCJ<{0B##k(z(wqj^jTK?1+9AVI|or7+uXAX9C(Mc~wy8>X= z=p)P!c|G>jhM}giY7O`t0v&9NA`ntzVm2^TI#dAABpOxLtPKfAX-{_h z8KuFItj~Kj=nUykA!?8u;2(m z;lgW<2jSb=9^r_4iA{Gar%GELgATvH#Hb?sP>P!qUlQ2AW2t5&BODSbDkk#TbH0^l7L z{v7cu^-S5?r!p4Ll+hP@cFG*82TOU{F=4DR+BLr20f~MqK=jJqugN!Zr%m!)TZ6LW z*Qp;il6Upb#_;KX8C|9f{Q(ARABf1c%MoBer_o4GVZDK56$1No&GCk`rLRRGj%9`< zk&;VC$-!rgzzwNV-KHF{x>;k6cx0XK!%MMR*0g8dGH}U-Cisu~fV$@do31NvNa29k zQ^h~^3lHiBO3qFpN*hKbtmu0c-a8wA~MNG=D;owjzz zZXmpI%~P9)YGG0xp>J2FmYdf!7>@D|%F%2cGo6*t)=XeDUlV)|It7L4N8c8sJ>P~7 z9?iUge_tRVMed z4N+1Ia&*ASG!$Q0uK`VhR2Vou%*(Eb*$C5r1((QxjPB)&|E#{%0e+ZNzS+-q4|0(lUVXi-CF{Uaydgv`9N`T$bz){I@M29X(4KPL>#u`kF?V1VG>o|owYmo`9 z&tjO`V=dyx*2{S;3S7v+$P)$jv94@Ud?H99(E z3~D>xw4$U%!`kYwJcEB-3df(t-oWs`cze&NCc?IDSCJ}65v2%7Q$RtgbV&daRJt^g z5|AbxgisTzG^t9LE=cdahAJI`6zNTbK3p7p*z_u6ZH`=2gj#7yR%nfpAi z<5(*45oSNx<@7dlrbiq!vp_c7kpk9L$Z%y3|Tjo2l5X~;Kjvn$vfUk zx{lvzo!-;B)!lra*yj`JDjvb(kyMl5$vF05 z?`<|}JW;g#QgAmwMT5gbglHhmMlSVHpbzy#(U0^e=CX05gfjK~ZQG0NbUC+m2w=0+ zI_cgtq{0YmfytL%<2!O(AKdJwN!zQh&1>s@*Zys4m>bIQ zbzl4bAw~R^g^2lEbNknOw0(q3M-3{kATg4`kIZscCr)7M3eW+uxMZ8=+PG&=5Ib*a8&N5|7O^sacfa4(~H_ff7k4v73Pfd zq+iHT&o73LVef(CN}a$0kmnn2Y?YQhG7N}t)c9>%ZHiO;PyXP@{CwGS?q0>0C2nL;>8?p)f@C^kycirR5p2u{f&JQ zyc6WS_0B^+?HpRc6SQFuGE*R1em)pIK*_**u9}S2y*N>A3RU2!y!<|2$pxAOAFm8{ zMN@os-Vh;wn$%(Wm{-mOoxM>rvv_)!X2&<&n@1f?b8QeawwSJY(44c+JyPmyEp%(T zyTe7V=5elFvb|CoQ?}sRL`G;i_`2i6Rzw^6c~>vNbOMd4DB^;8N7G0jqTOjjvR^aq)!)}+*VeA%T3W}btfakVEWJXt)QZcsjRuoB1n6w z$p}sqo#~Hl!dReY-!nvly1N<8xRNmPSf~#*{6nfIlE|KIG_K63|0sE1OJCfqcS^V> z36jd)O#zX-Ive8PI-iy_qmYiGNVd&|k1J2l{PCTn5%&xhkHxRAs|GEjt45Jk9x)$eXQ7PkTzbCd`bnmCpP{KCr=hrh{eKo zEfh%YL!D&FjniBn84cjP@0D#@zoqdkE9do$KgNd%Mv}>YBFHSI{EstRPl~H;B zc}CxxePA>4k0_DaTE}OvOEv0)CcE?sn`ayJ$q(cUc6rB`un!3Q%?9&Zow+yJ%`9&+ z`)49*V`Y}L#K|7>k|onIYVtmm5;FyFxI_xVW2nLyv?PgeD{QV|WcCm9F^!N!kOT9F z))ZD@sR^Z-ey~F!MU^L^JNC$LFwJg?I+-U~1*>R&D-ReS5ncwNl&3kx$<3W2A0CUP zCn1hv>C;_`iXZ#Z4s<^eNqgru>EeZU1iz0mVg0@MHYS;?I+6&=)*r!=ES%97(Z6%z zctj07ABoo6eBR@1x02j?mgUgphqUMlmEmZvJ*{J6f^7yP?I!#F-7Fda)8MqEWSR4Q zV(}k7?DUhX*^l`qn28Yz5Zv~!L&YVTL?@+`Q22{$F4YUWyRfLYZAt9>l_vJ*lC9nq z)PRy0WfrHMY>>PSf}1m|cxutf@i1^Af{%dywK|>3JW)HE<60b?!4eZn>i@vG^KzbQ z&dw1GV%P!CCFU~_!)Jb!aqYL9+$?7a*C@)YPb%Rr2WPyqNh_euDq=--xPW0L4wr)w zqHvMo5I0+1&EhN4SerV%o^fMF@b-k&Lj-K?Dk~#P=JNfwA8^$Qk>9+V1Ujbs>GtTY{Luqi%4{sahuafHJAeO&A7MakVvA8 z2?iE%I!q6uyUJMvhAS0RJZnEQ?m4Iv8~z>g@1aQysQu8^etHP#1m1+A6rMSn)7(O$ zJm|Qzisb9P>kF2r<&jB}WzTlS`b5I;KOgDgEBs4;l9LKve-O?-IRF5LZLw+dSO*~C-@;Ge|1WI&WBc$Ywd*H9WVI6^GQ5r-O=h&H!T`rY zQ8oQz|D$=pBsGWUlK*3)vj8@Tp(x3){y+3} z&hBBE(>Vvc7vq*Bo@jt)L#&Pc`}BcF<;H?!9{~QYs{D8!P?r6dyYAiqSee}L@mSLU zf0NzX{C{8WCTQdgO!_;B;2xv`40NtuH8A!F-*DK(a~3?)T!2~Y{~WEpAbR7e`<+tj znSquI`Y?b4A3F*_UR9e01X^KhY5#r0lRb&wc=S4Ui3FVVA3pmQUa(UXi)Unr?EX8t z_yDIbN_uBXVfv3Hfag&6Js)$x>m^!c{sSZdwq%6?ZB2N1%Kzy20{`ci|AVslziQ^Q ztOw?=n*g|VNfW?E*nPindR93M5IRP&2Z^nAxkjevmuS7##xV(|AS6(RXxzc`DI5@I zYY)ZfCbj{$+=rf){cfh&t8n=mXVc;AAnWXps0(HtpW}8_@%g8R4*$->d2RqqNEo39 zfkWNTH}c%^W;S=A*1}hgXH@{K&e$eAG()wQ&qky1snc4rl^al0IBmptCFX%hOj`Ha zr-y+;{{#X;-4U(-sbDNREeFx#VGIU!z<0Q&;^kWbvT?^N?CI`5V;EkWtEyV#vZ zI>NFqe%B!;(tvY<>@htJhktMV(WtUPx4CJ>E{ziNFMFV&Gk?BWw%E-evLm(#J9Geo z&Q1RMrGVs;CN8mvR?**Yv+?AV#J9nU7o?a8U4s&QBc#c;w83&MHfRM%umJ++#mR#A zF4s|ayEtChT?ce}-0%bq%lY3{uo}G24v=h6%{Z@-WAUOQDCDePuQ8BPD&I)t-xr^b zgvQ*RZAG@YxaRk}d3wZN&_72RIorR!ge-Cq@RPx5E%9pXCVbAUnuJGJ3}|<&&?z#7 z`Dv-khz8iv2*K64bh#HzkuYfQlFo?s0`s{yZJp-omw9sSfAoYf@hsNZy6}s8m?^uAPL*=NBFV-Cg--+ zm0_Xj7pwc6S5FE}QVI3@cYRKqw&&cRbNxqj86HkrDb3a!diQ!J-=_`z1zK0K&)98; z=0|$oI}KK}PyYqZeq7!a#i7FXabeRYfwzDT2{D1M{JaNy3|COAvI6N=7^&7_gn%2K zhO!kN`}G6orEE;YE7`6m)^cmzos+G&V*sJ86C(+Z0%F|+=Y%Yoy|x?lp+lfyaYk8j z5a_f43B@0v*@RFQ`H}sg#to7&Qk2#M8uL#a&iDQUH2Mi#7+5HXrtRcvWmWd_DDn0R zgEhOasP_rP2{~*Abe9$R)aKG#< z??g+h`jrSAWOotsc(FG9@UXw9(`yeHwRb!Yb1QUVvE_lqwG*P!x#Rlqd>aFOi>j<4 ze6NYgj7~TCC^-*ciA!0p0PL6XG@yd>WK3+%@($LS^Tr@69IVRG%bZwd$;3V4Hg;c6 z-y&X2)rBV$vxDyEm`TJ5Rm#g%CLutN9K5#u1=Xj;vMQ_q?V zYhIc@5;Oqnt%}B;z4yQ!Z+2h`uRM<-tx+I7x?ej}CmW|j)U&hQCzl_#qHP^ndnQgr z*V1BFqQ<~r#TtAAlr$W`%CeBE&@0Bs?QQO~py18#u4Pe=y!|=C0+UNM`Ih{L=Z48Q zGkc|aQXXhmej9B7mw)|AQpdy4;H8ZuvncC}$4yb3w5HHzpESV~Hrh6C{(ci`L2^K2 zmF0t;qZ%iCFC0`$pLz3MOaCRl8uBEcPOzx#F8XFqoX#7o@4wOBxjT}%jgG)dSQ1U- z4O^E)6p}-v(ye{73FQ3iDB^_URvUQB0=#irmdlQ_x0&(6Pg)stdvw(5L7Xjkr~(ks)|zXslMfJ#{T&pi3=dh;zR%hri@y)NMY5y35V@UrvV;Qu6*4n zB59jBZ3yqcRB)zGD%)6eH#I9wV&}e)g0Ez5rHav0hYNk#yMVt-L2v zQ9kUU_Pel>isG{I0Z+^3@u^?KKzpj@EJpwx|6)$Ry355oU0;^4f{GBX&*o7U&5CJ z+Sk8bGaPyBF%Jj`-4TRkhyxD*=o?He0zjmjQFg94Y`|<0xHfCeN27qaFiX zQ)fN}oFoX+#NDK{5Wx6J@X=Uty{ zsbqE&JLn2RcgnrLxPypXw6b)8Dst+C=UJ`U8OIO`iCL_D?J$}>V+ z@<{~O0*#CA5-G-5g@TIr5yL@3yRdSk(-&%jwh9%LtFdIQ-pbo6Y4w|^be|CiDBIj7 z+|1C|CdF)H;2;cl@cJmOZH0+r5VQ2WXEXz?37XLDK@|a8-Mv**V4CWOmgYqfrWX>v zsnLL#9sL##{4Xc1`kk50I^RBx+P0meB5Lq)QhOXN5LeK0^6UQUzV)K}$(Q3X)QP55 z$J|vEis3TX5TObF^R^{5<#I(T8xdvYi)Gcs-&yfV0hhCKo&buSQS!M^W_>!+86vrU zn!)2d%Yv#*BkC2AEAlPE^aakBgwya)E+{Qe)5Q~sJKAI%WOsV#OlWn2>_b6vb;!ql ztT%z{lP{@2DYI1 zIV~ZV>-gH~LzU1H2A!>f{Z3_Lw@$CcH<4C0-nnm*O5<3`6|c|xrHh16UzJ;jH9+nn z9=xG0yADA-O^87DcRkWF2=&F9?=$B|R}>=9+7kD(I^AcCw>dGdQ>h=GD@a!l)yIpD z(V68qWbVBpdl4|SA4d6ooI!&a7$b|xf~_UNmWf|X{Iu$B*#99=SfW91_LcQGvBI9Y zz}+56G7L;PKu0l8cfv#n;*jHM^~EVdu(uZyix@R^_Pb%`^>mSC+sjO^0uULCQ| zp-^UAm3*#AeYC7r8`_1V3pHcEMBY*3>uZhA^)-kvfP?Cndg4Fx>0Tc{mj*b%%-p z(X4UUbuGOMGe7n70F|~|(7&;`+7S>cY&&e-hKc62$p%$Neq{pu=*2Nfg|0X~sl`fE zd5r$k6LUqbyJi}YmD#tOi(PDVsV)mzZWD=^Cw|h37rUjy7U1%ZpBmh)`d3%?cl$=NFMz3v_qv|Tsa_jTWLc)Lm5&t5<`hk|?p%y!N zV)~ZgYf?UH;x)x%<%HY-ENBNtjwXyi8zbl`Y>P5owXgAI7GWQA@;KcwoQ)RTR?j0? z)^h(FzgR}qK8uv1j_&6Kg_+nxIpWLCI)%>iayMuoms$bEpE9mO7;MpU632n&{vJSP zX`<=muL15zD*x50kX#&n!dn-x8sLb~IKJQr3Vnl_eMUx_RGjx6=fqh|& zex~R2B>7gxVHzSquBcW@&+x{0UwzUo@n~RwIka~C?5_@l`a}08AE&?qLO0-SDSyfq zCA^Kp?)v(k9_7ij0EN8t-!g#bFKNu~bUY7K<2__+JmyeONlSSs2i&yqeP-jN9Dm_w z3HHyjlT+<0ludt2qmoE=NhqO0t?S;%Tg9budy6c6T-h(5@47irTUXq*m)ACGARx~2 zW`TP8OWh^W*D(!dOZByps0u_}o>|_Nh;8PkE<1h9^oBZg zMwYGL?f7SiS6osoP0>NW(gL?^Fk;EY-q3S3-oV6h>xhW4La6zo%$Mj_e~m9BW`3SR zpS6c(?pp%a;ddNxo4EQ5L`^Xx4BRF86(tJwDG`=eSX10nkK!~D(~S(n-Dz{CHvjZ> ze{1SwMF6pDML|z&KT6WCsf3DPqMs?b?4OhOy=VoAsOo`}qh2PhgArBO2k*zuy1>KN zeb`;wTXEuRDordeEGC&J8#=1nJ@v;x7klEZ(&EpqUdY}Sue2EM^yvmD_@Q~40d^i* z<~-L-!B}+jKG7S-mfPktj&=-V*~2X$*gF6o70HAe-J2T~5CzRq;GZTF=wOLw#=aB0 zkO^!b{3_>S#T)DFoiY~ zG@cQ33Y<_mz)>!z?H1PV6JJcG0rf=2XT3j>PKAv(YvUxJMKzuDvorMT5#(mEjXAV) zu=6aHoFDC{7k^rcYP-{WOq5&@k{uv@5sw+AoGuTdF^_sQrXj&zU2yV5ZcXOTbHz|U zhV#y=%jNo+-e>}E9G&)N-m=G=(}(Q6=L{h;#ZL%xaBrx3)?%~R4jI|=jJj^Mw_)Ca z_hYf+iM!@Qk7M5^|Cko!vVBJ09B+F$m67js8T2BdUt1D85HhoFxCf?@GGxw4ryryH zlivG_>xx(DiYa{Nc|GGmrEGS2$%>>q^Xyw$NK6AF;yYn-3S0M$3Nt%~izpfN`;v^f zl7)wBD&gZK!cc`0ET&6hf{Va${HcaTazK6r&edqwHUw)iX+t3a#yn51olG2li1Ocm zaR*sT9gdjb_ivk_)d`~t}wW$9do8G$&H`nKnxkl!WYkU3tlz#spK)s^o zvq|xLBm3}LoaJ%r5>VD5G}##;-+KL4H9!oYaY>^;4L)ELzA5urgq$t&DWEprxBJ1U-^d)bN4?hd^2c3{08Pv=?M?EoFQb zHqp3}t>GL-VbC7~EgLe@m9{IGuv+L~u=8{6hU-g)BG@Ps!C|t*b8=DRPWxwtuH0JHGLTF8B#-nJ~!x?BE+WE1)0QEs+3kmlwm^)o?fWo!Cxcj zoI5qt4b8P z4XBE)zKEr6L;P+Kmy!Xw7Q}Ez;!#bY(b%hoLEk% z@QSNFsDf@YpNSl8twKqZE|zSgKP>>y0x2l^ek=C!j5c9+h^S##xMjLg&ktJ_>2P}^ zX9-yu^;vL9Pkj09FKp7CP%Dxb{$7RpTR&It(Kab03>WyJ70)4J=$rA9fdgOKA z+2LcAWC!ES%A8B%6U5g7;Xx?PZ5bdr`6chxL)2CN~W!xITb8HyG44R$QaH}G^?!vq5#En zDr@Kqt&-j$DvUCsma5g#yMDq$B;fLw0WLR2Z~l-2&VK{2&xKrfs|iCYKX}CSu=nw- z$*laW`N3A6oj%c2u0Y{V6kCy>;nD{-J(FkXvwLtpwlZ1l4j?zqVvjjztn|51GTqt} z)f6=BLoS}T=KipT5Mf}m^yZh4KcCm{fts=-30!Asg+)b?^}j5UbY40cAkq7T7wDBk zpm1ThlG?UHyP}>6>R+tNVm048s#x?ITX0Th*OsyQMcV^i0eaJ8tq+gp*kuH23ai9A;Onul4SEs`Loa9p^c7H*H9q zr`2=x2P2O*XeS@Rd+MN_UG>g}9Jbnuqf;N@A5~UD)#FWB2^^;G&rU!swGHrFCqIaU za^L>hkM@S0`dxrC!^-<3XsoHOQbOEWJ+4UTF1GRjZ$;CMc2zRr_AC@{=ykagy^hz{ zc%10trWLPoxrkD55S@!+UyeLnr{VKlq3wwa zpMP#9MDW8*>Nz8(lKo-xINX`6(kB^Ra5b)EsmX*<6%^oIlJU0=OT5hukZmvv-Y`c< z&!`*d2_*V^E_zy>S~ZSzy=C{UurXR-PweDz{cc(1hf8mW-w;Wy_(CD>T%}b)31ciX zK?MgF1Xiq01$_MeJCYPz_H1>%2f?-?A_2;1G|f~Sj`jf$1!Y2-&ffos*R>%C7wER| zvUgD21$kfDF9lbTA6(M z9J*?@twGr;onY(dqeYyx!@NSiBHK!>2!Lz-*T*uBn{0={9fJ*9sk*xd8D{ljo*4ov zWKX_W!5XdyC z*8OY(YX7f5MEjurHZ@sp*!403sof@T`=(_hSb);m=#SdF`BZiOQshT=MNw!qiL+M1 zG`3;f-F)lpTGB4UB5dXxC+_l5kbmt41Q1T;p5J@$>7^;ezqOLtAftEcL*7U8v+DUP z!WS$9MQl+5_Tc6!fvRN;fhxcweB2rC`#FDU-abMz#G#_Us&+xU?Jyo6t&+5UVbrA-%MRuDTF zWRmPRD%M_uxP3{ENN&WaOrJMW4<5z}b@YJf{SITVEjVM+z>$uiH+u4!zGiJnn(Ahq z3=HwN(r@`Au3Z60j;?@WWWKZUxV9RB+G6xKjw0kytPe^Zaa1RiZ@)06J3IdKH|SH4 z{aGM2V-NsL9kuFG3bWX=mf`L5b%3pdIlekJqv}5;HEzjUAe zEIlann(y{OI&P)z_7-p})-f8fE4ts4eg711a!L0ZH=CA2 zjSfqkXsXYuM1XC8+!FXs4PoSoc0v&=X1KyR)Jx){voCpc(F zW*8=ZESOd-P+gr?R;_miE6q4QHC!3pCnK=9XK-oZ+o6=Pd688vM2t~r8ZYSj5#saw4CDmT z;|&;|X|F?t?Og0u_5CaWzDLznvTwqQD)X8}O8j!`VfZd{qEjkl!n)GMAmWhP)ZM#h z=nVhJBJ6P7vceD;HOp!iGUFqgj*&D6wrzH?7<4htIC7xrU(6EJ85Z)aZq zVHl`y&@$|n+5j>bhc6!%k}NLHS9aTXl6+!@46PkHPXW#6l~xNycgrL?=+l^cx+YbbHXUMZph(5DmlwwaGvX{_5RL8 zbZMAzPg+&BhBz+Sm4XT`HF@&yYxjBAU|Lw*((z*A@BSDJupHBo`L{}+h)9+^>w2oJC_R&_%}U! z7{JGOJNJdBTbGDzuEDE|U~0PiNQk~uPt|)m0wGl){0`?SGyaX1r)m7Ni8%T0k3gP1 z_#`Ol;P5u{=i}T;v-F{x_@7TE{Z;o?83fu<76gneZn z7e2j~JXFJfB>2_Z8$UIp8u7o}*LVW}U@f(uNQ3^?p9hTUV*iOA@vk;`DV$tdj|5!| zHdkSi#O3hOjw^sU{-Ig=&|MRY<$*UvJsq6!`s>jq){!gk4@ZNXSWRA!G#o?5a ze`B~ta?XLb#Qzkg`b+pxd9`QzT}0p3fG{xS%p9-krVwB7FmwO=MG1)2>x>2xXg=Ua z`rYwSCB2cWCSV>1WC{NSr|_4-;Cm+{eZ~XR9KQt@eRF5QX@>tJc4u6rbZY z;z!u=fmjoe*0;_Anbm*(KK^a&3aW?1W{aQvaX$K`<_ponMy$Kf*d5o4*sv8I`mS`=y7^NlsT- zuj-}o@`e9|uB)`|?W>891s2#|`*!02;9z*a7F#ay51@b#Rc8V0byGNh(S9On&>V#yaIgVp5D7aQoM_w{&py9IYAFUI~*ikqo(usq%j8&`9|cyi2X=M zS6~p{+8y{`Mz?qv1Hu4&w_~ydKm2W?vgdkAlLHhX7KZWwPeWUC=);~7NKr}cyE-8L zhXUFB@gVmA_s%&P0c?8E-5`w@ag-`fu&|t%BG3bzRC)j42mh&7?30?OngQ2&?wKy6P23oFB8uIqoN)hxOhup3QD4vBc?|JZV?0^ z)^1(EPuX$?zD*Y47Fb^Df-Kzl`7lUYAMc(ZpMh&46S54onLAm8=~rcdib@0hC}F;I zP{8FGr99C+B^IbQE$xoPw|k>K`$S#9CuiSO1)|?E?$1BFJ)svHT5rMFUY2<>yNGjZ zD&>pRPqj#PLCVxc4mkqSh9}C1>gC|ft^I#EbinXIzVk<&_B#4sIRtNw5FZGxqKnWw zwz!n0?_cqj+ruwu!b=0*)G&M=U3*|!DR_tk7Q^Z8!p;1YGF-@En4mELA21-k^+$R? z-9z?-4~W8ptQH89!+!`+_-E5j8evTn2CJpq3GvHA!j9O)gE@HU!|M{QO%lT)5apwW z8>&tMyTGHeY;qgkk3j@J3_I+K6TDs~(6O8f3y2VmJN{udkfoww!;HCFm=YMxrutVun8#Od)Xv!v+D4?77nKD;51T28GRS-LLy$&%=kyWh1a{4pGQZBm{kh&qMMVuApMNu2-~Yy z@DCTx_J<8#5r>XxiP_!N$Z@mUTJ19|5?iuk498VvVC{6})}|)F@CiJck{@k+YlqXKy0IY2Q zEI(GyuWnsj?%b@DqPm!sNUxPT$=^qbfYR+RA62zF#*25kk*|!Q7d0&kqE`lW61KO% zg}Cmwk+ZLOAVZ>JXYc3S&_a_8#kJGEQ{1>y1hPnV;n01NUWde9EA7I0_a`zy@QbS+q>;*;qw#tA%GUjsU#R z_+<;&+kQq&)3*52E?}@P4|bu0ZLx_)+&A{9j;DG;*#SWtL+1f5iouC%+>%J`czbn@ zFjUibhtktRbr?`XrRP?&Wh@GQzGNpS=2qZ93Ob>#ThkBV9EES*YA z4_kN@dG}Z>m$2@+96%z^bpD804iR9`yIl2MDY?w#$GSu zHH>I{2}c=0yPKV}oqH z(>S4Nza|&7^d{K?3QKRmBN$hP8s3Y&OdGq*rog%2_6jy?x{HM-%TnyqR7rV%(SW>C_SUZ?5rtTtg$qyV@FEQqxF0e13a1(3!Isc zL!Yx46^*}0+0KR>hc_~Jdd)4g#An$-&fxgzNlx#Q=2h`M*fY_!$X<}AF>m(n?n@Zj-k--R%QtRReSck9U zHq0mrTK7>>PnJrzjV5ipq4m-M5sUFw5lkeo9m5gb-3e$%!$NDfFk+<>$MDzS%C>IP zFR2ukr`G>8R-pk`T9N5EQFCRa|Di~#P%qM`43>qM0HMfyC0iq>m)&6#k&uu{*-0L3 zj^VMU!14XNoX48tsQe0)x5s#GsppK%;JC;*`cD%2%_JR2nD|UJgT6b-rAzW>Stn-T zs=EZqOZ+ER%8?}xVZ|~aS;xm$`qUm6Lul`_Vk-qIVcQRWYZB-C%jPgLi)K=g(wkFl zd*S-#1p_D2cJS(_C)940@qgU+_lf?Xm1#W*lPu(O|=x^;UCY8Q{X~mQkZYZZ7WvX{==!( zP-$Xt0$b6Nx_9ba09B7T3L-i4W|dNaqjr+@+(;(bjXXELjF~?1ro!1|VikEI)(+(e zT?JD$Zqm{_n#raS@}SKx8Ziari;e44fr0W(hEDcHi{`HJozXB0!-D7vgB7!{71B44 zkBIVFX6y}Z)hDV5-t>(7&V2WUcSBaqE~KIXzvEg*p8$W9s2ZH+ta1g?laCX_p$!>= z$jc7SofD3+wfm|L5Hsv_iKc0U_5$8hE=lT(xnwYd}>#`eP@4r zTKK}>LZ;5!=*R0fgU@w+Wv^~J)XbxxI2)y7Wq5@rblw^6Vt8C$;u6owQU^Kt^FEbL zgfc<|0UgTU&jHm!Rf`4SW}}k&a6_}V(hifpp6O@do&>WqAArInpV;3Y_!rGjk_~tJzMWGBPusP z-sc>FEKE5ZIQf)Xp$A3Lf&yYmm(CR5i$wJn! z!!J^&Wr@S8t3}}md7{Yk;!{Vcbo#9voehv&=^LJ?v7dQ2KTm zGn8F6LciU&=4uDf7z-zCml<7_l&GW`DH+;M^mvq~*~jhH*Se%9yj&y4t%zVvrDqQ- zu*sXbm1nV%^bzFS+mOUItGk^a)Q|o3u9!BvOI?O)(FXuY>&KbBM+@}+UI=EW<~b_^ z_*1wP!y+{Dw6zw^G&$i6`U{4-fetbbzeiV;`RLT*KB`p2n0>eb2~)q^ZH5|FSQtT= z4dxu0ADek(_g{4b6j-}l7;di)wB(D5mSWhQS!lJsqbRZ)K*1kF7#lSzq(%`a8*-0N zFx2pG^R9xEW?^_3?RZdokRkWvlGEsIBDKP0`bRL$rX?~;nCZj|6+vs(wFiepYlK23 z-9&@^be9ZWP~m&kI9-Gs>H(Uy^Hu&Z+Bi^C@}W6w|E8dcEbX(IT?Jag`l@z;%cUIM z1Ob0%2rei*AaiQ}%+N%ao*CY*7u9}scfIjilh$`*eguH%mMe~J-D!;9wcq_y2!mEW z@s(#(@wr>5p%uc1cNFfz$ssphki4XTQ6obB1)gj-0}#u^!nvz&j*sGgQs2E!HJ zVh&s=%!058QJTjL=a> z+%|1911qu#F zazZqQ1yI+){=g9kA7MCbMG{J%NBE|Opw}2mZy~hL^V*%qD;LcbID!wgNig8Z+lo6d7CH(--j3t9;k z-+c)5P+?~G3|3KvPsBerk< zz2vCRmsMod_NC^$dl`?6(*n6IjGuW(Qed5(JS{-{t&(qwECHUk8je*X zg)mVx*#%8vMJZ+n1)CmM!H4sA&CzdHrC5(LDhhqj?YlrVl(sZFXx9rd&U&fYBZP}2 zpR`=O+wbqvb^5tt)63S77s7(kV`_%mzgnqWFNs}1;7VUA zFf0vqZy7f}HbHO7;FyNr?6*ptL_;QD^yFjScn*`VH2Ka`X6thVP!N~M*$Hl%Lpan( zS*WWH9W|#} z2?T8w;u$$NX=^=O{hp+z;U`Q1;=x27?7go$!oCc_|88cU-kg5{@xYLx#p>er>!6RM z{28YgQ}m(X8E%KFk!1CIq?bjd8l@jG{q*lyp>=SgKTgZV&LdRyS#a!1WOl#PppWjg z?oyKhdWzOlfv6E;dl9z@cZu4Nbpx~4o|jMg$^ty?wcT2A{BZ%Q@ZHM*vBy0PtICrM zzeAuq61wn5clx)YTiMUw=?`0&wICQKA{kD8{2|w2GjQKa%^zi=!2cZZ{C?BI{A?kf%CAaeF8{Px`PBB^w0?&wK6~U)BK&^6sf}vx=pcPvO-lR zj<}qt0_$G+dS6|)*Mt5gZ*^=IT3}dgT5Q?%fj2pxZsYeDwWO_5U4|u0DWayj#rpn6 z^hqWGzvr;}0>I*`Uf#a_l9c8F;a9)26GwF1+jm>=fSl}X4c|!7;ol3kLmE|umRPHI z=~S)1K1p@4-Sy46G!Ok?!~M}FZ`Zw%Qx&Q!?j^ex(3Rg*4gF+(PvQ6Jfzx7uAv=Uk z*}mJ7GiJH0x@osJ7MA@iebV|uHEpAAx9^kPxART!#zHN3J;V1 zR|CR>ZNyD$PVzHSr;TfbLVvW5_75CqGT7e4D4kM^UeO!&8~Bb5DzOc6D;K38?Q_E; ziVTk6s*FL#Y+7~@eVIvBcX+Ne`r-YoQ`t!ji~Zd$$)&1FYiYO5v-P^bxyGJdSGC18 zn^QB@5f|vP_!s`Y=IhkR$K>+e!}69hBBNY4GOI2Pk5Ipq_VPxlGHyM8Xzr>=!NtWiSu8Cj6d7NQ@$j?rX$5PP8>d|cq>=E>V+jl9gMYn(9?Tw!Y3x* z?Wt)Z7Z;vw2_UU_)LakZ>TdFOjKzX`uW|nFg9a{Xu@Ew7Dp7vE?!ve2wdqQfK1vZl!vf0V+Ov`G z`935`Gfdn2IXg^~DGi?Tfa;U+Cq7p_>Kw`zS8)QC11%g*7F!tQn@>0^p(>wIy_*SZzIi!hQ(QAuSRxoXS|xgTWRaDB;i!7S%J@{uyF)FH!D)1tq>$o~#n^~Dxrt|4u;jyYy==_uayl*$j%2+^&Hp9zFt6oob# zk7PRC%PstSP54h=llxOPh{B=OC9VD0X;WO1N}ko4aUs~B>vjSj2|M0g#85(-nV5yD zR#NQ_E3q-1Nrz7n!(=bN`M>s~v^KEFF|doegmbg_@SanVGLED;c+C>e)0mIm`e?nW zwqA6thDERjE4LnG6%g}tP#B^Vf{{A-p0`Xw5;(%UeZQSozc5cYCd5mbp2F)FEmm<| z{^&+P%^IJPA2ZF|I!Q;!Pre_`O&g)R<6+2A#iLFuxb-a6SH`B>wtGXag)&pOHLNyV z5A)0msVrjqXCSTQTN=_8BKD=FF#p|Wiy6CCdFf-wua9KHe5N4K+|6ZjZ#jg}%i@Lp zwy554@CZo-{c)t6@^)Wh`Fv=09JHP^*|ny%HazJOb=TN#|J;dvKM(76KjrGg)@e$P z@&)^=9jZ#2y=ix!6-WQ5PdlHLXV{cagLf3YW&)q}UdtkE4Z6y_fgVDBQ1;1Sp|45SZfNW&vkKWDkRFi zy)z=PpR%=IJvix@*l$GW-}rbZ)zQ_7;Thq!T|gi$&D1M&ouPk96J!gKR!lk@GD!Owb)TUN8?8J>31{dK#+-D6VY zBB6G@+$+{%Hk3$uM<;Q8ZZ#UCAgdryEqeAP@g!~nFkEj-p8~K>lzU2|Bz;GX;cCT;U z_G5PPjS^Uq{z3*NQJm1Tlvs0WuV>w7f z0Vhl)um#}CX?_`Sj)}_9Kw5f^Cu7M}z9Ax8ipU!pm5i$C;c;qnypCce_H)X=XH;9@ z-1B=brjC~?;_|s$_A;4zqoemPx7kmU86^^T2PrSYL>W3PuxD!{yA#;tJj#aMGn1BESGOt2}eB@ zq`=T;H+Y18gE;FrZK=`#eC;yVt01=eiyG+7)h}ZGmwG8^kz;6o4#dS*gvb7N<%ukd zKDkPKjZ9J-H=1gvdKbt{BGQ-qMX9f=cW`STHvCnu&So010 zC&zg+vIpgQJL-N}SC!Ap`o^X0D1sSpC|){uk@P>f|1nJ64{R2Glt9t+^W97ms3HXS zx7W&A-TGB5c>Jp88kuC}jcp#*gK?6Y6*&zmANiF2M9R>*XCm72iefXCf<8h*U^-s* zl_ka&MaN-b4XG%9kug&@0|I$DLb*GBLd5LkZ$=_xf}CIJoyXChb1JvUPRA!_iw3ky z@{+73>bm}29y}iiWM{0=a~STvBH=JMzmxqr73wSUjDnps`OCb+*+3$D6B5guT|E(f z_?uYjcl{+KgoY*AijV}|_9!O$LgI~H8D-!X+vmf4QhUI8xn$n^ilsV4|DHXE(J)37FLsHz+}z@)k+*GXzRFLnVX%% z)L+*5V<7$cYuDSP@>43}A9yzknxYJ)ZFp;6_PEZ?Jbx!8zSyKEjaHaYR(i?msxTiQ zxIGxW&X|(rs|vUyw%L@DH_9oaTffw*1yNS~smxh;&SWXXR@^C_bFhx%_-Z|Iqt5hu&Xu>Xg>^Zsh8 ziQYZENC%}zO9)k(1f+ys1JXq)B1P#!K&jHJpwdDSr1vH&NRtgi!N+znXeov+3xU`If6s7@ zINuir0DT!5hz{+7SM$dwtMTJ^W#bx8qH#~>7r(MLHLApOvT4zOp!$L_E5@Xj;3o|z z0$rZ7Hfbq2iNUt3)Wc&n;Yn>XVmPH%`jrpTaMmj^n+xFCdRIY$ZFtt_h;ZswB7|7K zy_k@azBShe)ZL%`v?aB5pFjNcK2g+nm9mAWr@TE5A?_D_n%NqDD!x5ofTvk7ZqD7g zMcR}{d`Lr?w?!IoU;!+Lx=(TBJ-&+>;FdFjC@%IaJ5_Va3Xq z)3T$#(DOKW{{cBL_sj(^M-=#@^-slR!>#-DjS$%tya+SiJ3+Zu&awnO!7{_HlnN1}N&j{ip-f}&VIW^yy5>eAitn*!oPT0KDdJMF2paLjnpAdf{H2KNb<+hN ziFFT8Hu00xW^_itm#GY>0OTNeqrMO_56~-242*+6CybZ!WXtn)0jljSc=mi)!8GWJ z5>mO1l+-Ds#Ynn%f2Qwjhzf~tuiN*?zXS&P!{B`}Ha+Wt+c%6kNzb9m!8M-4sSG%S zM=kUAy2kQqsF@?w7u(W7DCN@0#mPE&e#PZFn|dr6hu~j@O()qV&N8_2CZwb+Znulm z+i^f{z<{v*BekBbm$zxDP3B#u9p z`eKg9VDT)q4<#C?i-~ffEEW{6Eu%!c`t`(3t?4M~%!wLbXrH0TJ4ocwNckbbJe`K;jxFbe3P zJQeJ<@7%IpUWkna;-^E80Nk_YpZ$&%Bg8I&(A2 zzQ&yRPe&=-qG&4`xoYkV7GX*lkDN9_w+qDsLA23ucpuU#YW>Kz;q~&k)Oy4fr6aI^ z&-}usU5%-5Z^b9?YRUjznoOux@0WIJGS+GH{H(_cUhP(_%U@_1$TgCki zw||!|Iuw^O(`V0$p&A~+t*6&2#NY6rW_D$_!n@w6xW{)*jQcy^UbI`v?)pGpV#erT zi0i5UT?j(#$PQHM}LD%CFpU`!hAeUx}FN@nN+G2TokYKLX{ zZLzUBz9cNmNY&!lhjrFBHi8#h>lhaUx5823h_m#ud2WH)*bSkCLrutgjd^_Of>mumf>F-B1FyXu7CdDQP&E3yXJOtkSu z)cg{mjjfq@37huY#FWN`FYn8%XyoT#7EgnJTiM(K8Hla;5E_R~7{Vs9;Z&evIR zu)tR^O;PC}-yhzVrM$r81%@HJnx&7X#~Mv`%RKj#Zq7UgxJcCOV2^t2%MOGwspI*&ayg3=*iW;j?e@# zFTV{@4XzOM8Qk5TzZ%#2XB89i zWYvlfEzt5iUDr>&UV438rn3HH0i{Khiz@ofB~;~+5rUIBxd`qh^=3-cH5>lqc}jM_ zCs=o;<6h!BInUt6>+$gcn%_zp8DHsGYJJB$$Q7Cvz$FGP=<_CS4qO9OKFi1n`UNQg zxKbz{*QfhGPl&2Oq|Epg1_zQA-sjE<0|zHcJ#*2DQ>rwFdS0*5Od5M0<|qJpQ1lt{ z1))t_-(Bda%y%l@H)W9c-3w>SQ$H~&b)EJn{N?uqlLqT#-MM;a+|2;oOJJUB|gU#RE=w+CHh%QbY##`Hv=<{$ZF`h&;3- z%Iw49(q6JW9`@nFy`Wm!)HVP5Bf|w(&=;7gHg9^;@96CGIWsopv|Q8@Xdb&r!jEhg z;TMW9$J2RrmC>x9PeJRY1xNs_tzCc?6*8`HLc314Jlme+2p6;et#m>cdE3s@Ca9XL_|W0R z>9SzyP>_(vFJ2SgYC2P=iDGGcawX+7c%0}c*$@U-TG{G#*Cazi1vXbw;eY#(EEt=v zj*!L#le|tg@TAb^^M@_3+icuVJ#F@x`WRm^MgGx8WY=>6lflh{D2GnH5T=u)ox8aj zuj+LKbwadzo;+Kqr^Z!wFCJDbp63MOnmg~7OuSEekA2OxhASm<(E_3jU4sY14$T7z1ino_q@+gxCb1}G} z?v&j<&(IgvViL~33%OgJCHSZ+oKb~28`%zGpAPO@yZs`okV#RA3v+Ydqf6XS&VNRc zqEz@)_BIvOU0OgN!al6cKgq%#tJD5CvrIMJv}b*@VG{Yl6|jjJXE-=*lx7HZCn?5Uto7 z(6EnY3%aK`QMcWXDfNJetfQ(idJoz5{Hx=d9XtO7*oTA|Kj}N8riM_K9%GQB`5BG_HM_WiU`zLjhiC7(_<^cn3KmHKTMNHaJS$b)xCg?? zxrbzeVr|F$$n$bX9%}A1tAsH;J26SZxi%LgtC$utAe+MZ@M^OM>Ozxb>TbiCxG*^Toh1_ou&)YX!0D3S9G5!AC06NI`JTM@(|Mp5tNY+IQJ{!Wj^b} zD=z!ZZ%vc5E`8(_KaZl&T_IpD|PgVNej;nb{mZ(xX{Yk%pel zMQ>CyQ=MpI6I0>w!7M7C(J;`ir!LWaBU12)85!GuD|i3SFNTr=H|DN4+y=$xIpI1X zZ(ll18!RJD{qRGg1wQu{TlrmL!5QjKTpD^rjM@|Fp>$~$*Lv1Vvjv>rVsuQ?P23dM zwE1ZxZIC5&dxXpDASUqJBhqLMmUvUTz0Fl56cQXFUhcN|$$uPLZM3E5(Pevq@6R`w zrqadM-tknHPORndg=C9zlUJ2GXHzfiMISTx@KgdsvkL~TX=2TrEATAY(gwBJBO(@+ z=M*qAa9qHtg~^1|0*~siWHTcN%=C98TY^cCOG3e~W5(fGfq_kG@XX^prd1sEn5&R< zbfnnjL4u0SsigKFFWQeQ2s5qd{!*wrB z&p0r@5V!E#{vC=(3Bu}RkY#C@y5-lzKFjy+%h?VcGb=bDCA^(GRcymG(wZ6jiCp*|J&~OroLN zI_?C+J)xCGE3hxdi%|!6`{J#^{DXPJt`ErSWn6vWh{`$5vg<~a9}zgYXSu_m{@)@- zkBYPD56lqv`fPMPTUgI>PG>GKkxPgFjHi zu>kdTFHB1}_M;CvAZR*dKrGVDcv{zq}z z{2VgZ!myq84t~S#u5Q7!lbat)Wt|N{tgH)jZxJrnXBx8Dn|2A8C@GW5=v~#IanForAcfx9PQcnshLgC&?a$j zy;IR`Z0j|$TQnf9KG|yyjJ&v6a;aM=uTv>{K-VRtX)U1}@7)g2)~blF4yTZTRXC{0Td1Bz z0{W(y_V<>YF`8J#rB1M|_@r~760%2FJDh&$lvUfX9bn6fnB;c&okf%U&24(^52yas z=SJI^i+&4$dFU}(ZlE^Sf;@y#Z)#+Kz{XJCh5Xeq(2^XELuR59L9ZM0GEgOJa}Ibk z5b_zI)da74$1lK`m|t@?;vD*8aP2$0DI&+3?stmLyPJv_MeeGNXDLzCQ=XF%!j4H( z52n|10&pfOPba>)5hf5tQ$j;+KX5Q1x%&WD1C8-l+;-y=UJKMsu8C9opn=Xjw{5|w z?^2wCyGrum+dS<kJwv*Jl<>wSIRm96B=+e7Ixac6XmdHsYNV z9s>ZbkjQ~z5!Yq+DE5HIcYmS0Csn z4WZ+OT?ff3>KVUnrQTVOmt!B&_UNzuVCjg$|5>Mwa|st=bsE+AC?B$5|768E%~p)_ z4r6|!M{w$v$42|s;A*;?R!*tXMM@<5k#*qWV2*8P=}IdU)%$LMu~?Md_}T}u@~#|{ zjrZ@X1dR~v#_Fujpcx*382jrr9b%rIP=+BRS0-G9{q?_>!tAey=z#Qg|L5hucT!mY zZ~pM{YoKdG_x{~H1+ROdB$9Jfeo}4v7c@{}?TL57kG6W9 z!UomNH7^1~&;JgO&eU0L;62N;Ndl73nB5kyY2w@Dm12@795{3z`}Axp>am>n+=u+1 zB$(D(faLy&F-R6#)(h!A-yd~5Kl!^}mL%|gdpyU09a$m!cyy|SK-)L@POjoef(R_h zy#uEY2XM4?+;(oB8us25xnZjQP9u`&FDR+k^}6n4C>A>)o6f8B>B!GD&rp~qNhA@D zANyyS=Z`B(${|&s?ATqr{M%2-GIMrJ(sKAun<_mRj(zdZ?eeh8Sf-;Aa)0$vI!Q&b zf82M~lfolkP2)sP-gL#{u_rPQPwLKOl!+|mg>Xd|Y}csolHBL~=N~6MxX?eMjWgRO zCF3RjRek(tN+~H}Jl2{)saS(H2iIjL0&$=ebY3|R48$HGb5JdunbN}JWq^^JtFcI1WvXOu7ec!;R z8tk*uZY+Qsu=6N-a7aeaGx5dQORJ^e-J6El-xsm`e56vL5BwOPNsE_HI`BmSGi=!# zhgiV|vda4J{&_t1`kf7v#2OAqO97Kj(`Kpc_t}#LFuSDRyH#P3dh3|bgz zG!pAy8D#mcv+b}PFNZiWU3US{jwt?-D;#)ddGgPii%50p=M}S1s{g<}SnKL^?_V*R zc}m%~Tm76qAgb*)rY^nj3#VEzSz86xFC>$>T!-L=swl@f=hEzMX64nGyZO4Jg#H+W z1)=|L9I4wMiO7Ers=JFKEQHm{EVK7v&q(SS=6V*}o2=WS)tw}Qn?~4`X6E!+yuG!A zG#wV`KE3gibmDFGGZfAVl9EdpqieULB%fkZT0)WLBkTFK!oW|3;uZSAf$Z*r`SP5F zZ%QB5#PPk1J#!TE(Str5BoBSQWngJel9at<7T`$cg3vivt=cK677Aql^;yLjybe!%B zar#HqjZIIoY3G-U*YZ%5k=2(zjiW3JwPXbid|S8PWss?BY2gMgj#v7YIGQ|_qVi-& zr+G$;hi=Y|`j6TG&h<3Nh4;N7p?>j0`>`UKaOp9_3br0mAg{PqPD4iUCATfB^RN@|5W?~?^RS)FPZ z%d{^)u2dNCi%DPiRLBZWrHA2ZF>u|sz`6pCdr@c6+4AnV{JpL)qe*b>vwIYB;MJ1? z?Nnz+NB8NJFSiOr6yqibXe80iCjI{6`}?_LRaL&5RfPt3QZGlKi*QpVu=aPP>(Cdw z>LbyJb*l+De+RtiY+crG-D?yPfPw4;)%Dqp`SSOGp9Z)E%i7(wFM2y2u+QSDw9am# zcN#h8NEswWq3i|{YnIEw(3#CRo#hz;{qSgeqVdlt5@Ve!l@E&0@IFHoxG%mJ3b^hm{6o<WrkXrxAvXkXs@)sPxU3HZ~lh1 zGVplY!>WCdH@r?4cf4`Ji@^=xmn=^=pK@T$Kke8=DgFX`j{9;Z!ZRAKj%#YlDtdp= z?6*Xx@)fjUx|ITLFA(W`nC)LznAGzYD}uw0->6guw*(_E@RzWP3b_>RnAUjIH>bq$ zcNBc2#6-nx3M!T#;%$I$*yT1?c1|3|Ee|)wfV|n8p6&7FOq65=$%+9;L6XZAqUWit zlFYo;5_d<-MTxwpu7@+$FyH+U!%rzTc#5zI-3sE+%8U)$#AZ`gy1n^{W{uU1tCiY> zg~Gus-@Ql!Y~KfyEfNtyXY~0$Y^vaxkwG8Xx4d4-PaE?fZvuDxP~Q;~TLsJac<2%^$gTbIsEvG651w;xs8X<(-^dbNr{+A;#2>1wGZ<4ic5=M zLdb4%hoX4<4!KZu(Jiloo1KhMJE5Vxvo(GMy}UHd%M(oXPWllj#MMlUD>UN}2%zkFUm3`NWFx@g>zA@s5XYfI22^$~34X#%0?d)-mAHBth?2edh=SND$ zMsiq{294~z7eYR8&iQVaJf9T8Lz|xGj2u!ILE*PzEQZnHS!fQ4w(dEhStayaUx}aD_neT;u+EeYdQQSW+B>acIq!^3^u!Od*N(N0am>-P_s%4h*Ff1ycto=8rQSDF+A zM#_ zh10PY%jB0_!Tq96<7jO<9)7+36FoP0Fv9A}WO6=LBSkY+qur~1EB9k2Lyt3b%jlxA zF6FsXSL9OtZh1)H>#b9#!(U2gyoQFtoLr=clblpVF&}l3E?y;!9KOq)-k-om@oTMg zoBnUXB!zpd)UW?4xvSHJ4Rgpcte(uoJ;CWCsGjmMfKn!$LYH_aVgqv1*nqLrpt(AH z1Z%8&>BztrbBD_M!Kd-ltF@_7&uKQRe{r%Rp{H*L)t`ffJ-+ft7EG9n)%ZXx@r+bv zwi`_(N!(a~vumw%Af_;PH;4|~&xTZmd~t_^q9>oFky2E(2OFLv8O`Zty8P$SB$w?E zW|lnpFd5kP%%l8DT{`wpAb82Xr_wg`h%Gxb@zGMfl@LS4oJO?DIq=0*N>ywhxc^?hl|AoJ8OEffrdq36uCc0aJ#e{D9Ha-5k(Fi{;lL@ zC3s|5jWH=L-3EEi{*|FSi6f=s2`|;(=Qf>~p5kuUdFv|H%ItG&c{v>lRk)vBvnY=o zxzEes+VU}Z8O~93T~DvHTx$OT%_NFHw)SkqpeLLD`TeMMk$~+6rc<-ddIvT0@1}dY z7-Ow5YayrgIT{RfRo5fisu(LFOHRKK+bN+`#Bse&tb>Hj{Z&P~(h~IZ^4;dF*M_MS zcGk?_7W_Qejk2=2EG$HPi!9~y$=fa4-F*Dp;>=TQ-H#dJDGRw3d04R$AG_boA2iE} zoYa&MJY$vWf_8a|X{0QT%BV7%ZAuIy?3xI3c7bK%s9$yjnRcb`{tsF<8$ z);KVVc%yLgqiyFX&SN20Z6;ZL|5u=yeU>y&^Jf&>cJ*XUazsHa8)RL@E2Aqc>Ef8; zLO2?HXo9%^6PB0BrRudAdw%}O2Z?Ym*Q{1`)oFhOv^vtOUW0;gEEn^n6N@*5NZ}P~ z5IxEA7mCS9BQN^P$ODX;$~CenQma_5t~Yh(lEh<=f*DhX-cR_kTc z7$X6UX_RTf7LCT>MTFML&A&EMluYXDKXlCBzU~u=j=av3__0&W=gy{>`s5zJk9E&X zdK8=^dOHBIMC4csY5x(}_ z^M>B-iFe#Le^Hp-qVVOvLlAO>eB#!3T_QKjIpA>ie{Wi=iye(AsCb0r)fN}~*!+ju zEV0?jV*&)}A{&;BKD20~Yb5hx8-p{PCUl3zQeykF?M}-tk^Lj5jw-Az&yHL<`9;KT zn3)U@(PvsVoA4DiZTjelqTzxYw>)(Ng)Te~o_*537BMLiP(3~cIn8WuyJt3WFG-RP zB{$kfFKi-a?DxcA*eM7thwIN@r!C5uu=Wr<+@|KI-e~yf;`KnQb6<=FMhX{i#x=dC z?U^0OFhbnjfd0f}r=6KC(p|RL01S2)gFJP`KJ)|zj}K51t3dZ@N`PCY1>OzSJtNQ< zJ)3zOD)^Qez>FEo*+dg&@Do86$!}=hrCr381OIy`?vQS)#i1Y3GLfHx*Q4Ux1U}oI z>!lzgXR<@BRc}A_z$1(sagNW@yV*p;V(G+_6koUCW1Y1+1MAfvj?1+06rqbv4aE>u zok@bFXK+~Z_zbYGIiMx6$b;)S&QauyFo?^p2TrxR$ z^;fym^e60?7}0cPi6EhkTOa>jwZeoo*d2M@jR`|B>`Y%}057J*Xc39(o*}UTCm_8z z{ykk{nP_>5MJ`})xl|w`SAn!Y>(xJlpDUf`>FlCe6|V77IXrG;0}vf8=4fgFsQoT7_XQ0bE?THNLj$~B`p!G1c}{Edn46>GSqIX~DQ00fc!Yy9Ppjs&{Lo)GBL;dWkC`#rFZG2@C^ARl{1f1UMK(F~-eE*bqZ9Nsh0`#VRLKHgO6bV?j?&CHTPT!NK&?>1K znOKND;xJ^vI_2+0ATpQ~Xw?%bdgBG)AHi_xysYLXp4+(J_2sfNzCA=9H%6Vg)OW{m zXM|`U{XER7l?cfB8W~yo6C*wC`*C_tLn>eQPVw}vgqRlT%n`(*Y>H7OirF^dEY{V8 zm}1W#?wa%jfV(k%Kr3j2cBDlGFkBpG*mn4?0j{yo1iP}4-A;_zlSHzv)(@Gwa)J+w z=93=bnw4X^b$qv+`f8XXXjBgLcWUHePS+z}f|=gJsezl+p;Kk3=~ujQp2#e9g~d6) z;wafL1K1Mz^_2+yQWRk2CQJJ82QYQnC5$vxM!5wG})7M?#&UaR|X4#0@$jo z`^SnSbR;}=oDBhxc5QG;Rb-+29T*3Zf|N@K*&;!v{3*sB!sh<|nsz*HO7^;nrl&@_ zT4Ls5)s9&9+}-m}>Q5|6xey8@LPWw3uqhf%T%4l@|M+`tB8;sG-rmXJ9+ZN15u>m> zr8VMaoM$hZazyKasi?G-{}TXDIWE)j>}J&P)x^6MO{qdw!>|vIrE9^ z&bg-kpd!)vjW~8NzBT@q|7-G(ia_r(&rqn=u%io^ez*bPr;I#5r(sxP^k3CzQ{PLX zW=~lXwsMT(mne|IsVd6HPGeJI8=kvAT2oNMM~CE17z3CtrayIBXS9bA&5p`Ve5~`Z z<^mW|Iy)S%-L<)qI7c{nURFitA+7%!Z+6Co(uHR<%KUHrTE#+#Hr2YD)#j#<2RIu# zJz1Z;F{68S=+TFki>XQ3oSSQPq|=!bW-+Dv*=mlX?7DA8DZbUAS;XGbz9`>6v0#K~ zDNt9`X335h*y}S@N9~-O;r{dxA*AUf1`IM5Q2q^DG}v4!mT2XHu~@ z%LQF{Wx@XQQ&PTKIucHEy2lb2GgjcQ8yK<6Zr-RHI`trQ`cSldIos@%ts4nac8HIM>btk5n(sJN!qYKSA%dco0cMh^wrdi zDU(DWnjfj~paLMtd91*LriD*9mw>)&8{3 zk>ty^cn-InbT~b$4m4;C!o1S4vP-aqKKcH*bk)Q@>!VrLC*ycEYUdpG%xEed5|C^L zqXL5d*n-g{&OO7Sj~19Ed-0U!W_;|~Tm7Qe0GyGM9_sof0OM-O4&Jv80k0ZkD(R_A z&}eH+_9OKMrjeYqplMmyox+3is?V_FE zCVDIi^ro5d(YJfzo_Up~m8_Qd-oCUOS$#f8n$RZ)J%HTkYxjiB@kph(Enm82V6;Yu zrV|U2gt0x5pNe{Xu+O~E8NsZ>8AY3|LR5*0{6cTX$aseYm|^0jW@7t(98dkNHZw}{ z+w~JoG+FRNv@Gwyr09TI)jS$U+?S53waFJty}txU?uSzYK^xUF?eunpZ~J#x%eE`4 z1TH7JMw3deX;!G?K7%urRDBkOI)E-dI{Z2lgZc;r{L$I;boK6bI-G}3@tFk6-`Gxt zKO0wK?e zEuWdi>;9*#{HSQkIj7>8sP+p?!kCAdK4zS!SoBFC6xycjjzFLSAQ18|r8))p!j`h5 zjHp0ctnzgoPP2!Lqv+0CCM#6_%E>Dsj86r3&Do5+d)yp{KA5(=MQ`5OPGz*uP2R{ZG2=q$cSeAHUD z4+SW&0;lmt^Df!a}q}HEk`lRp9{lpGC)z>E%=8_Z#P?yt&FvDCyE7}VVd+$Hj7oTq4R~lkm zaCUn?PHIlJE>DZZ3gSmH8zMd3UpbMODS4WSk7ES~Ij=+%Z{X^TdvHY>s#+6rFUEa! zte3IXYWo0^0!a~g&=qV|P0|hoy^JNaEVmy)5ko9k=qK0m?u%KL{DcpEQ6d@KM*ll3 z#w}PgC$RiO`*~-fUjh8ke-5JIg_nw;F=(r$%WG;=ogqt-=+9LezDdGs&8dPeV&%Qq zi>n8xJDy_-A2%Fs7v`AV?;W=*^<5?7N`%wD$d=_R_kaF%9AKEFpR@0onKHl*9^gzG znYt@@`qWR&fb1uB8@#k7t2SbO{8I2;@IOY z;R!aWX3#;Xx!lg|!O>R!`Oh0u%7rfF3Jfac{-(vsgQAi^RGJ{2oEFSLBbtG-oLV=4?ZmSW;22_?OrJ%kY4pVh`c&aSRwgDV23 zAIn*o$uXKf<0|4AW*zuRnhSC4MezQaS|$%rtuL)5UxmL!`}DMOfjjNIpmHFUE?L!} zP1IqKgsw7!nNcaL*}DpZ`&3`z%m;Ekh?E}fw-ZcwaTbW=19Yeoed>x3JUeM+<}s;} z8nsCiIz7s#F_1s+s4t#xx&)HRVQS?M>{RdC#7M;4JH{;`ERw3tAadIuic2ZMf5caH zxAOU)C>h$&e$<^>Gg$1Xl(>2cbe;O?qpea<^kFyKiHMQ+DNh9m1-=rXtrX$@+pXA~ zO_}XbW1ul*WPqUym@j9BY_%I{jNMLvWyG4LF^4Bc=@n!o?c*x(`&v!bj{MC{LWXh9 z19{Ats-D2F1aKhKfg(8*PHry}Pv@JjXBB$FjsHCLPTHn8mX1e-s*aK3PX`@gM!SH; z;u|CTMy!?*a{;Jic*YHfR~l~$X*VlK2*X#>!znGB9Ot8=WzcUJcsu25=hLFdv6S0< zMaJD{x;4XEfl_odE=PRBY|KDD;KnzJ*g$3QfS$ZoPC7n{=4WB-uM}$PLZ%uZ3d_1E zZ>b^4AO;|n`0Ap5xZK}Q{nfS17YP+bmStTVzCOfCzXk}bG^{sUaysOvE6fN7-S!I> zrJz|4xno3HDFM>Px(;AU-hob(~^x1}b3&hA@%m8u3^5x-z9F?bE# zNtbpnvX(DSSerNWuGO%Cy9|UH$*QfiaA#W@VqW>KIH8B$7Ea|4P=%NbBW8PwNzz_{ zQbCNZmg#Ugj2J)hjtS0FocptNn!$6I?pBMe-M%W_BPxMF6ZMOU6;Ix1 zYWH3nNARno$L2n9n%*>jwPYx?CM8sd?s-lk3byHHPCU0qp!Fdqo^*oqGR=^znwE8t zY12LEq^J3|A=kRGHP&q9lv<;Lf6Umt2uH52Z&J{PJVNNIP?u1iLj9}a9}gD?eKv*- z^~M2+pYn1&W@E>n9yi(@@yC9c+|*Co#A^_DuOiaG?@7rbT7Q5%#H8=!RW&SAR(0+> z+U)@Cmh4kJHeaS_VbfFvLLaL5)Tq3ulsQig^K>GaSo`kv?6(|h^}>3*;{?~!5C3j* z4(aqnn6u!Dx^-(7J_L@KMV@?U)bwAopM4v3OdE7;f>1awN;WyY0j{#56OGnlX>qb* zY3cHxrgXE^IF_UgKQ20YKubhg&)S?ung=*f7DN_xFPr{tQXfS9yk5P-#v zkjru}YBD~|)4LOY&8{n34qGh+h&=V86i(U&dK_uDV+U6Dm|&`~c+Jufq;T((VWPtN z+-_oVXG2|$snf{M^2xW+i`IuB1hb3N8|&=;#loBuFlUF77B*k( zfJRc^q_{;cSQ{TK=HZr`6!xX1ETdg9c9RDG?7vN8PMO1$)jZuAu1$9SCoyCATIP2j zD)p^5OHGh6Ona(brMyV)qPqT^PvLD`=c3OHzT-@B%&I~)q)-i|wprzFxWARaWL=3r zs>FSx-U@U@_aV}H!c#S@hS72YZclaLE)qw@L={g#A-9c`QJ?gEgfRnPa_(@`+bF`m zITipy!sQ=YfDDK1d~KtM#CeKkkp>iyOXV=%v<$S2W&n_3UY^ewhi%EaGiMvG z;=&J)bEVjv2d))arSH8yPuit=H15oc(6<{&laBM3DDe=A?irTn)q>3g8nw5VJL##Y80fL5A$+zhXwPBS<)VNec!Bj zi#!j|)i~>3KrFg!VuMtszD`VB_gjB8Y6I_|W_sZcWWI+1@_IaUkv%9N44f z%G2K|zy$JEb!SHpTq_+A$%P|XSFd~7W?uTt49ah^2QM*g)-LO^%%nRi1XVGeW-vt8 zjzw6V@ZrUkC}!p;FH0tfJq2c;`;O$7S;4ctKr61i{XvXV(V*a$^sA?fZM_nN7)MXq zW3LsG9fIa^j$qL}v;Ck3!eK?3ylEtg%g{-%G!?rM(rZhk;XhO*YDS-GUr>@V8o}xN z`ypGZ=PjMY`j6PjVjbU2)7;A4;yooFeZl%qgz^qz8P|;6#i733D{TBft$T1octAJf=o7~P>LknB#Y7?gV|p4m9h`Ys)(M{wurCG0IhwW5 zPl6po@vl@^julje(~|!N3_;2fevR-P|2hUcy`rz=+SyZng$Yg1gg4pJ+yLTY9hTV)=ge*M$ymGkI*CGj*%medR+nAxUqAmjd~Zo?{Cd} zHw`qEtxY1{O81}IXe$&dxkSgKD1)x`+&`)|FLlYNITAJ|R$m~> z?9x%XL0*3zBS_h*Fmr?6ER7;d&PmC5NXvGsecobJidw`*@t(^js@J@;_m7B}hBcYE zvc3)mwi7kU@F?S1C?k~{h&HU=hsJLvt6PUfr!Rl~br`hozbn?G<(YQWjoy?;r)G-y z;%X_{xNvn2e{6V5UmJ_UBFIN0P>)|tuqo)&E431eUdq)oLb? z^cQ#u)!g7k+60rXg{N8zSpA(%uI&XMUI49EP*#V6+F)jpVPnyQWY33>pIb*FyMh%cJ3CWJuS zT+{2)F7kqe6?Tn}VY_a%Zcr+< zCfAv58*%$dm6cALl+oUg56qeFb7(NPcERenxLrWP?J0f(Nok4C1|_~MGzO>Av~tEi z!vFq}Tctg46zp1Y>EAY@=SbRKVd#H)7*@_5>`mwKOe0&(*>jKI-{MIz;7@SE0 z%_4+>yi_UO4hAPA z(~meizb+T4kSXDKy%yJxE|!j$bsz3bB_7b{2u`i*J+Vt9SD<8};b(e-KZeUkmZ(3W znU$J07G^h=&4e>iK%)M%jdx@HV={E9oa63{vVS}~`f=52ng(YPaJ_NHN>e{gBQzf? zNh`uW_!@PGnAe$OIJRu%_H6#ALS%{d=lSrrlo_NemM-l2KSZvckerU84yld*!{~w% z{{M&U|NkP8ZixSOLnZ#p*YOfJ@5R{aC(SF$RNn7I@eN3(|V#OD559i z`C5jAETeaCLvUyPhGXG_;;6MMaKKT2|viJ~*nkWH!dSQ$6VB%Y!Tl34PWl}C2LcT))# zldw0$KX%J43Xvd@+Aa%GUE8C~i@O%VE3jkY#ezqXgDnN#N_+_^uty|wem4?MEVEG*;usqI@TyHnyo1D^A}`Ojoq`vG+W)aW_q!IohKrEB zXFK+08hEV#;RBo8*XAY>x}j_tqq2S7e4*Hk^?dJ7b(FVssnus4Y54;!{?*YQ{qp=5 zG0G0SM{>-!QHi(;Uy&Ua2-xZ#D66xo$ssYm*6U;PlaID4P3#FC8wgNe$2zBd zj$}i3)1S!KL(jfyarN+SIsYivYE4#Y_SQrl*<40XENo=YMzud&aQ_2Ca+s@49giY( zjfT|^byV=gq*2A1m4}MTmx&WWE*l5){!4dQ-l`6pOUgTjEhkR)!E{cEXGm~GK2N>Nc;(b`qJgd(U} zV(-!7S0kljkJjFsS}|KA_THhy3?WFY@aFw9f8@%QBzxcZwD!IV<$x4V>ZeTtX=UK1=z98(et zfvaKEFx|i_Hk8ZFQ6qNXj=ZTnVMjM*H1I;=6w{}GJHn191lC`jsVh`&pkrcm+q%{s zZ&E?YK9HJbh;82!C6d3f6>dRgf!h(>rD2boMK$&8RgXDAH)`jD#_)F{%^Qiq_7>0- zx8U^*lqTrvUGsMLx53Q$WlZuxnti4z{4xszLssD~fns9sUl9cbD7TPH^Yej(kas$A zs42J`J7BJ7SQ`1^{~`q}OuD1s%3~S?Gk1j-U}g_;|Nr@#=lxL%?^f_D_~biACO;Q4 zoxbRn>&fH3CnQ$|qsmZj=7frwW{x@j>P44Ui*uqf!IF0M|2dCfj#oo7d?lFR+19dQ z{?IzGs7wS!^emCc|6cy^n&}5QP9b*$N_Kg1oOAPG1vk@_EIDgZH{vUz1C^7N?wlAN z*&#@GoDXBym_mM|J8rJMyrsUId|37DOW+2%Hltv`Z*9LjEr)~UULCAS(@S9!>@z>B znG&TDgO54g-cirND0s+dx^|;1%vTy#98rM1Y%}@#fizS^)?UYqLPeY7a1xMo*Us+5 zB5Bu|A=49ahE40pGJw|-&h3U&z9m4jypGpG{lQIXaxPy31+qDcIA?dJE{<7K;(HtE?H}kD2Y**Z9d97!=eZ(Xpq}w{$ehQN$SeW)yDHxW{aMq3aD=UhA%PjIu(u-J|A&Nzu#l zvQ5zr1>+`jl8JJW;w8wW_G!!7SI%8wHHK?A?i71I8UjzO%@oSuCaZI*gcnjli?Y3U zIo1AnX^;&NLl-UOL<UG5|b>o})Z-E+v&q#L| z+GmBULLxe|&hqm@e>{&G`0Mok#^a5C1`<<>MLT>h-qmCCd zjqMBlhd*?^ZqhBTJs<7Je%C1D;@l2uu7ux|cwkeb5ckzBK`YC=4Kzs{^#a*`S*z3b zO|OdD>_b1n}^+ z*vfX|_AMQWU4B8l*7-TKFGWsjEh&L3c{VBnLLPN{;3^VQJ_9mRr+C+4|2=N8nWIhP z;LJPWEbma7WwGFQA=@4e+?0@f*|v_8y$F62+KG;x92In`XfTr5-Og(fF%bXcu)*qR zIs@(_gC*}bVS-Tz(1pi5(Yu4NN$_SmB(7a6wIjg2AOBadc!9n58)ilDE1qu1s^cE5 z-kh70E#Nnsfq$A3$lnwnd3f&kCy+)>>c~}K7GF~6YD; z*b_)g7Q8cD_JmW?e(*dX%{Np$%>dmL2IA^+D)J?%l%aP1Z1p^WIdW=Wknd9lrDty!>$TbUKwiN07 z65Akr_+yV-gnQZfeU_=&=TXf)=UEYZq<(hrPkiVv(@IfD^kS5>OkJkm`W4{;AAW9f z62Y~#Y`8MbYX`3CzbKG@$2l5YoHQy#7=oUL6YOEn(qk5z4MygJT$1kHFYI{H>g+Kw zj0)n+wrrFOLWd&Wvw7C|(#&737+<%HfDn@g*$0<)!T*n-d8r2d;Vw5BQNUd7+Okv7 zT`cKU7a!l#8uJ+(D4NnaMAwZTT!MH0hWC$205>s#Te7GCrm+=Ua-tZHH|Fn zO^KTU{fBD$-g7J>pYPBAkzSw5i9hAD?TP7{YgP*Lx^B^tA!f=h;ifG$x!&-HHlIh{ zia6G;s$aurk$Z{=Vi0Nuzkj%1u5UkW_(CAd_Z?EYDk3&La>!%n``?q4%^&R|zsp7r z=b}!~-y+N_L$WI3?BPP<1R6+9p%@3(+J(*lcbi| z?@5xZ&ZhCG_w9^#nW48x_J4{mjw9iGhppo%;^v{_f#=C{R55?T6JoLIEa$R6uFjRk zzIx%rk;}7O0tVxlL-`pMemw}eI`X>vI5+-4_Ix90Pl;yY6;TNGWWae;D^&mB64TK^ zWdU=z<)85KU$h6|bas^eD*codT}CWcFJ`g(ZXvBy>tF;le)CJQDdStC;TIH}j1GF1 z=kWm!9~chv4z{uaER#Xf%>zhu?8!93tD9C}>X938J;SxFmv_SsR3c&aiUe*Q@=NqAb1Gs$} zZs^KZaZ#|RspEA2t#^7x*L2pTv&ED6?yn6}nqrQ>5!xBb`smf5EsF8HS+9lsVFG7f8h1{^dOEl_Zl zN_`IOdkn-+<`e>crw}p&M)tfbU(F^o2g`?}<$?KduqOjgDs987GRJk1i+ovn_38arP4x(3K>RdtjgD_Ed#qbut?40_9gXF*4*Lqf>>t3^# z#zAT!)JJ1Bjgq*Tng#$lWUW#bQ(F6Sw>WS2HG96g`a) zE9>D2DfWeDGf@^b-Q&^!HP~m;UZ88xekv{RQ^q74%9|PS3h9SCPi~IxQ|?}r z!j<)MUa9B==rYckd@x`O;A14`+?XK_acXjnmhX|ckkicB->bi+#0_2~&v|=o9}lc6 zuN{0couEM6;Y=T#@z(OHldG(Ab0a{%1ZMHbwQSnS!5wKivlO(Pbe)O_^{hoo*+Eb#v14O1rBSxy!{^a_ z|IPsnBfPZDr$=ItHxH8|T{f+j^j;`H`W-4?Ufg%9r6}1e9{y8n*Iey)b>z^sRFn^o z?V=A&S|gfj|MNhW?vFg+{%1Y)%1X8>fU&Ee?@3c5u1|_~16!38Ylt`e$^U;PX|t^5eX!QTxUnFnA)H`R72`ZhW3LEB)dhkw`L@6$Jm$+rnUS?ajm6D*t)+W5^JSJNA zeia@!j|Nu;;v{k^X1Q<9`ram{XO%!$@`)~{CXt`xntyQq=;`Riz9#76U2+oe{HfJ#_nY06j#4Ka%U{b#4rs=rTWh;>)3W!e>1lG6 z$0Ae1mSw<<4n)lNx_Jvx*@`x^TUGoIhy6jEugc0B7e0AO>8}d+pYOW`o6ivTT2S$_ zo1j~oK7myL$inA|>=uw0$}pLN-}CdmuZ@Oo7i08K!l8t1b%oLv*Bd6~v-8m)_{@;X;H{92UQP@4ujlbpje7JYv@iLg!x;hfQ;8a<`KRSr-Brr{3 zh^wre-09AfZ`@U9>%cNiSYT+7E?A1ep&lPflFmeZ_gZ(j*y_p~VZ$H9Nxcl>drYL}IAwJ&|-; zA^1h4;j%+`CJV_^hEL5vse{#req~G_pB^UUPJxsJvsQ?;Oj*J@w{Q#!fu25TBZN4Y z7>g}(Q(VCo)5oPWrs)^Rui_Pvp;X+j&P$qm8Rp@uS9F8BAtvoG6+$w?4mW-M7sspK zPXs)R-@EOYVtUDAVB4!hO~&m8wZdEYRQajNVza=ofr`X*HTf$!*3yp4f9!A*1ZR-) zq>pRk4mjC)tj2RhuBg0W)65RjfwAx!N<=3Fo!Af;t!s~Ibs!&8FOiuQ`Z1>C#m>}h z$Hi&wI-~3iG9!3N;)=}zyuQ|Ffwts;_Jn~6%xnZ+IsaK%q~Z0k%Z;f^R8Y`q8K|P; zOU_uIle9Nqhj;6I-u(Vpy3`4$#yTeC=CTyQ_+p~(dwV@vNFucJhJ%KGR`R-5P41Cl z>o&gsE5^OU0E}8ZNg3?KpCpzTI4+6<=$RkZDdA*-J|5S7THRGu>}$qH5y|m-gt4U2 z+4*!`7ccK+&M(fXUjD?13U&woyZnRyY_GI%MV&1=f3I)4ARN3aUp=0N%FD<$%(>hk z(!};he!Hv)eQA&8(dUJ9$ZnUW#N(Btc&_rGlT@=p5Ww9|Zev5+Df7LQ?9L{3%wW`~e=rYK zXP#p)^YULyd?rSuLt!Hw`(0bj@Vvc!GI)>MN5zGXy80!>39pa?Tf<8(gyQ_bQSCe6Mym>_(3dcBk;=eh5{IlMIn_ zm#ZoEGUq}_^v{B9Zp_kU$F=*1^CAT<5aKLeN4DLw-C*yh2xIFT$p0rtr>X{TfFtCe zMLRGni}B*?bLk&((hI-i-LpP#avamdt}3|;h}AO{F0xDkUqrq>6t>#Gn@QI9G*syT zj$g)1+u$je)n>ewCT2PA$5D_b>uBi}+k6)5COT>@bz@aKH|+lYVdV#8g)*1C5I0sI zlMVZsYsym*yEXN zZfbEXK70fdjBk|`bjg;|aEs04Q&V%J92;!hZ(N)iF&pE|73hy8d1DbfLQOG2bB9dl zFA>Nb2X$+l2RE!sy(88w%@K6i+@k2m*iW1PeV_p19BCCPo(-0}v{Du<=U$NkfV^k) zzYeNayDfnmW95pR#Gt=aBA`*JZ+Pfah=hcaYe5GwWTM4DHMjS8wLs!1Xc>YWx4u}6 z3rrG~z1!BD08gb9`(|aO7FzI1-`1HVD?J z_}xM?C(EtAOU+_GlOV|1(d$NeriQOL>$!s%HM%gJrgQmx{7l+7{M&XSlLAZL^}-j? z+|iuNp{+qo@Nw%dbyeH$ATSRhe}moXnCf~0T*|iQq~A^ri*^fK=k%(6rV+w+6<+{G zUi@wPw7A1Lq?LY$KLK=g#eT|sU&?=P&U{ZUI#R`cs9Q?6TQ$C)gvF|AKNkcLZceI9 z+YAfsbTrmXKji=m>^uaGf~V-ZoCg1LIgqxBuDR!TPmJcqrJ0#636=XHaP2&{7Dqf5c& zr`9^sGt`2hDmWX|<{S$6)#C=7Q(3&9nUA>=Wwo1$4LlDB`cSuAFF{b7u!(_)I>>sB z6YZb2^lRcKJtA`eWd2e#XKi#ZcJYEOSmh=+aAR$04;0nS3(Yj$3ld)|!qY zc9okKMv&^KQ=b0`PDu&5M8u%>vsO7vSRlH-n}2jd<}GHlKSFG{pJP`l39_m*HJ9@% zd3*F}co_@dOE$axyh>5bE=5a+{9)<+Kb6LHK-+#XJLqAHbt} zrAB&+jwST?ueX4$Ph5@>Em`;4TyWi!2#b%>xSA3>R8Up%D6sdTPU4F^Z1TRSU=HTT z@aN0rv4@7>sFsq=tgPJYtxHSGfz6RE$iEs6&zOJiY_%}}*1BoOCL`rYldPa)8&tMH z_WlohGnv)IfI;>qxsJe)Ce7VzMV=~8*Pu35Yt?P`Eb)*$mBf96#Rh|eZwIM*F+co) zLC47Ji5MZ-{E^tjRO+Jl9FE9Q?~T)a3sI4$`$2w>?CrgxbfK(C+^e_2Wf}S&t{Q2oQpzx?Wl1f@|=lFRdudtOsg#R4C}o$ z>mif+S8I07cnJirnS7nqk~QAE)%dn$xCPkOL!Uj2Vd)m>j0^=(Lp0p8h2{iU%?q6ll(2-oYNd%PZP zCGj*IvQaJb@#bGF`(EwKF4(Go+5!up&Kvmep6RU2Uawd1bz+j$s1g&9%Y!}@H9^Wp z7X|R2;{+<9X2NK5DN}iLyP%;+8f)gT&=X1~a36H@8t5U`@-({UM{zS8>6!8&@Xa|< zEeaz4(dF$=MlQ|{m6;ZUC4aB5G84F;hZG$NtQj2AKPM|C26eXrqAbhXHy4f@622Rg zw#B!}w9?W@vNm7)@6B4VGjm8Ng6*yzr@AB(W6)DV5kc=GET~ktXynLvd?!Z~UDYCo zi|_X{4>b{ifdqQK-a672em(ZK2`17jP0rYti3z8A+I9|RzJ%obE2mqkE)JG*%d+DFRW$=_ z=B|HJ#UUP+ieq=ElRV5!^NOGb3onTOlvdkQ=@9O+*^*unyLo5Wk@1G#oD?r%u(bed zx%Hcc;aabAet3j3mCEQ$rOWGe-_;xiU)j=?PJnwv6(6Xs>jdab^+v1}AAup{!iX?p zDuHfex_8vIr>R)=3P(y0@V-+*_{*eIl^C=Z%1vuj7p_NL~wRqN$)tvWRV7CmF@ zp)7}P8+|@U2K#Mih7w-_DP-s=TsnO?qLp3K6=~)Kn(x<>xqYisemK!ZC}=C(Y9y;RjF6@oN1J*g*}lFH!N?-ZR5fm< zBOxsAbzbNk0xsO!SUU1G_i+^R<|nHUhKi0rvENPoNgCdq1ttVlG?XIOLKd$Z7`%7{ z@}n!bAgfAVByY2c#xrN5M2$(bNRY#z`h73V=P1?mFGc1&ZkjJu3UzhTL(YAY9iu{x z(z1^(6pyT)K#fJ&E!sE|r;=kt!>p!Zn?7p?*(J{P}2TiTn-7qybp7!j<`WLwv3 z9FLzxweGAHArkN{<$>iYGdkPM)uH%TGD6I>b8t4TCVORc2bS?gok^&eD!isi^YFS) zrAml<$@cyH#Yqk7Xhb4{lGWp6n#oAK`X5)y43;|E7p(T^7TDPPo>xHH3hL3kQt1%$ zJaXnQKj7oJj(1K0bnMycj z0NcJSDs;1#(3fl|yRkW=ZkbY4kVL%D)08Xa)~|D8f)-Zo`&wu5HuToe`Z0Ou&jcD2+K%UvC5O4tC3wkJdJxhZdN%Ds)I)m(gF?&aC#C@;1;XAdDGQ7rG2% ze><9Z&;NF93`Z?@@J*E0j-j+eh(rY=r{l=s37H&wJ}=1zn<}4`QD}DRvb)urq&5=e zueVJtAL)(%z2;8ANo3eNGIA^^aL|ZLzgNbsk~7WBvW{7k3uz1HtR{#MkLjN)Cpgd) zJ`3BY3h*An(rhm=DNV(?3Ze>&hM6c}N6Y+TYMYfl|*_TB`Y+6@=2 zOq{zy&J{xBe~t*I?KmF1xv!%vb^0acNt#+Z>1H%bgX_jy#uWqzB--X#$PTtw{ z>_m@E%LDf?O|x_%iz=1ho>x~9tKcys*|XiZE#9y8aOb$-??vl`?*v0!>n~Q9-~X}& z5JgSEBKXK^Y!G)P=!l%Wdu=@k*g}EQP!ww?7wk> zX|D1!*XQ@~K3)hDhK#5w@TV%PHY_%E9Pi)1onczb(e8>ps4puJS94$?v;7ms!_)tZ zj*KjCa`|vog=t3rljgLr`GbC$v;x-1mu)?rp}9uwHZRNw+bO0pO2)EhN3z{(ayA&> zKG|J#{V2CDl6n`UNxoCau_@JM!mM1V1?x;7K8`PcX(cdtHCW~evUFAJ!sg+wi-@4n zy^M>+NW19pQZUJ%sA|VrH<33k$_pC6Uk)W4#gkSv^O?q*Q}eX`VM9DsD$CR~YNzM) z6hoa!C*S{VmMdiB7Lf(!=jb$9=XP}}CqW$pG$A|J-_-%8b}I~E^jx~)ODf?fynAz@ zsL1v!mF1qg?xNBP?_+E7pHu}+OF%ct$TVzd0TLX$!6MO4)hcW%Nu!QmgiGgQ#H@IK zClHbu8}I2T7b@_%1x4E+2j0mr+$G7{A|Lv@ow6XI>KuAqR|U>Nk=!Ex275A*bUya^ z4i$vHJaz4)+v~z4$N&`5BfZ04zA5H3BEsz4*dM=Rc+3$(K<57FqW7NOo95AeDW;}y z(1L3@N2(N7H9a%}{aRkAt0%2%rV>Bhk!RYVYe*w~+4$)YjY`=BVfXzSF+W~MJXnrq zlT`tKf9g#_Q{K2|Txbp#nRI>1@|7%X7lqd;7>)tI$LJVO6+g?*SKz2EY0 zjuQH2?Q%pUKDr%1S?&0HdPF-qUKYY*)*jm8wF4WkHZXZlpJTh-Y!9n-IEMa{5kpmv z<7e&Blkcc7V&oFGV7iV;-nM0(l=pvmY!&wMk(v6DM*!t3*^NT}D&oe;-N;6-8*40} z&o`{Hn zX^U5JLWugMfy67=vFXR6q&qL}Pp?~1`Vc5@0rhq6c2zsq%=TNbX)AyIQ7_95sBPHV zebo8de<3C489414o6<2AFAxef`RX9U%W7Mwoh1`A%25%|L1J<{l77vxkb}o3a!CFq zVpTaF=;?Qj(hVt5)vw-^#Tx7Q(RQ{b7jQ1AAArRvMU`B=mxB8iD#KX<%j{Tw&Id|GbXp33eh0&$KQer5ME;x^M?+0N zk>5ji%0JH68=I*h5KI1XeWiNRTcnMzek8%4NYn}|O0HIS_Zb%hk@ZJJF$c=h-^wk1 z{yrkq=H@*SQGoH%AP2A)(>gp`Bg;D==JwEC?MD`i(x=q^MS4q1`C80Z-hLHvsX!Reoej7=t|N4jkT3G9Xi9#E z1gB;52!iDUsZSDI>0mp$fyl30AGp;&G zis1Tf%M&T_SE`|VK&Qs@>nenuSpxb zs^f#wD33X6-24c^%{<^>CK+}dxFceZ6z0L-=eY4}+O!mhCVK`zX&97OnkF!5>BQft z)xr~LfHJ1GG-RUkJ2n$A%&Lxsei*W1sE3SY_YjM0rE`-aoh>g&FY8~a{55G8!fBRB zallUKgCr;efM2IS^te&TqR;w$Rdc3_6@Tt+y=jBrz$|`s5j|#N>Xcwjgm%ga?h}{k z|9D0PbJ7yGAsHzQXr_dK- zy{b{Ds&UnaRNv8(9?M@tZ#gzHF^S?R`4%Ov>CNB1F#<3rDbDpFk{t-0jkCqijzg#UlZY&y_I zjFl#2amHG2;nax~P$`K6benegU0U9~nkg2wM5vz*iL=p+aPgygss^Zmw-c41;!VFb zk1}q%$1_FX7CP!1)GvF8MYYZnGS!)Y8!tK(2rMFh`m6oKZr3IuBc(D5^CDnKVX~{R zLW9HS||4!{Feer^W~8pc^7>e>IaS%;4x1)C80to z-c?6R){G1?+|`MM(ERy-50#e@!t!~*d^&shAxu(Ntg(tiMqTCu_jc*@GVtcQSH72|*}aD~$G|7*qw{%BU;A7w|c2ajw=T@xIWXJ_)myIYi zuAMplR#W6`d3E@k~_$5Z$Kc7 zh9k?{CI+V~cbyG$?qxdPjVupR)FFvcyr4K#Y8jA+5i4PS9px9*W3STmvZxwtYiyj* zjjS8-nJizzV8V%#vesMR@tzUNUnkJ4k{cq>VBwoW{mIbKIDr2^xoq?H+%}Tdh^XXCajsJU(R@pX{;{IS8L~f?jY!`4 z)f=~7IQ7UGc53QRv%+*Ya){P{m7>Edp2J`uBSKiG<7nK!I}2|#@3etx?Kc)Sg$NT| zi6^KDhCWMIRxsheT;kMBBqg?_V{x6YdCSnC-x=4uL5%rPJ|JM2Ug?U zyES-z)&6wFsb?uK_L{q4_QoEV=9h$8&dnJgh@Ae!RiMH3zx#-pC=tO_`HsH~#Ck#4p7T9{oq&CAUA7h z|7%pseimhLmfz)6nX8!QPkzt$9!=seQOq37zom)#FXZzsvW9G>Ek4Uy!PSO(HER2O zccrxx4RtT``!?q~izSQ4*4(Kh5Wz?-pjs%U4cHPr$uFzu5i21=m#=M z=8EYj6xL#;OCP`s+~K!rC@86xtzv8+EdoA{<-Ce}M>n#a-07FwMJ_?Mu=GxmLSUnn zr|v`8PkARPV^hG^K0$@h^);{#+CMxKvgn_!z`SuQmLg%?pUEd@(MKQ$ z5L1Q=-vO~j@S1_5fE=Id@&Zu-in%xx=ug75vIMl0=O}3f3>Q-|J8819{0zd+;~BiB zM>1S$=U*w`ZH$5({aL8>bI=s^tpq90kk;yqqQ0y$s2v#PnYdOB??(X!bG1ZGJVubk zNNwqkqvhPZk^6>(Oz#Y_>$qNxW`;QjZd)Uy6mzMcjP(sJ2_JnyeuN(1Y--mJKczT+gqfI!S9H)~L*LGu}434CiW#-3`1J4(0Q zZ{yUf`5&xkY2v#hl1Xm0U=TdSJlT2yHp+cd3jO*MT1p(-mi2_DN!N;QZR4(j1*)gq$?Jxeh(SDF~|2N+Z2$)X$qu4(+I*^DwpYhq<`MeA% zr@cTCJ1G?PoH+nQK&(AeY$(V0t+CJ4@VSglAB1wAwy^07v4~=sdF|`x24Ru>(;Du{ zLF!Q|xQIu#)}V#CFuIfOck@SVmQ3mMq2t~Zp*pv^0oh?ZGST_8!QhlE?-GPZ0N6&2s$%fO@{a^K`TjbhgF*PZ;HFZ`)ZIKW~atpM%}B5!n)>q#FED*!6;;n zFQbd9ZbHK8?7*kJ6@KfFyt>PrT9Cr~K8%BO-;p#P1Erj({D8a5FH!SK=hCHV*xSp! z6MdVTE!P0{mESp9TXcfc${d!&pFl*8htEZGROQ^Dw@$^LU>P{JN73XnK4WhlDueC> z2<_Bf$fR?Ym^Po=6*m>C6;K_)^TeLfZ=z_h#6@Ql2g&~V#abcEi|QZeQ%RP~6A`Qc^=!`P?48?zHuZH3 zfMk*@3rwIf;0-sLo=OjhkU}qGb@)hYt-5I%>aYIzRLgt~C{x zpa=SOy&lS?xriG~()27Gz$$t)B^1c)-5ncuU@J=Anc_>Gred^KgbkUQ#TUlOU)Lne z+7&Ru4>K8OxH>F(_$6Ylyb;&a9k#nCZF1JR$I#Kr? zQlx=ipNHIdE-zY(syFEvxoayho7OpiaZsA9CXxwsTIU-v@{@Nh8>;b)qSXSV97aB> z^Vck0%1!L+R1|u)ia8(JVF!{*RRHzMEGW$dT2)1fZc&`X8r;kg%|Ov>RXtiNYdTIB zAat1&(pjF}!_eOwfB?6>%wxTU5%MGnLuvIFcN(tlpGc5%fX*k3ce{9pLk$oAc(Xi> zoV(_d5U~}tYK-~@s);IpGsAMX_dk&MB}*i|zD-`XBm7>vMfKDDk8U}SN@4dhroqx0 zIivzEYNKr({Q(7Mzn@n9_NdGPg;)UeY9Q}SzvmWe4K&?+w>-Qv7=gp!j8h8daCPq?3w#V7lU> z7={5GT-68n3zxIkmw8sP>67n9w!PI1;4yHvUSOD-3%MR1VM1hTxUgY|UBnpXJ~TXPFZ-uYxzDF|%nzMI7sn0j6a_td6ucer z1$D*v{6*eZ=xg`{(-c7okSOSTGi78y8~O_CV1xCP*mEB2sQCQx+)iLsoq{Ud#~|&^ z@lX}63Z+A=gUHcRtpADa@hV`$irNWo7_Rqc%H$W0>zj6A+AWpg0+bo6v`0IYO zwXE$)Py+*Uk(U#eWCnBO&h60(p8LKZ1>A7xV;qmiLwP&;@zUOH)hhMKd&nnw{^`$D z7Z&Q$6SULx-%JskQyWG;HPD*Ax{P>xwt3;>zfMDhy|osyiUM~YO_^tUc$!)xJO^9) z;)R&xW!yP=*Qi0>plJ#9Z8y)0IsfAS5cZg`W6rb}Oi+C=b*

    $`&#!(iwUWN4)sH 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 PeoQY}7?m2_di=itFG51F literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/SlackGrantScopes.png b/libraries/functional-tests/slacktestbot/media/SlackGrantScopes.png new file mode 100644 index 0000000000000000000000000000000000000000..d2969aae1d940db0927c7de819c5fadc1efc6ab9 GIT binary patch literal 172196 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ℜ>!K$90>|D1v|`hLo0m<&}z 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 z zHaguX4Q@Y|j^0jzh1idI_==-n~UZ{7xl z{f9x*C4NN|DnADP>&%d8vCAzHbh>2X`9Di8>Wu{4+zP`Y90XJ_m{hW`U+pZAb#A)4 zz2aVKJqA+wG)>cTo{#B!PFX_)?tVo_P|(fNjvPz#IJ<=JKNEPdJ<64SV`E zcOJ0iP5uGF;h%Q5A-|SEM`X6zg5(K3ij=gkbf*zntW;VXKUvP~-1`gfFva)SU><@p z;3ed;WEUKQl%TBL6t7Lpt%I%ahCE1fgW7y~_B|ld0RPH3na-jMKzX>4a2R@O!{OVa zkiv$iG8fERkVCNMv~-JFRTpzr7&GgM#-VYN>9=C9%SpY=4G;DPHbrB@NPY)~tP}-bS*QeDhb)P{+7->^3XnNHz!E8;=6fGF$`6ztW=c z_K(<4mCoZ8IYEhE9{L6jJV}2zi2<{Q4orWlHxhy)yM`bKaoyUyFoevn=Kg6haCvXJ zC_bCFc=kY2j^yg3J*6yyg@LWTfF<9{I1dU7Ci6{1*2q{hGd1{oZ1p1^{~0@Gxf5o-QKSN%XsKzWsi{r=zLJIx*GO+)I3W=r ziG-6eW?S-pe`_@~w&|yA5xfs~??#5EtilZ8+9^@dFQqE}6?{`peZX&$#tcYwG4ufE zd>cQ+bfh)@G74V6Cpy6txa+}#6)%rtjNdmKmvX+wj99zJd(FHEKP#e$pUDjh5iMiu zh~kFn!)iOD-~U5yPfLGbXTTuugDK}4XH<&Du0®+hI_C3XvSM@sdEh`h>3jMbGM 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 Bz9Rqt literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/media/SlackInstallApp.png b/libraries/functional-tests/slacktestbot/media/SlackInstallApp.png new file mode 100644 index 0000000000000000000000000000000000000000..f6ae3ee08568850849f20dba2bf378a185cefe00 GIT binary patch literal 516602 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 SxhADyeTKA? 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

    8EFJrD4741Mq87(8n`wv*lKLH9d1 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

    <;<4WOzfeZS>sxyY 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%0AYhw=7C6gWn*PowOn#gfvAUrPB?#3 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 literal 0 HcmV?d00001 diff --git a/libraries/functional-tests/slacktestbot/requirements.txt b/libraries/functional-tests/slacktestbot/requirements.txt new file mode 100644 index 000000000..0cdcf62b8 --- /dev/null +++ b/libraries/functional-tests/slacktestbot/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-integration-aiohttp>=4.11.0 +botbuilder-adapters-slack>=4.11.0 diff --git a/libraries/functional-tests/slacktestbot/resources/InteractiveMessage.json b/libraries/functional-tests/slacktestbot/resources/InteractiveMessage.json new file mode 100644 index 000000000..91637db25 --- /dev/null +++ b/libraries/functional-tests/slacktestbot/resources/InteractiveMessage.json @@ -0,0 +1,62 @@ +[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Hello, Assistant to the Regional Manager Dwight! *Michael Scott* wants to know where you'd like to take the Paper Company investors to dinner tonight.\n\n *Please select a restaurant:*" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Farmhouse Thai Cuisine*\n:star::star::star::star: 1528 reviews\n They do have some vegan options, like the roti and curry, plus they have a ton of salad stuff and noodles can be ordered without meat!! They have something for everyone here" + }, + "accessory": { + "type": "image", + "image_url": "https://s3-media3.fl.yelpcdn.com/bphoto/c7ed05m9lC2EmA3Aruue7A/o.jpg", + "alt_text": "alt text for image" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Ler Ros*\n:star::star::star::star: 2082 reviews\n I would really recommend the Yum Koh Moo Yang - Spicy lime dressing and roasted quick marinated pork shoulder, basil leaves, chili & rice powder." + }, + "accessory": { + "type": "image", + "image_url": "https://s3-media2.fl.yelpcdn.com/bphoto/DawwNigKJ2ckPeDeDM7jAg/o.jpg", + "alt_text": "alt text for image" + } + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Farmhouse", + "emoji": true + }, + "value": "Farmhouse" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Ler Ros", + "emoji": true + }, + "value": "Ler Ros" + } + ] + } +] \ No newline at end of file diff --git a/libraries/functional-tests/tests/test_slack_client.py b/libraries/functional-tests/tests/test_slack_client.py new file mode 100644 index 000000000..cc5abd74d --- /dev/null +++ b/libraries/functional-tests/tests/test_slack_client.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import hashlib +import hmac +import json +import os +import uuid +import datetime +import time +import aiounittest +import requests + + +class SlackClient(aiounittest.AsyncTestCase): + async def test_send_and_receive_slack_message(self): + # Arrange + echo_guid = str(uuid.uuid4()) + + # Act + await self._send_message_async(echo_guid) + response = await self._receive_message_async() + + # Assert + self.assertEqual(f"Echo: {echo_guid}", response) + + async def _receive_message_async(self) -> str: + last_message = "" + i = 0 + + while "Echo" not in last_message and i < 60: + url = ( + f"{self._slack_url_base}/conversations.history?token=" + f"{self._slack_bot_token}&channel={self._slack_channel}" + ) + response = requests.get(url,) + last_message = response.json()["messages"][0]["text"] + + time.sleep(1) + i += 1 + + return last_message + + async def _send_message_async(self, echo_guid: str) -> None: + timestamp = str(int(datetime.datetime.utcnow().timestamp())) + message = self._create_message(echo_guid) + hub_signature = self._create_hub_signature(message, timestamp) + headers = { + "X-Slack-Request-Timestamp": timestamp, + "X-Slack-Signature": hub_signature, + "Content-type": "application/json", + } + url = f"https://{self._bot_name}.azurewebsites.net/api/messages" + + requests.post(url, headers=headers, data=message) + + def _create_message(self, echo_guid: str) -> str: + slack_event = { + "client_msg_id": "client_msg_id", + "type": "message", + "text": echo_guid, + "user": "userId", + "channel": self._slack_channel, + "channel_type": "im", + } + + message = { + "token": self._slack_verification_token, + "team_id": "team_id", + "api_app_id": "apiAppId", + "event": slack_event, + "type": "event_callback", + } + + return json.dumps(message) + + def _create_hub_signature(self, message: str, timestamp: str) -> str: + signature = ["v0", timestamp, message] + base_string = ":".join(signature) + + computed_signature = "V0=" + hmac.new( + bytes(self._slack_client_signing_secret, encoding="utf8"), + msg=bytes(base_string, "utf-8"), + digestmod=hashlib.sha256, + ).hexdigest().upper().replace("-", "") + + return computed_signature + + @classmethod + def setUpClass(cls) -> None: + cls._slack_url_base: str = "https://slack.com/api" + + cls._slack_channel = os.getenv("SlackChannel") + if not cls._slack_channel: + raise Exception('Environment variable "SlackChannel" not found.') + + cls._slack_bot_token = os.getenv("SlackBotToken") + if not cls._slack_bot_token: + raise Exception('Environment variable "SlackBotToken" not found.') + + cls._slack_client_signing_secret = os.getenv("SlackClientSigningSecret") + if not cls._slack_client_signing_secret: + raise Exception( + 'Environment variable "SlackClientSigningSecret" not found.' + ) + + cls._slack_verification_token = os.getenv("SlackVerificationToken") + if not cls._slack_verification_token: + raise Exception('Environment variable "SlackVerificationToken" not found.') + + cls._bot_name = os.getenv("BotName") + if not cls._bot_name: + raise Exception('Environment variable "BotName" not found.') diff --git a/pipelines/botbuilder-python-ci-slack-test.yml b/pipelines/botbuilder-python-ci-slack-test.yml new file mode 100644 index 000000000..7e540187b --- /dev/null +++ b/pipelines/botbuilder-python-ci-slack-test.yml @@ -0,0 +1,105 @@ +# +# Runs functional tests against the Slack channel. +# + +# "name" here defines the build number format. Build number is accessed via $(Build.BuildNumber) +name: $(Build.BuildId) + +pool: + vmImage: $[ coalesce( variables['VMImage'], 'windows-2019' ) ] # or 'windows-latest' or 'vs2017-win2016' + +trigger: # ci trigger + batch: true + branches: + include: + - main + paths: + include: + - '*' + exclude: + - doc/ + - specs/ + - LICENSE + - README.md + - UsingTestPyPI.md + +pr: # pr trigger + branches: + include: + - main + paths: + include: + - pipelines/botbuilder-python-ci-slack-test.yml + +variables: + AppId: $(SlackTestBotAppId) + AppSecret: $(SlackTestBotAppSecret) + BotGroup: $(SlackTestBotBotGroup) + BotName: $(SlackTestBotBotName) + SlackBotToken: $(SlackTestBotSlackBotToken) + SlackClientSigningSecret: $(SlackTestBotSlackClientSigningSecret) + SlackVerificationToken: $(SlackTestBotSlackVerificationToken) +# AzureSubscription: define this in Azure +# SlackTestBotAppId: define this in Azure +# SlackTestBotAppSecret: define this in Azure +# SlackTestBotBotGroup: define this in Azure +# SlackTestBotBotName: define this in Azure +# SlackTestBotSlackBotToken: define this in Azure +# SlackTestBotSlackChannel: define this in Azure +# SlackTestBotSlackClientSigningSecret: define this in Azure +# SlackTestBotSlackVerificationToken: define this in Azure +# DeleteResourceGroup: (optional) define in Azure + +steps: +- powershell: 'gci env:* | sort-object name | Format-Table -AutoSize -Wrap' + displayName: 'Display env vars' + +- task: AzureCLI@2 + displayName: 'Create Azure resources' + inputs: + azureSubscription: $(AzureSubscription) + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + Set-PSDebug -Trace 1; + # set up resource group, bot channels registration, app service, app service plan + az deployment sub create --name "$(BotName)" --template-file "$(System.DefaultWorkingDirectory)/libraries/functional-tests/slacktestbot/deploymentTemplates/template-with-new-rg.json" --location "westus" --parameters groupName="$(BotGroup)" appId="$(AppId)" appSecret="$(AppSecret)" botId="$(BotName)" botSku="F0" newAppServicePlanName="$(BotName)" newWebAppName="$(BotName)" slackVerificationToken="$(SlackVerificationToken)" slackBotToken="$(SlackBotToken)" slackClientSigningSecret="$(SlackClientSigningSecret)" groupLocation="westus" newAppServicePlanLocation="westus"; + Set-PSDebug -Trace 0; + +- powershell: | + 7z a -tzip "$(System.DefaultWorkingDirectory)/libraries/functional-tests/slacktestbot/bot.zip" "$(System.DefaultWorkingDirectory)/libraries/functional-tests/slacktestbot/*" -aoa + displayName: 'Zip Bot' + +- task: AzureCLI@1 + displayName: 'Deploy bot' + inputs: + azureSubscription: $(AzureSubscription) + scriptType: ps + scriptLocation: inlineScript + inlineScript: | + az webapp deployment source config-zip --resource-group "$(BotGroup)" --name "$(BotName)" --src "$(System.DefaultWorkingDirectory)/libraries/functional-tests/slacktestbot/bot.zip" --timeout 300 + +- script: | + python -m pip install --upgrade pip + pip install -r ./libraries/functional-tests/requirements.txt + pip install pytest + displayName: 'Install test dependencies' + +- script: | + pytest test_slack_client.py + workingDirectory: '$(System.DefaultWorkingDirectory)/libraries/functional-tests/tests/' + displayName: Run test + env: + BotName: $(SlackTestBotBotName) + SlackBotToken: $(SlackTestBotSlackBotToken) + SlackChannel: $(SlackTestBotSlackChannel) + SlackClientSigningSecret: $(SlackTestBotSlackClientSigningSecret) + SlackVerificationToken: $(SlackTestBotSlackVerificationToken) + +- task: AzureCLI@1 + displayName: 'Delete resources' + inputs: + azureSubscription: $(AzureSubscription) + scriptLocation: inlineScript + inlineScript: 'call az group delete -n "$(BotGroup)" --yes' + condition: and(always(), ne(variables['DeleteResourceGroup'], 'false')) diff --git a/pipelines/botbuilder-python-ci.yml b/pipelines/botbuilder-python-ci.yml index 6388af8b3..89987ffc9 100644 --- a/pipelines/botbuilder-python-ci.yml +++ b/pipelines/botbuilder-python-ci.yml @@ -66,7 +66,7 @@ jobs: pip install pytest pip install pytest-cov pip install coveralls - pytest --junitxml=junit/test-results.$(PYTHON_VERSION).xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html + pytest --junitxml=junit/test-results.$(PYTHON_VERSION).xml --cov-config=.coveragerc --cov --cov-report=xml --cov-report=html --ignore=libraries/functional-tests/tests/test_slack_client.py displayName: Pytest - task: PublishCodeCoverageResults@1 From b458369a1d9e0da5b611534023b65eb7ccc6563e Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Tue, 2 Feb 2021 13:53:23 -0600 Subject: [PATCH 614/616] pylance warnings corrections for List arguments (#1491) --- .../botbuilder/adapters/slack/slack_client.py | 14 +++++++------- .../botbuilder/adapters/slack/slack_event.py | 3 ++- .../botbuilder/adapters/slack/slack_payload.py | 2 +- .../adapters/slack/slack_request_body.py | 3 ++- .../botbuilder/ai/qna/utils/qna_card_builder.py | 3 ++- .../botbuilder/schema/health_results.py | 3 ++- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py index 0911ce965..297fb6b8e 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py @@ -5,7 +5,7 @@ import hmac import json from io import IOBase -from typing import Union +from typing import List, Union import aiohttp from aiohttp.web_request import Request @@ -116,7 +116,7 @@ async def files_list_ex( date_to: str = None, count: int = None, page: int = None, - types: [str] = None, + types: List[str] = None, ) -> SlackResponse: args = {} @@ -185,7 +185,7 @@ async def chat_post_ephemeral_ex( target_user: str, parse: str = None, link_names: bool = False, - attachments: [str] = None, # pylint: disable=unused-argument + attachments: List[str] = None, # pylint: disable=unused-argument as_user: bool = False, ) -> SlackResponse: args = { @@ -210,8 +210,8 @@ async def chat_post_message_ex( bot_name: str = None, parse: str = None, link_names: bool = False, - blocks: [str] = None, # pylint: disable=unused-argument - attachments: [str] = None, # pylint: disable=unused-argument + blocks: List[str] = None, # pylint: disable=unused-argument + attachments: List[str] = None, # pylint: disable=unused-argument unfurl_links: bool = False, icon_url: str = None, icon_emoji: str = None, @@ -328,7 +328,7 @@ async def chat_update_ex( bot_name: str = None, parse: str = None, link_names: bool = False, - attachments: [str] = None, # pylint: disable=unused-argument + attachments: List[str] = None, # pylint: disable=unused-argument as_user: bool = False, ): args = { @@ -353,7 +353,7 @@ async def files_upload_ex( self, file: Union[str, IOBase] = None, content: str = None, - channels: [str] = None, + channels: List[str] = None, title: str = None, initial_comment: str = None, file_type: str = None, diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py index 689b0b25c..66b810ffb 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List from botbuilder.adapters.slack.slack_message import SlackMessage @@ -24,7 +25,7 @@ def __init__(self, **kwargs): self.user = kwargs.get("user") self.user_id = kwargs.get("user_id") self.bot_id = kwargs.get("bot_id") - self.actions: [str] = kwargs.get("actions") + self.actions: List[str] = kwargs.get("actions") self.item = kwargs.get("item") self.item_channel = kwargs.get("item_channel") self.files: [] = kwargs.get("files") diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py index c05456f69..9b7438619 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py @@ -8,7 +8,7 @@ class SlackPayload: def __init__(self, **kwargs): - self.type: [str] = kwargs.get("type") + self.type: List[str] = kwargs.get("type") self.token: str = kwargs.get("token") self.channel: str = kwargs.get("channel") self.thread_ts: str = kwargs.get("thread_ts") diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py index 7990555c7..b8ad4bd06 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List from botbuilder.adapters.slack.slack_event import SlackEvent from botbuilder.adapters.slack.slack_payload import SlackPayload @@ -14,7 +15,7 @@ def __init__(self, **kwargs): self.type = kwargs.get("type") self.event_id = kwargs.get("event_id") self.event_time = kwargs.get("event_time") - self.authed_users: [str] = kwargs.get("authed_users") + self.authed_users: List[str] = kwargs.get("authed_users") self.trigger_id = kwargs.get("trigger_id") self.channel_id = kwargs.get("channel_id") self.user_id = kwargs.get("user_id") diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py index e450b4ef2..75785c78c 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List from botbuilder.core import CardFactory from botbuilder.schema import Activity, ActivityTypes, CardAction, HeroCard @@ -14,7 +15,7 @@ class QnACardBuilder: @staticmethod def get_suggestions_card( - suggestions: [str], card_title: str, card_no_match: str + suggestions: List[str], card_title: str, card_no_match: str ) -> Activity: """ Get active learning suggestions card. diff --git a/libraries/botbuilder-schema/botbuilder/schema/health_results.py b/libraries/botbuilder-schema/botbuilder/schema/health_results.py index 28f7dca9c..6205e68cb 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/health_results.py +++ b/libraries/botbuilder-schema/botbuilder/schema/health_results.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List from msrest.serialization import Model @@ -19,7 +20,7 @@ def __init__( success: bool = None, authorization: str = None, user_agent: str = None, - messages: [str] = None, + messages: List[str] = None, diagnostics: object = None, **kwargs ) -> None: From 354444ef38c338f642ec41b51d18da6307b42172 Mon Sep 17 00:00:00 2001 From: Gabo Gilabert Date: Fri, 5 Feb 2021 03:43:20 -0500 Subject: [PATCH 615/616] Updated dialog_manager to send EoC code like dialog_extensions. (#1504) Also added checks in the test fo ensure we get the code back --- .../botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py | 5 ++++- libraries/botbuilder-dialogs/tests/test_dialog_manager.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py index 28dbe6e74..c1d3088d1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -16,7 +16,7 @@ DialogStateManager, DialogStateManagerConfiguration, ) -from botbuilder.schema import Activity, ActivityTypes +from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes from botframework.connector.auth import ( AuthenticationConstants, ClaimsIdentity, @@ -355,6 +355,9 @@ async def handle_skill_on_turn( type=ActivityTypes.end_of_conversation, value=turn_result.result, locale=turn_context.activity.locale, + code=EndOfConversationCodes.completed_successfully + if turn_result.status == DialogTurnStatus.Complete + else EndOfConversationCodes.user_cancelled, ) await turn_context.send_activity(activity) diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_manager.py b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py index 6ed5198f7..75f5b91a3 100644 --- a/libraries/botbuilder-dialogs/tests/test_dialog_manager.py +++ b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py @@ -38,6 +38,7 @@ ActivityTypes, ChannelAccount, ConversationAccount, + EndOfConversationCodes, InputHints, ) from botframework.connector.auth import AuthenticationConstants, ClaimsIdentity @@ -237,6 +238,10 @@ async def test_handles_bot_and_skills(self): SimpleComponentDialog.eoc_sent.type, ActivityTypes.end_of_conversation, ) + self.assertEqual( + SimpleComponentDialog.eoc_sent.code, + EndOfConversationCodes.completed_successfully, + ) self.assertEqual(SimpleComponentDialog.eoc_sent.value, "SomeName") else: self.assertIsNone( From 5dfcc48754483eae459ca3986136e2ddaefde37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20Su=C3=A1rez?= Date: Mon, 8 Feb 2021 06:05:32 -0800 Subject: [PATCH 616/616] Limiting yarl dependency version to be compatible with python 3.7 (when min version suported >= 3.8 this constraint can be removed) (#1507) --- libraries/botbuilder-integration-aiohttp/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/botbuilder-integration-aiohttp/setup.py b/libraries/botbuilder-integration-aiohttp/setup.py index f45e67ec8..e1e06e54b 100644 --- a/libraries/botbuilder-integration-aiohttp/setup.py +++ b/libraries/botbuilder-integration-aiohttp/setup.py @@ -9,6 +9,7 @@ "botbuilder-schema==4.12.0", "botframework-connector==4.12.0", "botbuilder-core==4.12.0", + "yarl<=1.4.2", "aiohttp==3.6.2", ]

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%0A(dIn_u|}iP#EUy(c%rkK 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 VInC-Q{5QFJYSt^VdizoAI5+wml^h7cnw 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