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, + )