diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 656050bab..93d6751df 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -8,8 +8,9 @@ import asyncio import inspect from datetime import datetime -from typing import Coroutine, Dict, List, Callable, Union +from typing import Awaitable, Coroutine, Dict, List, Callable, Union from copy import copy +from threading import Lock from botbuilder.schema import ( ActivityTypes, Activity, @@ -56,8 +57,8 @@ class TestAdapter(BotAdapter, UserTokenProvider): def __init__( self, logic: Coroutine = None, - conversation: ConversationReference = None, - send_trace_activity: bool = False, + template: Activity = None, + send_trace_activities: bool = False, ): # pylint: disable=unused-argument """ Creates a new TestAdapter instance. @@ -69,21 +70,42 @@ def __init__( self._next_id: int = 0 self._user_tokens: List[UserToken] = [] self._magic_codes: List[TokenMagicCode] = [] + self._conversation_lock = Lock() self.activity_buffer: List[Activity] = [] self.updated_activities: List[Activity] = [] self.deleted_activities: List[ConversationReference] = [] + self.send_trace_activities = send_trace_activities - self.template: Activity = Activity( + self.template = template or Activity( channel_id="test", service_url="https://test.com", from_property=ChannelAccount(id="User1", name="user"), recipient=ChannelAccount(id="bot", name="Bot"), conversation=ConversationAccount(id="Convo1"), ) - if self.template is not None: - self.template.service_url = self.template.service_url - self.template.conversation = self.template.conversation - self.template.channel_id = self.template.channel_id + + async def process_activity( + self, activity: Activity, logic: Callable[[TurnContext], Awaitable] + ): + self._conversation_lock.acquire() + try: + # ready for next reply + if activity.type is None: + activity.type = ActivityTypes.message + + activity.channel_id = self.template.channel_id + activity.from_property = self.template.from_property + activity.recipient = self.template.recipient + activity.conversation = self.template.conversation + activity.service_url = self.template.service_url + + activity.id = str((self._next_id)) + self._next_id += 1 + finally: + self._conversation_lock.release() + + activity.timestamp = activity.timestamp or datetime.utcnow() + await self.run_pipeline(TurnContext(self, activity), logic) async def send_activities(self, context, activities: List[Activity]): """ @@ -99,12 +121,11 @@ def id_mapper(activity): self._next_id += 1 return ResourceResponse(id=str(self._next_id)) - # TODO This if-else code is temporary until the BotAdapter and Bot/TurnContext are revamped. - if isinstance(activities, list): - responses = [id_mapper(activity) for activity in activities] - else: - responses = [id_mapper(activities)] - return responses + return [ + id_mapper(activity) + for activity in activities + if self.send_trace_activities or activity.type != "trace" + ] async def delete_activity(self, context, reference: ConversationReference): """ diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index ce1f33d69..9053a08d3 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -2,7 +2,8 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Callable, Dict +from copy import deepcopy +from typing import Callable, Dict, Union from botbuilder.core.state_property_accessor import StatePropertyAccessor from .turn_context import TurnContext from .storage import Storage @@ -186,7 +187,9 @@ async def delete(self, turn_context: TurnContext) -> None: await self._bot_state.delete_property_value(turn_context, self._name) async def get( - self, turn_context: TurnContext, default_value_factory: Callable = None + self, + turn_context: TurnContext, + default_value_or_factory: Union[Callable, object] = None, ) -> object: await self._bot_state.load(turn_context, False) try: @@ -194,9 +197,13 @@ async def get( return result except: # ask for default value from factory - if not default_value_factory: + if not default_value_or_factory: return None - result = default_value_factory() + result = ( + default_value_or_factory() + if callable(default_value_or_factory) + else deepcopy(default_value_or_factory) + ) # save default value for any further calls await self.set(turn_context, result) return result diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/__init__.py b/libraries/botbuilder-core/botbuilder/core/inspection/__init__.py new file mode 100644 index 000000000..2b03e3eef --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/__init__.py @@ -0,0 +1,11 @@ +# 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 .inspection_middleware import InspectionMiddleware +from .inspection_state import InspectionState + +__all__ = ["InspectionMiddleware", "InspectionState"] diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/inspection_middleware.py b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_middleware.py new file mode 100644 index 000000000..02335092a --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_middleware.py @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from typing import Any, List + +from jsonpickle import Pickler +from botbuilder.core import BotState, ConversationState, TurnContext, UserState +from botbuilder.schema import Activity, ActivityTypes, ConversationReference +from botframework.connector.auth import MicrosoftAppCredentials + +from .inspection_session import InspectionSession +from .inspection_sessions_by_status import ( + InspectionSessionsByStatus, + DEFAULT_INSPECTION_SESSIONS_BY_STATUS, +) +from .inspection_state import InspectionState +from .interception_middleware import InterceptionMiddleware +from .trace_activity import from_state, make_command_activity + + +class InspectionMiddleware(InterceptionMiddleware): + _COMMAND = "/INSPECT" + + def __init__( # pylint: disable=super-init-not-called + self, + inspection_state: InspectionState, + user_state: UserState = None, + conversation_state: ConversationState = None, + credentials: MicrosoftAppCredentials = None, + ): + + self.inspection_state = inspection_state + self.inspection_state_accessor = inspection_state.create_property( + "InspectionSessionByStatus" + ) + self.user_state = user_state + self.conversation_state = conversation_state + self.credentials = MicrosoftAppCredentials( + credentials.microsoft_app_id if credentials else "", + credentials.microsoft_app_password if credentials else "", + ) + + async def process_command(self, context: TurnContext) -> Any: + if context.activity.type == ActivityTypes.message and context.activity.text: + + original_text = context.activity.text + TurnContext.remove_recipient_mention(context.activity) + + command = context.activity.text.strip().split(" ") + if len(command) > 1 and command[0] == InspectionMiddleware._COMMAND: + + if len(command) == 2 and command[1] == "open": + await self._process_open_command(context) + return True + + if len(command) == 3 and command[1] == "attach": + await self.process_attach_command(context, command[2]) + return True + + context.activity.text = original_text + + return False + + async def _inbound(self, context: TurnContext, trace_activity: Activity) -> Any: + if await self.process_command(context): + return False, False + + session = await self._find_session(context) + if session: + if await self._invoke_send(context, session, trace_activity): + return True, True + return True, False + + async def _outbound( + self, context: TurnContext, trace_activities: List[Activity] + ) -> Any: + session = await self._find_session(context) + if session: + for trace_activity in trace_activities: + if not await self._invoke_send(context, session, trace_activity): + break + + async def _trace_state(self, context: TurnContext) -> Any: + session = await self._find_session(context) + if session: + if self.user_state: + await self.user_state.load(context, False) + + if self.conversation_state: + await self.conversation_state.load(context, False) + + bot_state = {} + + if self.user_state: + bot_state["user_state"] = InspectionMiddleware._get_serialized_context( + self.user_state, context + ) + + if self.conversation_state: + bot_state[ + "conversation_state" + ] = InspectionMiddleware._get_serialized_context( + self.conversation_state, context + ) + + await self._invoke_send(context, session, from_state(bot_state)) + + async def _process_open_command(self, context: TurnContext) -> Any: + sessions = await self.inspection_state_accessor.get( + context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS + ) + session_id = self._open_command( + sessions, TurnContext.get_conversation_reference(context.activity) + ) + await context.send_activity( + make_command_activity( + f"{InspectionMiddleware._COMMAND} attach {session_id}" + ) + ) + await self.inspection_state.save_changes(context, False) + + async def process_attach_command( + self, context: TurnContext, session_id: str + ) -> None: + sessions = await self.inspection_state_accessor.get( + context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS + ) + + if self._attach_comamnd(context.activity.conversation.id, sessions, session_id): + await context.send_activity( + "Attached to session, all traffic is being replicated for inspection." + ) + else: + await context.send_activity( + f"Open session with id {session_id} does not exist." + ) + + await self.inspection_state.save_changes(context, False) + + def _open_command( + self, + sessions: InspectionSessionsByStatus, + conversation_reference: ConversationReference, + ) -> str: + session_id = str(uuid4()) + sessions.opened_sessions[session_id] = conversation_reference + return session_id + + def _attach_comamnd( + self, + conversation_id: str, + sessions: InspectionSessionsByStatus, + session_id: str, + ) -> bool: + inspection_session_state = sessions.opened_sessions.get(session_id) + if inspection_session_state: + sessions.attached_sessions[conversation_id] = inspection_session_state + del sessions.opened_sessions[session_id] + return True + + return False + + @staticmethod + def _get_serialized_context(state: BotState, context: TurnContext): + ctx = state.get(context) + return Pickler(unpicklable=False).flatten(ctx) + + async def _find_session(self, context: TurnContext) -> Any: + sessions = await self.inspection_state_accessor.get( + context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS + ) + + conversation_reference = sessions.attached_sessions.get( + context.activity.conversation.id + ) + if conversation_reference: + return InspectionSession(conversation_reference, self.credentials) + + return None + + async def _invoke_send( + self, context: TurnContext, session: InspectionSession, activity: Activity + ) -> bool: + if await session.send(activity): + return True + + await self._clean_up_session(context) + return False + + async def _clean_up_session(self, context: TurnContext) -> None: + sessions = await self.inspection_state_accessor.get( + context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS + ) + + del sessions.attached_sessions[context.activity.conversation.id] + await self.inspection_state.save_changes(context, False) diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/inspection_session.py b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_session.py new file mode 100644 index 000000000..c73a5fbe9 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_session.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any + +from botbuilder.core import TurnContext +from botbuilder.schema import Activity, ConversationReference +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import MicrosoftAppCredentials + + +class InspectionSession: + def __init__( + self, + conversation_reference: ConversationReference, + credentials: MicrosoftAppCredentials, + ): + self._conversation_reference = conversation_reference + self._connector_client = ConnectorClient( + credentials, base_url=conversation_reference.service_url + ) + + async def send(self, activity: Activity) -> Any: + TurnContext.apply_conversation_reference(activity, self._conversation_reference) + + try: + await self._connector_client.conversations.send_to_conversation( + activity.conversation.id, activity + ) + except Exception: + return False + + return True diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/inspection_sessions_by_status.py b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_sessions_by_status.py new file mode 100644 index 000000000..f2ef2676f --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_sessions_by_status.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from botbuilder.schema import ConversationReference + + +class InspectionSessionsByStatus: + def __init__( + self, + opened_sessions: Dict[str, ConversationReference] = None, + attached_sessions: Dict[str, ConversationReference] = None, + ): + self.opened_sessions: Dict[str, ConversationReference] = opened_sessions or {} + self.attached_sessions: Dict[ + str, ConversationReference + ] = attached_sessions or {} + + +DEFAULT_INSPECTION_SESSIONS_BY_STATUS = InspectionSessionsByStatus() diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/inspection_state.py b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_state.py new file mode 100644 index 000000000..f2b258e36 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/inspection_state.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import BotState, Storage, TurnContext + + +class InspectionState(BotState): + def __init__(self, storage: Storage): + super().__init__(storage, self.__class__.__name__) + + def get_storage_key( # pylint: disable=unused-argument + self, turn_context: TurnContext + ) -> str: + return self.__class__.__name__ diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/interception_middleware.py b/libraries/botbuilder-core/botbuilder/core/inspection/interception_middleware.py new file mode 100644 index 000000000..1db94a867 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/interception_middleware.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from typing import Any, Awaitable, Callable, List + +from botbuilder.core import Middleware, TurnContext +from botbuilder.schema import Activity, ConversationReference + +from .trace_activity import from_activity, from_conversation_reference, from_error + + +class InterceptionMiddleware(Middleware): + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + should_forward_to_application, should_intercept = await self._invoke_inbound( + context, + from_activity(context.activity, "ReceivedActivity", "Received Activity"), + ) + + if should_intercept: + + async def aux_on_send( + ctx: TurnContext, activities: List[Activity], next_send: Callable + ): + trace_activities = [ + from_activity(activity, "SentActivity", "Sent Activity") + for activity in activities + ] + await self._invoke_outbound(ctx, trace_activities) + return await next_send() + + async def aux_on_update( + ctx: TurnContext, activity: Activity, next_update: Callable + ): + trace_activity = from_activity( + activity, "MessageUpdate", "Updated Message" + ) + await self._invoke_outbound(ctx, [trace_activity]) + return await next_update() + + async def aux_on_delete( + ctx: TurnContext, + reference: ConversationReference, + next_delete: Callable, + ): + trace_activity = from_conversation_reference(reference) + await self._invoke_outbound(ctx, [trace_activity]) + return await next_delete() + + context.on_send_activities(aux_on_send) + context.on_update_activity(aux_on_update) + context.on_delete_activity(aux_on_delete) + + if should_forward_to_application: + try: + await logic() + except Exception as err: + trace_activity = from_error(str(err)) + await self._invoke_outbound(context, [trace_activity]) + raise err + + if should_intercept: + await self._invoke_trace_state(context) + + @abstractmethod + async def _inbound(self, context: TurnContext, trace_activity: Activity) -> Any: + raise NotImplementedError() + + @abstractmethod + async def _outbound( + self, context: TurnContext, trace_activities: List[Activity] + ) -> Any: + raise NotImplementedError() + + @abstractmethod + async def _trace_state(self, context: TurnContext) -> Any: + raise NotImplementedError() + + async def _invoke_inbound( + self, context: TurnContext, trace_activity: Activity + ) -> Any: + try: + return await self._inbound(context, trace_activity) + except Exception as err: + print(f"Exception in inbound interception {str(err)}") + return True, False + + async def _invoke_outbound( + self, context: TurnContext, trace_activities: List[Activity] + ) -> Any: + try: + return await self._outbound(context, trace_activities) + except Exception as err: + print(f"Exception in outbound interception {str(err)}") + + async def _invoke_trace_state(self, context: TurnContext) -> Any: + try: + return await self._trace_state(context) + except Exception as err: + print(f"Exception in state interception {str(err)}") diff --git a/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py b/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py new file mode 100644 index 000000000..409c4b503 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/inspection/trace_activity.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime +from typing import Dict, Union + +from botbuilder.core import BotState +from botbuilder.schema import Activity, ActivityTypes, ConversationReference + + +def make_command_activity(command: str) -> Activity: + return Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name="Command", + label="Command", + value=command, + value_type="https://www.botframework.com/schemas/command", + ) + + +def from_activity(activity: Activity, name: str, label: str) -> Activity: + return Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name=name, + label=label, + value=activity, + value_type="https://www.botframework.com/schemas/activity", + ) + + +def from_state(bot_state: Union[BotState, Dict]) -> Activity: + return Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name="Bot State", + label="BotState", + value=bot_state, + value_type="https://www.botframework.com/schemas/botState", + ) + + +def from_conversation_reference( + conversation_reference: ConversationReference +) -> Activity: + return Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name="Deleted Message", + label="MessageDelete", + value=conversation_reference, + value_type="https://www.botframework.com/schemas/conversationReference", + ) + + +def from_error(error_message: str) -> Activity: + return Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name="Turn Error", + label="TurnError", + value=error_message, + value_type="https://www.botframework.com/schemas/error", + ) diff --git a/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py b/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py index e8f2e5abd..70b11d252 100644 --- a/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py +++ b/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py @@ -9,12 +9,13 @@ class StatePropertyAccessor(ABC): @abstractmethod async def get( - self, turn_context: TurnContext, default_value_factory=None + self, turn_context: TurnContext, default_value_or_factory=None ) -> object: """ Get the property value from the source :param turn_context: Turn Context. - :param default_value_factory: Function which defines the property value to be returned if no value has been set. + :param default_value_or_factory: Function which defines the property + value to be returned if no value has been set. :return: """ diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index 455d058ad..b3dff50fa 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -5,7 +5,11 @@ from setuptools import setup VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1" -REQUIRES = ["botbuilder-schema>=4.4.0b1", "botframework-connector>=4.4.0b1"] +REQUIRES = [ + "botbuilder-schema>=4.4.0b1", + "botframework-connector>=4.4.0b1", + "jsonpickle>=1.2", +] root = os.path.abspath(os.path.dirname(__file__)) @@ -27,7 +31,11 @@ long_description=long_description, long_description_content_type="text/x-rst", license=package_info["__license__"], - packages=["botbuilder.core", "botbuilder.core.adapters"], + packages=[ + "botbuilder.core", + "botbuilder.core.adapters", + "botbuilder.core.inspection", + ], install_requires=REQUIRES, classifiers=[ "Programming Language :: Python :: 3.7", diff --git a/libraries/botbuilder-core/tests/requirements.txt b/libraries/botbuilder-core/tests/requirements.txt new file mode 100644 index 000000000..a6634197c --- /dev/null +++ b/libraries/botbuilder-core/tests/requirements.txt @@ -0,0 +1 @@ +requests_mock>=1.7.0 \ No newline at end of file diff --git a/libraries/botbuilder-core/tests/test_inspection_middleware.py b/libraries/botbuilder-core/tests/test_inspection_middleware.py new file mode 100644 index 000000000..34d90463b --- /dev/null +++ b/libraries/botbuilder-core/tests/test_inspection_middleware.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from json import loads +import aiounittest +import requests_mock + +from botbuilder.core import ( + ConversationState, + MemoryStorage, + MessageFactory, + TurnContext, + UserState, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.core.inspection import InspectionMiddleware, InspectionState +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, Mention + + +class TestConversationState(aiounittest.AsyncTestCase): + async def test_scenario_with_inspection_middlware_passthrough(self): + inspection_state = InspectionState(MemoryStorage()) + inspection_middleware = InspectionMiddleware(inspection_state) + + adapter = TestAdapter() + adapter.use(inspection_middleware) + + inbound_activity = MessageFactory.text("hello") + + async def aux_func(context: TurnContext): + await context.send_activity(MessageFactory.text("hi")) + + await adapter.process_activity(inbound_activity, aux_func) + + outbound_activity = adapter.activity_buffer.pop(0) + + assert outbound_activity.text, "hi" + + async def test_should_replicate_activity_data_to_listening_emulator_following_open_and_attach( + self + ): + inbound_expectation, outbound_expectation, state_expectation = ( + False, + False, + False, + ) + + with requests_mock.Mocker() as mocker: + # set up our expectations in nock - each corresponds to a trace message we expect to receive in the emulator + + def match_response(request): + nonlocal inbound_expectation, outbound_expectation, state_expectation + r_json = loads(request.text) + if r_json.get("type", None) != "trace": + return None + + if r_json.get("value", {}).get("text", None) == "hi": + inbound_expectation = True + return inbound_expectation + if r_json.get("value", {}).get("text", None) == "echo: hi": + outbound_expectation = True + return outbound_expectation + + x_property = ( + r_json.get("value", {}) + .get("user_state", {}) + .get("x", {}) + .get("property", None) + ) + y_property = ( + r_json.get("value", {}) + .get("conversation_state", {}) + .get("y", {}) + .get("property", None) + ) + state_expectation = x_property == "hello" and y_property == "world" + return state_expectation + + mocker.post( + "https://test.com/v3/conversations/Convo1/activities", + additional_matcher=match_response, + json={"id": "test"}, + status_code=200, + ) + + # create the various storage and middleware objects we will be using + + storage = MemoryStorage() + inspection_state = InspectionState(storage) + user_state = UserState(storage) + conversation_state = ConversationState(storage) + inspection_middleware = InspectionMiddleware( + inspection_state, user_state, conversation_state + ) + + # the emulator sends an /INSPECT open command - we can use another adapter here + + open_activity = MessageFactory.text("/INSPECT open") + + async def exec_test(turn_context): + await inspection_middleware.process_command(turn_context) + + inspection_adapter = TestAdapter(exec_test, None, True) + + await inspection_adapter.receive_activity(open_activity) + + inspection_open_result_activity = inspection_adapter.activity_buffer[0] + attach_command = inspection_open_result_activity.value + + # the logic of teh bot including replying with a message and updating user and conversation state + + x_prop = user_state.create_property("x") + y_prop = conversation_state.create_property("y") + + async def exec_test2(turn_context): + + await turn_context.send_activity( + MessageFactory.text(f"echo: { turn_context.activity.text }") + ) + + (await x_prop.get(turn_context, {"property": ""}))["property"] = "hello" + (await y_prop.get(turn_context, {"property": ""}))["property"] = "world" + + await user_state.save_changes(turn_context) + await conversation_state.save_changes(turn_context) + + application_adapter = TestAdapter(exec_test2, None, True) + + # IMPORTANT add the InspectionMiddleware to the adapter that is running our bot + + application_adapter.use(inspection_middleware) + + await application_adapter.receive_activity( + MessageFactory.text(attach_command) + ) + + # the attach command response is a informational message + + await application_adapter.receive_activity(MessageFactory.text("hi")) + + # trace activities should be sent to the emulator using the connector and the conversation reference + + # verify that all our expectations have been met + assert inbound_expectation + assert outbound_expectation + assert state_expectation + assert mocker.call_count, 3 + + async def test_should_replicate_activity_data_to_listening_emulator_following_open_and_attach_with_at_mention( + self + ): + inbound_expectation, outbound_expectation, state_expectation = ( + False, + False, + False, + ) + + with requests_mock.Mocker() as mocker: + # set up our expectations in nock - each corresponds to a trace message we expect to receive in the emulator + + def match_response(request): + nonlocal inbound_expectation, outbound_expectation, state_expectation + r_json = loads(request.text) + if r_json.get("type", None) != "trace": + return None + + if r_json.get("value", {}).get("text", None) == "hi": + inbound_expectation = True + return inbound_expectation + if r_json.get("value", {}).get("text", None) == "echo: hi": + outbound_expectation = True + return outbound_expectation + + x_property = ( + r_json.get("value", {}) + .get("user_state", {}) + .get("x", {}) + .get("property", None) + ) + y_property = ( + r_json.get("value", {}) + .get("conversation_state", {}) + .get("y", {}) + .get("property", None) + ) + state_expectation = x_property == "hello" and y_property == "world" + return state_expectation + + mocker.post( + "https://test.com/v3/conversations/Convo1/activities", + additional_matcher=match_response, + json={"id": "test"}, + status_code=200, + ) + + # create the various storage and middleware objects we will be using + + storage = MemoryStorage() + inspection_state = InspectionState(storage) + user_state = UserState(storage) + conversation_state = ConversationState(storage) + inspection_middleware = InspectionMiddleware( + inspection_state, user_state, conversation_state + ) + + # the emulator sends an /INSPECT open command - we can use another adapter here + + open_activity = MessageFactory.text("/INSPECT open") + + async def exec_test(turn_context): + await inspection_middleware.process_command(turn_context) + + inspection_adapter = TestAdapter(exec_test, None, True) + + await inspection_adapter.receive_activity(open_activity) + + inspection_open_result_activity = inspection_adapter.activity_buffer[0] + + recipient_id = "bot" + attach_command = ( + f"{ recipient_id } { inspection_open_result_activity.value }" + ) + + # the logic of teh bot including replying with a message and updating user and conversation state + + x_prop = user_state.create_property("x") + y_prop = conversation_state.create_property("y") + + async def exec_test2(turn_context): + + await turn_context.send_activity( + MessageFactory.text(f"echo: {turn_context.activity.text}") + ) + + (await x_prop.get(turn_context, {"property": ""}))["property"] = "hello" + (await y_prop.get(turn_context, {"property": ""}))["property"] = "world" + + await user_state.save_changes(turn_context) + await conversation_state.save_changes(turn_context) + + application_adapter = TestAdapter(exec_test2, None, True) + + # IMPORTANT add the InspectionMiddleware to the adapter that is running our bot + + application_adapter.use(inspection_middleware) + + attach_activity = Activity( + type=ActivityTypes.message, + text=attach_command, + recipient=ChannelAccount(id=recipient_id), + entities=[ + Mention( + type="mention", + text=f"{recipient_id}", + mentioned=ChannelAccount(name="Bot", id=recipient_id), + ) + ], + ) + await application_adapter.receive_activity(attach_activity) + + # the attach command response is a informational message + + await application_adapter.receive_activity(MessageFactory.text("hi")) + + # trace activities should be sent to the emulator using the connector and the conversation reference + + # verify that all our expectations have been met + assert inbound_expectation + assert outbound_expectation + assert state_expectation + assert mocker.call_count, 3