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