diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 5b3299d74..f316584a9 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -1,383 +1,383 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import re -from copy import copy, deepcopy -from datetime import datetime -from typing import List, Callable, Union, Dict -from botbuilder.schema import ( - Activity, - ActivityTypes, - ConversationReference, - InputHints, - Mention, - ResourceResponse, -) - - -class TurnContext: - def __init__(self, adapter_or_context, request: Activity = None): - """ - Creates a new TurnContext instance. - :param adapter_or_context: - :param request: - """ - if isinstance(adapter_or_context, TurnContext): - adapter_or_context.copy_to(self) - else: - self.adapter = adapter_or_context - self._activity = request - self.responses: List[Activity] = [] - self._services: dict = {} - self._on_send_activities: Callable[ - ["TurnContext", List[Activity], Callable], List[ResourceResponse] - ] = [] - self._on_update_activity: Callable[ - ["TurnContext", Activity, Callable], ResourceResponse - ] = [] - self._on_delete_activity: Callable[ - ["TurnContext", ConversationReference, Callable], None - ] = [] - self._responded: bool = False - - if self.adapter is None: - raise TypeError("TurnContext must be instantiated with an adapter.") - if self.activity is None: - raise TypeError( - "TurnContext must be instantiated with a request parameter of type Activity." - ) - - self._turn_state = {} - - @property - def turn_state(self) -> Dict[str, object]: - return self._turn_state - - def copy_to(self, context: "TurnContext") -> None: - """ - Called when this TurnContext instance is passed into the constructor of a new TurnContext - instance. Can be overridden in derived classes. - :param context: - :return: - """ - for attribute in [ - "adapter", - "activity", - "_responded", - "_services", - "_on_send_activities", - "_on_update_activity", - "_on_delete_activity", - ]: - setattr(context, attribute, getattr(self, attribute)) - - @property - def activity(self): - """ - The received activity. - :return: - """ - return self._activity - - @activity.setter - def activity(self, value): - """ - Used to set TurnContext._activity when a context object is created. Only takes instances of Activities. - :param value: - :return: - """ - if not isinstance(value, Activity): - raise TypeError( - "TurnContext: cannot set `activity` to a type other than Activity." - ) - self._activity = value - - @property - def responded(self) -> bool: - """ - If `true` at least one response has been sent for the current turn of conversation. - :return: - """ - return self._responded - - @responded.setter - def responded(self, value: bool): - if not value: - raise ValueError("TurnContext: cannot set TurnContext.responded to False.") - self._responded = True - - @property - def services(self): - """ - Map of services and other values cached for the lifetime of the turn. - :return: - """ - return self._services - - def get(self, key: str) -> object: - if not key or not isinstance(key, str): - raise TypeError('"key" must be a valid string.') - try: - return self._services[key] - except KeyError: - raise KeyError("%s not found in TurnContext._services." % key) - - def has(self, key: str) -> bool: - """ - Returns True is set() has been called for a key. The cached value may be of type 'None'. - :param key: - :return: - """ - if key in self._services: - return True - return False - - def set(self, key: str, value: object) -> None: - """ - Caches a value for the lifetime of the current turn. - :param key: - :param value: - :return: - """ - if not key or not isinstance(key, str): - raise KeyError('"key" must be a valid string.') - - self._services[key] = value - - async def send_activity( - self, - activity_or_text: Union[Activity, str], - speak: str = None, - input_hint: str = None, - ) -> ResourceResponse: - """ - Sends a single activity or message to the user. - :param activity_or_text: - :return: - """ - if isinstance(activity_or_text, str): - activity_or_text = Activity( - text=activity_or_text, - input_hint=input_hint or InputHints.accepting_input, - speak=speak, - ) - - result = await self.send_activities([activity_or_text]) - return result[0] if result else None - - async def send_activities( - self, activities: List[Activity] - ) -> List[ResourceResponse]: - sent_non_trace_activity = False - ref = TurnContext.get_conversation_reference(self.activity) - - def activity_validator(activity: Activity) -> Activity: - if not getattr(activity, "type", None): - activity.type = ActivityTypes.message - if activity.type != ActivityTypes.trace: - nonlocal sent_non_trace_activity - sent_non_trace_activity = True - if not activity.input_hint: - activity.input_hint = "acceptingInput" - activity.id = None - return activity - - output = [ - activity_validator( - TurnContext.apply_conversation_reference(deepcopy(act), ref) - ) - for act in activities - ] - - async def logic(): - responses = await self.adapter.send_activities(self, output) - if sent_non_trace_activity: - self.responded = True - return responses - - return await self._emit(self._on_send_activities, output, logic()) - - async def update_activity(self, activity: Activity): - """ - Replaces an existing activity. - :param activity: - :return: - """ - reference = TurnContext.get_conversation_reference(self.activity) - - return await self._emit( - self._on_update_activity, - TurnContext.apply_conversation_reference(activity, reference), - self.adapter.update_activity(self, activity), - ) - - async def delete_activity(self, id_or_reference: Union[str, ConversationReference]): - """ - Deletes an existing activity. - :param id_or_reference: - :return: - """ - if isinstance(id_or_reference, str): - reference = TurnContext.get_conversation_reference(self.activity) - reference.activity_id = id_or_reference - else: - reference = id_or_reference - return await self._emit( - self._on_delete_activity, - reference, - self.adapter.delete_activity(self, reference), - ) - - def on_send_activities(self, handler) -> "TurnContext": - """ - Registers a handler to be notified of and potentially intercept the sending of activities. - :param handler: - :return: - """ - self._on_send_activities.append(handler) - return self - - def on_update_activity(self, handler) -> "TurnContext": - """ - Registers a handler to be notified of and potentially intercept an activity being updated. - :param handler: - :return: - """ - self._on_update_activity.append(handler) - return self - - def on_delete_activity(self, handler) -> "TurnContext": - """ - Registers a handler to be notified of and potentially intercept an activity being deleted. - :param handler: - :return: - """ - self._on_delete_activity.append(handler) - return self - - async def _emit(self, plugins, arg, logic): - handlers = copy(plugins) - - async def emit_next(i: int): - context = self - try: - if i < len(handlers): - - async def next_handler(): - await emit_next(i + 1) - - await handlers[i](context, arg, next_handler) - - except Exception as error: - raise error - - await emit_next(0) - # logic does not use parentheses because it's a coroutine - return await logic - - async def send_trace_activity( - self, name: str, value: object, value_type: str, label: str - ) -> ResourceResponse: - trace_activity = Activity( - type=ActivityTypes.trace, - timestamp=datetime.utcnow(), - name=name, - value=value, - value_type=value_type, - label=label, - ) - - return await self.send_activity(trace_activity) - - @staticmethod - def get_conversation_reference(activity: Activity) -> ConversationReference: - """ - Returns the conversation reference for an activity. This can be saved as a plain old JSON - object and then later used to message the user proactively. - - Usage Example: - reference = TurnContext.get_conversation_reference(context.request) - :param activity: - :return: - """ - return ConversationReference( - activity_id=activity.id, - user=copy(activity.from_property), - bot=copy(activity.recipient), - conversation=copy(activity.conversation), - channel_id=activity.channel_id, - service_url=activity.service_url, - ) - - @staticmethod - def apply_conversation_reference( - activity: Activity, reference: ConversationReference, is_incoming: bool = False - ) -> Activity: - """ - Updates an activity with the delivery information from a conversation reference. Calling - this after get_conversation_reference on an incoming activity - will properly address the reply to a received activity. - :param activity: - :param reference: - :param is_incoming: - :return: - """ - activity.channel_id = reference.channel_id - activity.service_url = reference.service_url - activity.conversation = reference.conversation - if is_incoming: - activity.from_property = reference.user - activity.recipient = reference.bot - if reference.activity_id: - activity.id = reference.activity_id - else: - activity.from_property = reference.bot - activity.recipient = reference.user - if reference.activity_id: - activity.reply_to_id = reference.activity_id - - return activity - - @staticmethod - def get_reply_conversation_reference( - activity: Activity, reply: ResourceResponse - ) -> ConversationReference: - reference: ConversationReference = TurnContext.get_conversation_reference( - activity - ) - - # Update the reference with the new outgoing Activity's id. - reference.activity_id = reply.id - - return reference - - @staticmethod - def remove_recipient_mention(activity: Activity) -> str: - return TurnContext.remove_mention_text(activity, activity.recipient.id) - - @staticmethod - def remove_mention_text(activity: Activity, identifier: str) -> str: - mentions = TurnContext.get_mentions(activity) - for mention in mentions: - if mention.additional_properties["mentioned"]["id"] == identifier: - mention_name_match = re.match( - r"(.*?)<\/at>", - mention.additional_properties["text"], - re.IGNORECASE, - ) - if mention_name_match: - activity.text = re.sub( - mention_name_match.groups()[1], "", activity.text - ) - activity.text = re.sub(r"<\/at>", "", activity.text) - return activity.text - - @staticmethod - def get_mentions(activity: Activity) -> List[Mention]: - result: List[Mention] = [] - if activity.entities is not None: - for entity in activity.entities: - if entity.type.lower() == "mention": - result.append(entity) - - return result +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +from copy import copy, deepcopy +from datetime import datetime +from typing import List, Callable, Union, Dict +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + InputHints, + Mention, + ResourceResponse, +) + + +class TurnContext: + def __init__(self, adapter_or_context, request: Activity = None): + """ + Creates a new TurnContext instance. + :param adapter_or_context: + :param request: + """ + if isinstance(adapter_or_context, TurnContext): + adapter_or_context.copy_to(self) + else: + self.adapter = adapter_or_context + self._activity = request + self.responses: List[Activity] = [] + self._services: dict = {} + self._on_send_activities: Callable[ + ["TurnContext", List[Activity], Callable], List[ResourceResponse] + ] = [] + self._on_update_activity: Callable[ + ["TurnContext", Activity, Callable], ResourceResponse + ] = [] + self._on_delete_activity: Callable[ + ["TurnContext", ConversationReference, Callable], None + ] = [] + self._responded: bool = False + + if self.adapter is None: + raise TypeError("TurnContext must be instantiated with an adapter.") + if self.activity is None: + raise TypeError( + "TurnContext must be instantiated with a request parameter of type Activity." + ) + + self._turn_state = {} + + @property + def turn_state(self) -> Dict[str, object]: + return self._turn_state + + def copy_to(self, context: "TurnContext") -> None: + """ + Called when this TurnContext instance is passed into the constructor of a new TurnContext + instance. Can be overridden in derived classes. + :param context: + :return: + """ + for attribute in [ + "adapter", + "activity", + "_responded", + "_services", + "_on_send_activities", + "_on_update_activity", + "_on_delete_activity", + ]: + setattr(context, attribute, getattr(self, attribute)) + + @property + def activity(self): + """ + The received activity. + :return: + """ + return self._activity + + @activity.setter + def activity(self, value): + """ + Used to set TurnContext._activity when a context object is created. Only takes instances of Activities. + :param value: + :return: + """ + if not isinstance(value, Activity): + raise TypeError( + "TurnContext: cannot set `activity` to a type other than Activity." + ) + self._activity = value + + @property + def responded(self) -> bool: + """ + If `true` at least one response has been sent for the current turn of conversation. + :return: + """ + return self._responded + + @responded.setter + def responded(self, value: bool): + if not value: + raise ValueError("TurnContext: cannot set TurnContext.responded to False.") + self._responded = True + + @property + def services(self): + """ + Map of services and other values cached for the lifetime of the turn. + :return: + """ + return self._services + + def get(self, key: str) -> object: + if not key or not isinstance(key, str): + raise TypeError('"key" must be a valid string.') + try: + return self._services[key] + except KeyError: + raise KeyError("%s not found in TurnContext._services." % key) + + def has(self, key: str) -> bool: + """ + Returns True is set() has been called for a key. The cached value may be of type 'None'. + :param key: + :return: + """ + if key in self._services: + return True + return False + + def set(self, key: str, value: object) -> None: + """ + Caches a value for the lifetime of the current turn. + :param key: + :param value: + :return: + """ + if not key or not isinstance(key, str): + raise KeyError('"key" must be a valid string.') + + self._services[key] = value + + async def send_activity( + self, + activity_or_text: Union[Activity, str], + speak: str = None, + input_hint: str = None, + ) -> ResourceResponse: + """ + Sends a single activity or message to the user. + :param activity_or_text: + :return: + """ + if isinstance(activity_or_text, str): + activity_or_text = Activity( + text=activity_or_text, + input_hint=input_hint or InputHints.accepting_input, + speak=speak, + ) + + result = await self.send_activities([activity_or_text]) + return result[0] if result else None + + async def send_activities( + self, activities: List[Activity] + ) -> List[ResourceResponse]: + sent_non_trace_activity = False + ref = TurnContext.get_conversation_reference(self.activity) + + def activity_validator(activity: Activity) -> Activity: + if not getattr(activity, "type", None): + activity.type = ActivityTypes.message + if activity.type != ActivityTypes.trace: + nonlocal sent_non_trace_activity + sent_non_trace_activity = True + if not activity.input_hint: + activity.input_hint = "acceptingInput" + activity.id = None + return activity + + output = [ + activity_validator( + TurnContext.apply_conversation_reference(deepcopy(act), ref) + ) + for act in activities + ] + + async def logic(): + responses = await self.adapter.send_activities(self, output) + if sent_non_trace_activity: + self.responded = True + return responses + + return await self._emit(self._on_send_activities, output, logic()) + + async def update_activity(self, activity: Activity): + """ + Replaces an existing activity. + :param activity: + :return: + """ + reference = TurnContext.get_conversation_reference(self.activity) + + return await self._emit( + self._on_update_activity, + TurnContext.apply_conversation_reference(activity, reference), + self.adapter.update_activity(self, activity), + ) + + async def delete_activity(self, id_or_reference: Union[str, ConversationReference]): + """ + Deletes an existing activity. + :param id_or_reference: + :return: + """ + if isinstance(id_or_reference, str): + reference = TurnContext.get_conversation_reference(self.activity) + reference.activity_id = id_or_reference + else: + reference = id_or_reference + return await self._emit( + self._on_delete_activity, + reference, + self.adapter.delete_activity(self, reference), + ) + + def on_send_activities(self, handler) -> "TurnContext": + """ + Registers a handler to be notified of and potentially intercept the sending of activities. + :param handler: + :return: + """ + self._on_send_activities.append(handler) + return self + + def on_update_activity(self, handler) -> "TurnContext": + """ + Registers a handler to be notified of and potentially intercept an activity being updated. + :param handler: + :return: + """ + self._on_update_activity.append(handler) + return self + + def on_delete_activity(self, handler) -> "TurnContext": + """ + Registers a handler to be notified of and potentially intercept an activity being deleted. + :param handler: + :return: + """ + self._on_delete_activity.append(handler) + return self + + async def _emit(self, plugins, arg, logic): + handlers = copy(plugins) + + async def emit_next(i: int): + context = self + try: + if i < len(handlers): + + async def next_handler(): + await emit_next(i + 1) + + await handlers[i](context, arg, next_handler) + + except Exception as error: + raise error + + await emit_next(0) + # logic does not use parentheses because it's a coroutine + return await logic + + async def send_trace_activity( + self, name: str, value: object, value_type: str, label: str + ) -> ResourceResponse: + trace_activity = Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + name=name, + value=value, + value_type=value_type, + label=label, + ) + + return await self.send_activity(trace_activity) + + @staticmethod + def get_conversation_reference(activity: Activity) -> ConversationReference: + """ + Returns the conversation reference for an activity. This can be saved as a plain old JSON + object and then later used to message the user proactively. + + Usage Example: + reference = TurnContext.get_conversation_reference(context.request) + :param activity: + :return: + """ + return ConversationReference( + activity_id=activity.id, + user=copy(activity.from_property), + bot=copy(activity.recipient), + conversation=copy(activity.conversation), + channel_id=activity.channel_id, + service_url=activity.service_url, + ) + + @staticmethod + def apply_conversation_reference( + activity: Activity, reference: ConversationReference, is_incoming: bool = False + ) -> Activity: + """ + Updates an activity with the delivery information from a conversation reference. Calling + this after get_conversation_reference on an incoming activity + will properly address the reply to a received activity. + :param activity: + :param reference: + :param is_incoming: + :return: + """ + activity.channel_id = reference.channel_id + activity.service_url = reference.service_url + activity.conversation = reference.conversation + if is_incoming: + activity.from_property = reference.user + activity.recipient = reference.bot + if reference.activity_id: + activity.id = reference.activity_id + else: + activity.from_property = reference.bot + activity.recipient = reference.user + if reference.activity_id: + activity.reply_to_id = reference.activity_id + + return activity + + @staticmethod + def get_reply_conversation_reference( + activity: Activity, reply: ResourceResponse + ) -> ConversationReference: + reference: ConversationReference = TurnContext.get_conversation_reference( + activity + ) + + # Update the reference with the new outgoing Activity's id. + reference.activity_id = reply.id + + return reference + + @staticmethod + def remove_recipient_mention(activity: Activity) -> str: + return TurnContext.remove_mention_text(activity, activity.recipient.id) + + @staticmethod + def remove_mention_text(activity: Activity, identifier: str) -> str: + mentions = TurnContext.get_mentions(activity) + for mention in mentions: + if mention.additional_properties["mentioned"]["id"] == identifier: + mention_name_match = re.match( + r"(.*?)<\/at>", + re.escape(mention.additional_properties["text"]), + re.IGNORECASE, + ) + if mention_name_match: + activity.text = re.sub( + mention_name_match.groups()[1], "", activity.text + ) + activity.text = re.sub(r"<\/at>", "", activity.text) + return activity.text + + @staticmethod + def get_mentions(activity: Activity) -> List[Mention]: + result: List[Mention] = [] + if activity.entities is not None: + for entity in activity.entities: + if entity.type.lower() == "mention": + result.append(entity) + + return result diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 381287d3d..be48fdc04 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -1,351 +1,372 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Callable, List -import aiounittest - -from botbuilder.schema import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - Entity, - Mention, - ResourceResponse, -) -from botbuilder.core import BotAdapter, MessageFactory, TurnContext - -ACTIVITY = Activity( - id="1234", - type="message", - text="test", - from_property=ChannelAccount(id="user", name="User Name"), - recipient=ChannelAccount(id="bot", name="Bot Name"), - conversation=ConversationAccount(id="convo", name="Convo Name"), - channel_id="UnitTest", - service_url="https://example.org", -) - - -class SimpleAdapter(BotAdapter): - async def send_activities(self, context, activities) -> List[ResourceResponse]: - responses = [] - assert context is not None - assert activities is not None - assert isinstance(activities, list) - assert activities - for (idx, activity) in enumerate(activities): # pylint: disable=unused-variable - assert isinstance(activity, Activity) - assert activity.type == "message" or activity.type == ActivityTypes.trace - responses.append(ResourceResponse(id="5678")) - return responses - - async def update_activity(self, context, activity): - assert context is not None - assert activity is not None - return ResourceResponse(id=activity.id) - - async def delete_activity(self, context, reference): - assert context is not None - assert reference is not None - assert reference.activity_id == ACTIVITY.id - - -class TestBotContext(aiounittest.AsyncTestCase): - def test_should_create_context_with_request_and_adapter(self): - TurnContext(SimpleAdapter(), ACTIVITY) - - def test_should_not_create_context_without_request(self): - try: - TurnContext(SimpleAdapter(), None) - except TypeError: - pass - except Exception as error: - raise error - - def test_should_not_create_context_without_adapter(self): - try: - TurnContext(None, ACTIVITY) - except TypeError: - pass - except Exception as error: - raise error - - def test_should_create_context_with_older_context(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - TurnContext(context) - - def test_copy_to_should_copy_all_references(self): - # pylint: disable=protected-access - old_adapter = SimpleAdapter() - old_activity = Activity(id="2", type="message", text="test copy") - old_context = TurnContext(old_adapter, old_activity) - old_context.responded = True - - async def send_activities_handler(context, activities, next_handler): - assert context is not None - assert activities is not None - assert next_handler is not None - await next_handler - - async def delete_activity_handler(context, reference, next_handler): - assert context is not None - assert reference is not None - assert next_handler is not None - await next_handler - - async def update_activity_handler(context, activity, next_handler): - assert context is not None - assert activity is not None - assert next_handler is not None - await next_handler - - old_context.on_send_activities(send_activities_handler) - old_context.on_delete_activity(delete_activity_handler) - old_context.on_update_activity(update_activity_handler) - - adapter = SimpleAdapter() - new_context = TurnContext(adapter, ACTIVITY) - assert not new_context._on_send_activities # pylint: disable=protected-access - assert not new_context._on_update_activity # pylint: disable=protected-access - assert not new_context._on_delete_activity # pylint: disable=protected-access - - old_context.copy_to(new_context) - - assert new_context.adapter == old_adapter - assert new_context.activity == old_activity - assert new_context.responded is True - assert ( - len(new_context._on_send_activities) == 1 - ) # pylint: disable=protected-access - assert ( - len(new_context._on_update_activity) == 1 - ) # pylint: disable=protected-access - assert ( - len(new_context._on_delete_activity) == 1 - ) # pylint: disable=protected-access - - def test_responded_should_be_automatically_set_to_false(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - assert context.responded is False - - def test_should_be_able_to_set_responded_to_true(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - assert context.responded is False - context.responded = True - assert context.responded - - def test_should_not_be_able_to_set_responded_to_false(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - try: - context.responded = False - except ValueError: - pass - except Exception as error: - raise error - - async def test_should_call_on_delete_activity_handlers_before_deletion(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def delete_handler(context, reference, next_handler_coroutine): - nonlocal called - called = True - assert reference is not None - assert context is not None - assert reference.activity_id == "1234" - await next_handler_coroutine() - - context.on_delete_activity(delete_handler) - await context.delete_activity(ACTIVITY.id) - assert called is True - - async def test_should_call_multiple_on_delete_activity_handlers_in_order(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called_first = False - called_second = False - - async def first_delete_handler(context, reference, next_handler_coroutine): - nonlocal called_first, called_second - assert ( - called_first is False - ), "called_first should not be True before first_delete_handler is called." - called_first = True - assert ( - called_second is False - ), "Second on_delete_activity handler was called before first." - assert reference is not None - assert context is not None - assert reference.activity_id == "1234" - await next_handler_coroutine() - - async def second_delete_handler(context, reference, next_handler_coroutine): - nonlocal called_first, called_second - assert called_first - assert ( - called_second is False - ), "called_second was set to True before second handler was called." - called_second = True - assert reference is not None - assert context is not None - assert reference.activity_id == "1234" - await next_handler_coroutine() - - context.on_delete_activity(first_delete_handler) - context.on_delete_activity(second_delete_handler) - await context.delete_activity(ACTIVITY.id) - assert called_first is True - assert called_second is True - - async def test_should_call_send_on_activities_handler_before_send(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def send_handler(context, activities, next_handler_coroutine): - nonlocal called - called = True - assert activities is not None - assert context is not None - assert not activities[0].id - await next_handler_coroutine() - - context.on_send_activities(send_handler) - await context.send_activity(ACTIVITY) - assert called is True - - async def test_should_call_on_update_activity_handler_before_update(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def update_handler(context, activity, next_handler_coroutine): - nonlocal called - called = True - assert activity is not None - assert context is not None - assert activity.id == "1234" - await next_handler_coroutine() - - context.on_update_activity(update_handler) - await context.update_activity(ACTIVITY) - assert called is True - - async def test_update_activity_should_apply_conversation_reference(self): - activity_id = "activity ID" - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - async def update_handler(context, activity, next_handler_coroutine): - nonlocal called - called = True - assert context is not None - assert activity.id == activity_id - assert activity.conversation.id == ACTIVITY.conversation.id - await next_handler_coroutine() - - context.on_update_activity(update_handler) - new_activity = MessageFactory.text("test text") - new_activity.id = activity_id - update_result = await context.update_activity(new_activity) - assert called is True - assert update_result.id == activity_id - - def test_get_conversation_reference_should_return_valid_reference(self): - reference = TurnContext.get_conversation_reference(ACTIVITY) - - assert reference.activity_id == ACTIVITY.id - assert reference.user == ACTIVITY.from_property - assert reference.bot == ACTIVITY.recipient - assert reference.conversation == ACTIVITY.conversation - assert reference.channel_id == ACTIVITY.channel_id - assert reference.service_url == ACTIVITY.service_url - - def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( - self, - ): - reference = TurnContext.get_conversation_reference(ACTIVITY) - reply = TurnContext.apply_conversation_reference( - Activity(type="message", text="reply"), reference - ) - - assert reply.recipient == ACTIVITY.from_property - assert reply.from_property == ACTIVITY.recipient - assert reply.conversation == ACTIVITY.conversation - assert reply.service_url == ACTIVITY.service_url - assert reply.channel_id == ACTIVITY.channel_id - - def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepare_a_reply( - self, - ): - reference = TurnContext.get_conversation_reference(ACTIVITY) - reply = TurnContext.apply_conversation_reference( - Activity(type="message", text="reply"), reference, True - ) - - assert reply.recipient == ACTIVITY.recipient - assert reply.from_property == ACTIVITY.from_property - assert reply.conversation == ACTIVITY.conversation - assert reply.service_url == ACTIVITY.service_url - assert reply.channel_id == ACTIVITY.channel_id - - async def test_should_get_conversation_reference_using_get_reply_conversation_reference( - self, - ): - context = TurnContext(SimpleAdapter(), ACTIVITY) - reply = await context.send_activity("test") - - assert reply.id, "reply has an id" - - reference = TurnContext.get_reply_conversation_reference( - context.activity, reply - ) - - assert reference.activity_id, "reference has an activity id" - assert ( - reference.activity_id == reply.id - ), "reference id matches outgoing reply id" - - def test_should_remove_at_mention_from_activity(self): - activity = Activity( - type="message", - text="TestOAuth619 test activity", - recipient=ChannelAccount(id="TestOAuth619"), - entities=[ - Entity().deserialize( - Mention( - type="mention", - text="TestOAuth619", - mentioned=ChannelAccount(name="Bot", id="TestOAuth619"), - ).serialize() - ) - ], - ) - - text = TurnContext.remove_recipient_mention(activity) - - assert text, " test activity" - assert activity.text, " test activity" - - async def test_should_send_a_trace_activity(self): - context = TurnContext(SimpleAdapter(), ACTIVITY) - called = False - - # pylint: disable=unused-argument - async def aux_func( - ctx: TurnContext, activities: List[Activity], next: Callable - ): - nonlocal called - called = True - assert isinstance(activities, list), "activities not array." - assert len(activities) == 1, "invalid count of activities." - assert activities[0].type == ActivityTypes.trace, "type wrong." - assert activities[0].name == "name-text", "name wrong." - assert activities[0].value == "value-text", "value worng." - assert activities[0].value_type == "valueType-text", "valeuType wrong." - assert activities[0].label == "label-text", "label wrong." - return [] - - context.on_send_activities(aux_func) - await context.send_trace_activity( - "name-text", "value-text", "valueType-text", "label-text" - ) - assert called +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Callable, List +import aiounittest + +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + Entity, + Mention, + ResourceResponse, +) +from botbuilder.core import BotAdapter, MessageFactory, TurnContext + +ACTIVITY = Activity( + id="1234", + type="message", + text="test", + from_property=ChannelAccount(id="user", name="User Name"), + recipient=ChannelAccount(id="bot", name="Bot Name"), + conversation=ConversationAccount(id="convo", name="Convo Name"), + channel_id="UnitTest", + service_url="https://example.org", +) + + +class SimpleAdapter(BotAdapter): + async def send_activities(self, context, activities) -> List[ResourceResponse]: + responses = [] + assert context is not None + assert activities is not None + assert isinstance(activities, list) + assert activities + for (idx, activity) in enumerate(activities): # pylint: disable=unused-variable + assert isinstance(activity, Activity) + assert activity.type == "message" or activity.type == ActivityTypes.trace + responses.append(ResourceResponse(id="5678")) + return responses + + async def update_activity(self, context, activity): + assert context is not None + assert activity is not None + return ResourceResponse(id=activity.id) + + async def delete_activity(self, context, reference): + assert context is not None + assert reference is not None + assert reference.activity_id == ACTIVITY.id + + +class TestBotContext(aiounittest.AsyncTestCase): + def test_should_create_context_with_request_and_adapter(self): + TurnContext(SimpleAdapter(), ACTIVITY) + + def test_should_not_create_context_without_request(self): + try: + TurnContext(SimpleAdapter(), None) + except TypeError: + pass + except Exception as error: + raise error + + def test_should_not_create_context_without_adapter(self): + try: + TurnContext(None, ACTIVITY) + except TypeError: + pass + except Exception as error: + raise error + + def test_should_create_context_with_older_context(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + TurnContext(context) + + def test_copy_to_should_copy_all_references(self): + # pylint: disable=protected-access + old_adapter = SimpleAdapter() + old_activity = Activity(id="2", type="message", text="test copy") + old_context = TurnContext(old_adapter, old_activity) + old_context.responded = True + + async def send_activities_handler(context, activities, next_handler): + assert context is not None + assert activities is not None + assert next_handler is not None + await next_handler + + async def delete_activity_handler(context, reference, next_handler): + assert context is not None + assert reference is not None + assert next_handler is not None + await next_handler + + async def update_activity_handler(context, activity, next_handler): + assert context is not None + assert activity is not None + assert next_handler is not None + await next_handler + + old_context.on_send_activities(send_activities_handler) + old_context.on_delete_activity(delete_activity_handler) + old_context.on_update_activity(update_activity_handler) + + adapter = SimpleAdapter() + new_context = TurnContext(adapter, ACTIVITY) + assert not new_context._on_send_activities # pylint: disable=protected-access + assert not new_context._on_update_activity # pylint: disable=protected-access + assert not new_context._on_delete_activity # pylint: disable=protected-access + + old_context.copy_to(new_context) + + assert new_context.adapter == old_adapter + assert new_context.activity == old_activity + assert new_context.responded is True + assert ( + len(new_context._on_send_activities) == 1 + ) # pylint: disable=protected-access + assert ( + len(new_context._on_update_activity) == 1 + ) # pylint: disable=protected-access + assert ( + len(new_context._on_delete_activity) == 1 + ) # pylint: disable=protected-access + + def test_responded_should_be_automatically_set_to_false(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + assert context.responded is False + + def test_should_be_able_to_set_responded_to_true(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + assert context.responded is False + context.responded = True + assert context.responded + + def test_should_not_be_able_to_set_responded_to_false(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + try: + context.responded = False + except ValueError: + pass + except Exception as error: + raise error + + async def test_should_call_on_delete_activity_handlers_before_deletion(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def delete_handler(context, reference, next_handler_coroutine): + nonlocal called + called = True + assert reference is not None + assert context is not None + assert reference.activity_id == "1234" + await next_handler_coroutine() + + context.on_delete_activity(delete_handler) + await context.delete_activity(ACTIVITY.id) + assert called is True + + async def test_should_call_multiple_on_delete_activity_handlers_in_order(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called_first = False + called_second = False + + async def first_delete_handler(context, reference, next_handler_coroutine): + nonlocal called_first, called_second + assert ( + called_first is False + ), "called_first should not be True before first_delete_handler is called." + called_first = True + assert ( + called_second is False + ), "Second on_delete_activity handler was called before first." + assert reference is not None + assert context is not None + assert reference.activity_id == "1234" + await next_handler_coroutine() + + async def second_delete_handler(context, reference, next_handler_coroutine): + nonlocal called_first, called_second + assert called_first + assert ( + called_second is False + ), "called_second was set to True before second handler was called." + called_second = True + assert reference is not None + assert context is not None + assert reference.activity_id == "1234" + await next_handler_coroutine() + + context.on_delete_activity(first_delete_handler) + context.on_delete_activity(second_delete_handler) + await context.delete_activity(ACTIVITY.id) + assert called_first is True + assert called_second is True + + async def test_should_call_send_on_activities_handler_before_send(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def send_handler(context, activities, next_handler_coroutine): + nonlocal called + called = True + assert activities is not None + assert context is not None + assert not activities[0].id + await next_handler_coroutine() + + context.on_send_activities(send_handler) + await context.send_activity(ACTIVITY) + assert called is True + + async def test_should_call_on_update_activity_handler_before_update(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def update_handler(context, activity, next_handler_coroutine): + nonlocal called + called = True + assert activity is not None + assert context is not None + assert activity.id == "1234" + await next_handler_coroutine() + + context.on_update_activity(update_handler) + await context.update_activity(ACTIVITY) + assert called is True + + async def test_update_activity_should_apply_conversation_reference(self): + activity_id = "activity ID" + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + async def update_handler(context, activity, next_handler_coroutine): + nonlocal called + called = True + assert context is not None + assert activity.id == activity_id + assert activity.conversation.id == ACTIVITY.conversation.id + await next_handler_coroutine() + + context.on_update_activity(update_handler) + new_activity = MessageFactory.text("test text") + new_activity.id = activity_id + update_result = await context.update_activity(new_activity) + assert called is True + assert update_result.id == activity_id + + def test_get_conversation_reference_should_return_valid_reference(self): + reference = TurnContext.get_conversation_reference(ACTIVITY) + + assert reference.activity_id == ACTIVITY.id + assert reference.user == ACTIVITY.from_property + assert reference.bot == ACTIVITY.recipient + assert reference.conversation == ACTIVITY.conversation + assert reference.channel_id == ACTIVITY.channel_id + assert reference.service_url == ACTIVITY.service_url + + def test_apply_conversation_reference_should_return_prepare_reply_when_is_incoming_is_false( + self, + ): + reference = TurnContext.get_conversation_reference(ACTIVITY) + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply"), reference + ) + + assert reply.recipient == ACTIVITY.from_property + assert reply.from_property == ACTIVITY.recipient + assert reply.conversation == ACTIVITY.conversation + assert reply.service_url == ACTIVITY.service_url + assert reply.channel_id == ACTIVITY.channel_id + + def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepare_a_reply( + self, + ): + reference = TurnContext.get_conversation_reference(ACTIVITY) + reply = TurnContext.apply_conversation_reference( + Activity(type="message", text="reply"), reference, True + ) + + assert reply.recipient == ACTIVITY.recipient + assert reply.from_property == ACTIVITY.from_property + assert reply.conversation == ACTIVITY.conversation + assert reply.service_url == ACTIVITY.service_url + assert reply.channel_id == ACTIVITY.channel_id + + async def test_should_get_conversation_reference_using_get_reply_conversation_reference( + self, + ): + context = TurnContext(SimpleAdapter(), ACTIVITY) + reply = await context.send_activity("test") + + assert reply.id, "reply has an id" + + reference = TurnContext.get_reply_conversation_reference( + context.activity, reply + ) + + assert reference.activity_id, "reference has an activity id" + assert ( + reference.activity_id == reply.id + ), "reference id matches outgoing reply id" + + def test_should_remove_at_mention_from_activity(self): + activity = Activity( + type="message", + text="TestOAuth619 test activity", + recipient=ChannelAccount(id="TestOAuth619"), + entities=[ + Entity().deserialize( + Mention( + type="mention", + text="TestOAuth619", + mentioned=ChannelAccount(name="Bot", id="TestOAuth619"), + ).serialize() + ) + ], + ) + + text = TurnContext.remove_recipient_mention(activity) + + assert text, " test activity" + assert activity.text, " test activity" + + def test_should_remove_at_mention_with_regex_characters(self): + activity = Activity( + type="message", + text="Test (*.[]$%#^&?) test activity", + recipient=ChannelAccount(id="Test (*.[]$%#^&?)"), + entities=[ + Entity().deserialize( + Mention( + type="mention", + text="Test (*.[]$%#^&?)", + mentioned=ChannelAccount(name="Bot", id="Test (*.[]$%#^&?)"), + ).serialize() + ) + ], + ) + + text = TurnContext.remove_recipient_mention(activity) + + assert text, " test activity" + assert activity.text, " test activity" + + async def test_should_send_a_trace_activity(self): + context = TurnContext(SimpleAdapter(), ACTIVITY) + called = False + + # pylint: disable=unused-argument + async def aux_func( + ctx: TurnContext, activities: List[Activity], next: Callable + ): + nonlocal called + called = True + assert isinstance(activities, list), "activities not array." + assert len(activities) == 1, "invalid count of activities." + assert activities[0].type == ActivityTypes.trace, "type wrong." + assert activities[0].name == "name-text", "name wrong." + assert activities[0].value == "value-text", "value worng." + assert activities[0].value_type == "valueType-text", "valeuType wrong." + assert activities[0].label == "label-text", "label wrong." + return [] + + context.on_send_activities(aux_func) + await context.send_trace_activity( + "name-text", "value-text", "valueType-text", "label-text" + ) + assert called