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