Spaces:
Build error
Build error
Validify-testbot-1
/
botbuilder-python
/libraries
/botbuilder-dialogs
/botbuilder
/dialogs
/skills
/skill_dialog.py
| # Copyright (c) Microsoft Corporation. All rights reserved. | |
| # Licensed under the MIT License. | |
| from copy import deepcopy | |
| from typing import List | |
| from botframework.connector.token_api.models import TokenExchangeRequest | |
| from botbuilder.schema import ( | |
| Activity, | |
| ActivityTypes, | |
| ExpectedReplies, | |
| DeliveryModes, | |
| 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, | |
| DialogEvents, | |
| DialogReason, | |
| DialogInstance, | |
| ) | |
| 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: | |
| 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 = self._validate_begin_dialog_args(options) | |
| # 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, | |
| ) | |
| # Store delivery mode in dialog state for later use. | |
| dialog_context.active_dialog.state[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, skill_conversation_id | |
| ) | |
| 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 | |
| # 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: | |
| return await dialog_context.end_dialog( | |
| dialog_context.context.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 | |
| ] | |
| # Just forward to the remote skill | |
| 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) | |
| 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, | |
| ) | |
| # connection Name is not applicable for a RePrompt, as we don't expect as OAuthCard in response. | |
| 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 | |
| ): | |
| 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): | |
| 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 | |
| # connection Name is not applicable for an EndDialog, as we don't expect as OAuthCard in response. | |
| skill_conversation_id = instance.state[ | |
| SkillDialog.SKILLCONVERSATIONIDSTATEKEY | |
| ] | |
| await self._send_to_skill(context, activity, skill_conversation_id) | |
| await super().end_dialog(context, instance, reason) | |
| def _validate_begin_dialog_args(self, 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." | |
| ) | |
| 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, 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 | |
| # 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 | |
| # 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) | |
| 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, | |
| 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 | |
| # 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: | |
| # 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 not sent_invoke_response and await self._intercept_oauth_cards( | |
| context, from_skill_activity, self.dialog_options.connection_name | |
| ): | |
| # 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) | |
| 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 | |
| ): | |
| """ | |
| Tells is if we should intercept the OAuthCard message. | |
| """ | |
| 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( | |
| 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: | |
| # 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, | |
| oauth_card.connection_name, | |
| 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 | |
| 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.is_successful_status_code() | |