diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index d8acd678c..595871846 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -1,467 +1,494 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# TODO: enable this in the future -# With python 3.7 the line below will allow to do Postponed Evaluation of Annotations. See PEP 563 -# from __future__ import annotations - -import asyncio -import inspect -from datetime import datetime -from typing import Awaitable, Coroutine, Dict, List, Callable, Union -from copy import copy -from threading import Lock -from botbuilder.schema import ( - ActivityTypes, - Activity, - ConversationAccount, - ConversationReference, - ChannelAccount, - ResourceResponse, - TokenResponse, -) -from botframework.connector.auth import ClaimsIdentity -from ..bot_adapter import BotAdapter -from ..turn_context import TurnContext -from ..user_token_provider import UserTokenProvider - - -class UserToken: - def __init__( - self, - connection_name: str = None, - user_id: str = None, - channel_id: str = None, - token: str = None, - ): - self.connection_name = connection_name - self.user_id = user_id - self.channel_id = channel_id - self.token = token - - def equals_key(self, rhs: "UserToken"): - return ( - rhs is not None - and self.connection_name == rhs.connection_name - and self.user_id == rhs.user_id - and self.channel_id == rhs.channel_id - ) - - -class TokenMagicCode: - def __init__(self, key: UserToken = None, magic_code: str = None): - self.key = key - self.magic_code = magic_code - - -class TestAdapter(BotAdapter, UserTokenProvider): - __test__ = False - - def __init__( - self, - logic: Coroutine = None, - template_or_conversation: Union[Activity, ConversationReference] = None, - send_trace_activities: bool = False, - ): # pylint: disable=unused-argument - """ - Creates a new TestAdapter instance. - :param logic: - :param conversation: A reference to the conversation to begin the adapter state with. - """ - super(TestAdapter, self).__init__() - self.logic = logic - 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 = ( - template_or_conversation - if isinstance(template_or_conversation, Activity) - else 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 isinstance(template_or_conversation, ConversationReference): - self.template.channel_id = template_or_conversation.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] - ) -> List[ResourceResponse]: - """ - INTERNAL: called by the logic under test to send a set of activities. These will be buffered - to the current `TestFlow` instance for comparison against the expected results. - :param context: - :param activities: - :return: - """ - - def id_mapper(activity): - self.activity_buffer.append(activity) - self._next_id += 1 - return ResourceResponse(id=str(self._next_id)) - - 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): - """ - INTERNAL: called by the logic under test to delete an existing activity. These are simply - pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn - completes. - :param reference: - :return: - """ - self.deleted_activities.append(reference) - - async def update_activity(self, context, activity: Activity): - """ - INTERNAL: called by the logic under test to replace an existing activity. These are simply - pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn - completes. - :param activity: - :return: - """ - self.updated_activities.append(activity) - - async def continue_conversation( - self, - reference: ConversationReference, - callback: Callable, - bot_id: str = None, - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument - ): - """ - The `TestAdapter` just calls parent implementation. - :param reference: - :param callback: - :param bot_id: - :param claims_identity: - :return: - """ - await super().continue_conversation( - reference, callback, bot_id, claims_identity - ) - - async def receive_activity(self, activity): - """ - INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. - This will cause the adapters middleware pipe to be run and it's logic to be called. - :param activity: - :return: - """ - if isinstance(activity, str): - activity = Activity(type="message", text=activity) - # Initialize request. - request = copy(self.template) - - for key, value in vars(activity).items(): - if value is not None and key != "additional_properties": - setattr(request, key, value) - - request.type = request.type or ActivityTypes.message - if not request.id: - self._next_id += 1 - request.id = str(self._next_id) - - # Create context object and run middleware. - context = TurnContext(self, request) - return await self.run_pipeline(context, self.logic) - - def get_next_activity(self) -> Activity: - return self.activity_buffer.pop(0) - - async def send(self, user_says) -> object: - """ - Sends something to the bot. This returns a new `TestFlow` instance which can be used to add - additional steps for inspecting the bots reply and then sending additional activities. - :param user_says: - :return: A new instance of the TestFlow object - """ - return TestFlow(await self.receive_activity(user_says), self) - - async def test( - self, user_says, expected, description=None, timeout=None - ) -> "TestFlow": - """ - Send something to the bot and expects the bot to return with a given reply. This is simply a - wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a - helper is provided. - :param user_says: - :param expected: - :param description: - :param timeout: - :return: - """ - test_flow = await self.send(user_says) - test_flow = await test_flow.assert_reply(expected, description, timeout) - return test_flow - - async def tests(self, *args): - """ - Support multiple test cases without having to manually call `test()` repeatedly. This is a - convenience layer around the `test()`. Valid args are either lists or tuples of parameters - :param args: - :return: - """ - for arg in args: - description = None - timeout = None - if len(arg) >= 3: - description = arg[2] - if len(arg) == 4: - timeout = arg[3] - await self.test(arg[0], arg[1], description, timeout) - - def add_user_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - token: str, - magic_code: str = None, - ): - key = UserToken() - key.channel_id = channel_id - key.connection_name = connection_name - key.user_id = user_id - key.token = token - - if not magic_code: - self._user_tokens.append(key) - else: - code = TokenMagicCode() - code.key = key - code.magic_code = magic_code - self._magic_codes.append(code) - - async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None - ) -> TokenResponse: - key = UserToken() - key.channel_id = context.activity.channel_id - key.connection_name = connection_name - key.user_id = context.activity.from_property.id - - if magic_code: - magic_code_record = list( - filter(lambda x: key.equals_key(x.key), self._magic_codes) - ) - if magic_code_record and magic_code_record[0].magic_code == magic_code: - # Move the token to long term dictionary. - self.add_user_token( - connection_name, - key.channel_id, - key.user_id, - magic_code_record[0].key.token, - ) - - # Remove from the magic code list. - idx = self._magic_codes.index(magic_code_record[0]) - self._magic_codes = [self._magic_codes.pop(idx)] - - match = [token for token in self._user_tokens if key.equals_key(token)] - - if match: - return TokenResponse( - connection_name=match[0].connection_name, - token=match[0].token, - expiration=None, - ) - # Not found. - return None - - async def sign_out_user( - self, context: TurnContext, connection_name: str, user_id: str = None - ): - channel_id = context.activity.channel_id - user_id = context.activity.from_property.id - - new_records = [] - for token in self._user_tokens: - if ( - token.channel_id != channel_id - or token.user_id != user_id - or (connection_name and connection_name != token.connection_name) - ): - new_records.append(token) - self._user_tokens = new_records - - async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str - ) -> str: - return ( - f"https://fake.com/oauthsignin" - f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" - ) - - async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] - ) -> Dict[str, TokenResponse]: - return None - - -class TestFlow: - __test__ = False - - def __init__(self, previous: Callable, adapter: TestAdapter): - """ - INTERNAL: creates a new TestFlow instance. - :param previous: - :param adapter: - """ - self.previous = previous - self.adapter = adapter - - async def test( - self, user_says, expected, description=None, timeout=None - ) -> "TestFlow": - """ - Send something to the bot and expects the bot to return with a given reply. This is simply a - wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a - helper is provided. - :param user_says: - :param expected: - :param description: - :param timeout: - :return: - """ - test_flow = await self.send(user_says) - return await test_flow.assert_reply( - expected, description or f'test("{user_says}", "{expected}")', timeout - ) - - async def send(self, user_says) -> "TestFlow": - """ - Sends something to the bot. - :param user_says: - :return: - """ - - async def new_previous(): - nonlocal self, user_says - if callable(self.previous): - await self.previous() - await self.adapter.receive_activity(user_says) - - return TestFlow(await new_previous(), self.adapter) - - async def assert_reply( - self, - expected: Union[str, Activity, Callable[[Activity, str], None]], - description=None, - timeout=None, # pylint: disable=unused-argument - is_substring=False, - ) -> "TestFlow": - """ - Generates an assertion if the bots response doesn't match the expected text/activity. - :param expected: - :param description: - :param timeout: - :param is_substring: - :return: - """ - # TODO: refactor method so expected can take a Callable[[Activity], None] - def default_inspector(reply, description=None): - if isinstance(expected, Activity): - validate_activity(reply, expected) - else: - assert reply.type == "message", description + f" type == {reply.type}" - if is_substring: - assert expected in reply.text.strip(), ( - description + f" text == {reply.text}" - ) - else: - assert reply.text.strip() == expected.strip(), ( - description + f" text == {reply.text}" - ) - - if description is None: - description = "" - - inspector = expected if callable(expected) else default_inspector - - async def test_flow_previous(): - nonlocal timeout - if not timeout: - timeout = 3000 - start = datetime.now() - adapter = self.adapter - - async def wait_for_activity(): - nonlocal expected, timeout - current = datetime.now() - if (current - start).total_seconds() * 1000 > timeout: - if isinstance(expected, Activity): - expecting = expected.text - elif callable(expected): - expecting = inspect.getsourcefile(expected) - else: - expecting = str(expected) - raise RuntimeError( - f"TestAdapter.assert_reply({expecting}): {description} Timed out after " - f"{current - start}ms." - ) - if adapter.activity_buffer: - reply = adapter.activity_buffer.pop(0) - try: - await inspector(reply, description) - except Exception: - inspector(reply, description) - - else: - await asyncio.sleep(0.05) - await wait_for_activity() - - await wait_for_activity() - - return TestFlow(await test_flow_previous(), self.adapter) - - -def validate_activity(activity, expected) -> None: - """ - Helper method that compares activities - :param activity: - :param expected: - :return: - """ - iterable_expected = vars(expected).items() - - for attr, value in iterable_expected: - if value is not None and attr != "additional_properties": - assert value == getattr(activity, attr) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# TODO: enable this in the future +# With python 3.7 the line below will allow to do Postponed Evaluation of Annotations. See PEP 563 +# from __future__ import annotations + +import asyncio +import inspect +from datetime import datetime +from typing import Awaitable, Coroutine, Dict, List, Callable, Union +from copy import copy +from threading import Lock +from botbuilder.schema import ( + ActivityTypes, + Activity, + ConversationAccount, + ConversationReference, + ChannelAccount, + ResourceResponse, + TokenResponse, +) +from botframework.connector.auth import ClaimsIdentity, AppCredentials +from ..bot_adapter import BotAdapter +from ..turn_context import TurnContext +from ..user_token_provider import UserTokenProvider + + +class UserToken: + def __init__( + self, + connection_name: str = None, + user_id: str = None, + channel_id: str = None, + token: str = None, + ): + self.connection_name = connection_name + self.user_id = user_id + self.channel_id = channel_id + self.token = token + + def equals_key(self, rhs: "UserToken"): + return ( + rhs is not None + and self.connection_name == rhs.connection_name + and self.user_id == rhs.user_id + and self.channel_id == rhs.channel_id + ) + + +class TokenMagicCode: + def __init__(self, key: UserToken = None, magic_code: str = None): + self.key = key + self.magic_code = magic_code + + +class TestAdapter(BotAdapter, UserTokenProvider): + __test__ = False + + def __init__( + self, + logic: Coroutine = None, + template_or_conversation: Union[Activity, ConversationReference] = None, + send_trace_activities: bool = False, + ): # pylint: disable=unused-argument + """ + Creates a new TestAdapter instance. + :param logic: + :param conversation: A reference to the conversation to begin the adapter state with. + """ + super(TestAdapter, self).__init__() + self.logic = logic + 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 = ( + template_or_conversation + if isinstance(template_or_conversation, Activity) + else 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 isinstance(template_or_conversation, ConversationReference): + self.template.channel_id = template_or_conversation.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] + ) -> List[ResourceResponse]: + """ + INTERNAL: called by the logic under test to send a set of activities. These will be buffered + to the current `TestFlow` instance for comparison against the expected results. + :param context: + :param activities: + :return: + """ + + def id_mapper(activity): + self.activity_buffer.append(activity) + self._next_id += 1 + return ResourceResponse(id=str(self._next_id)) + + 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): + """ + INTERNAL: called by the logic under test to delete an existing activity. These are simply + pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn + completes. + :param reference: + :return: + """ + self.deleted_activities.append(reference) + + async def update_activity(self, context, activity: Activity): + """ + INTERNAL: called by the logic under test to replace an existing activity. These are simply + pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn + completes. + :param activity: + :return: + """ + self.updated_activities.append(activity) + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ): + """ + The `TestAdapter` just calls parent implementation. + :param reference: + :param callback: + :param bot_id: + :param claims_identity: + :return: + """ + await super().continue_conversation( + reference, callback, bot_id, claims_identity + ) + + async def receive_activity(self, activity): + """ + INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot. + This will cause the adapters middleware pipe to be run and it's logic to be called. + :param activity: + :return: + """ + if isinstance(activity, str): + activity = Activity(type="message", text=activity) + # Initialize request. + request = copy(self.template) + + for key, value in vars(activity).items(): + if value is not None and key != "additional_properties": + setattr(request, key, value) + + request.type = request.type or ActivityTypes.message + if not request.id: + self._next_id += 1 + request.id = str(self._next_id) + + # Create context object and run middleware. + context = TurnContext(self, request) + return await self.run_pipeline(context, self.logic) + + def get_next_activity(self) -> Activity: + return self.activity_buffer.pop(0) + + async def send(self, user_says) -> object: + """ + Sends something to the bot. This returns a new `TestFlow` instance which can be used to add + additional steps for inspecting the bots reply and then sending additional activities. + :param user_says: + :return: A new instance of the TestFlow object + """ + return TestFlow(await self.receive_activity(user_says), self) + + async def test( + self, user_says, expected, description=None, timeout=None + ) -> "TestFlow": + """ + Send something to the bot and expects the bot to return with a given reply. This is simply a + wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a + helper is provided. + :param user_says: + :param expected: + :param description: + :param timeout: + :return: + """ + test_flow = await self.send(user_says) + test_flow = await test_flow.assert_reply(expected, description, timeout) + return test_flow + + async def tests(self, *args): + """ + Support multiple test cases without having to manually call `test()` repeatedly. This is a + convenience layer around the `test()`. Valid args are either lists or tuples of parameters + :param args: + :return: + """ + for arg in args: + description = None + timeout = None + if len(arg) >= 3: + description = arg[2] + if len(arg) == 4: + timeout = arg[3] + await self.test(arg[0], arg[1], description, timeout) + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str = None, + ): + key = UserToken() + key.channel_id = channel_id + key.connection_name = connection_name + key.user_id = user_id + key.token = token + + if not magic_code: + self._user_tokens.append(key) + else: + code = TokenMagicCode() + code.key = key + code.magic_code = magic_code + self._magic_codes.append(code) + + async def get_user_token( + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> TokenResponse: + key = UserToken() + key.channel_id = context.activity.channel_id + key.connection_name = connection_name + key.user_id = context.activity.from_property.id + + if magic_code: + magic_code_record = list( + filter(lambda x: key.equals_key(x.key), self._magic_codes) + ) + if magic_code_record and magic_code_record[0].magic_code == magic_code: + # Move the token to long term dictionary. + self.add_user_token( + connection_name, + key.channel_id, + key.user_id, + magic_code_record[0].key.token, + ) + + # Remove from the magic code list. + idx = self._magic_codes.index(magic_code_record[0]) + self._magic_codes = [self._magic_codes.pop(idx)] + + match = [token for token in self._user_tokens if key.equals_key(token)] + + if match: + return TokenResponse( + connection_name=match[0].connection_name, + token=match[0].token, + expiration=None, + ) + # Not found. + return None + + async def sign_out_user( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): + channel_id = context.activity.channel_id + user_id = context.activity.from_property.id + + new_records = [] + for token in self._user_tokens: + if ( + token.channel_id != channel_id + or token.user_id != user_id + or (connection_name and connection_name != token.connection_name) + ): + new_records.append(token) + self._user_tokens = new_records + + async def get_oauth_sign_in_link( + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> str: + return ( + f"https://fake.com/oauthsignin" + f"/{connection_name}/{context.activity.channel_id}/{context.activity.from_property.id}" + ) + + async def get_token_status( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + return None + + async def get_aad_tokens( + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + return None + + +class TestFlow: + __test__ = False + + def __init__(self, previous: Callable, adapter: TestAdapter): + """ + INTERNAL: creates a new TestFlow instance. + :param previous: + :param adapter: + """ + self.previous = previous + self.adapter = adapter + + async def test( + self, user_says, expected, description=None, timeout=None + ) -> "TestFlow": + """ + Send something to the bot and expects the bot to return with a given reply. This is simply a + wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a + helper is provided. + :param user_says: + :param expected: + :param description: + :param timeout: + :return: + """ + test_flow = await self.send(user_says) + return await test_flow.assert_reply( + expected, description or f'test("{user_says}", "{expected}")', timeout + ) + + async def send(self, user_says) -> "TestFlow": + """ + Sends something to the bot. + :param user_says: + :return: + """ + + async def new_previous(): + nonlocal self, user_says + if callable(self.previous): + await self.previous() + await self.adapter.receive_activity(user_says) + + return TestFlow(await new_previous(), self.adapter) + + async def assert_reply( + self, + expected: Union[str, Activity, Callable[[Activity, str], None]], + description=None, + timeout=None, # pylint: disable=unused-argument + is_substring=False, + ) -> "TestFlow": + """ + Generates an assertion if the bots response doesn't match the expected text/activity. + :param expected: + :param description: + :param timeout: + :param is_substring: + :return: + """ + # TODO: refactor method so expected can take a Callable[[Activity], None] + def default_inspector(reply, description=None): + if isinstance(expected, Activity): + validate_activity(reply, expected) + else: + assert reply.type == "message", description + f" type == {reply.type}" + if is_substring: + assert expected in reply.text.strip(), ( + description + f" text == {reply.text}" + ) + else: + assert reply.text.strip() == expected.strip(), ( + description + f" text == {reply.text}" + ) + + if description is None: + description = "" + + inspector = expected if callable(expected) else default_inspector + + async def test_flow_previous(): + nonlocal timeout + if not timeout: + timeout = 3000 + start = datetime.now() + adapter = self.adapter + + async def wait_for_activity(): + nonlocal expected, timeout + current = datetime.now() + if (current - start).total_seconds() * 1000 > timeout: + if isinstance(expected, Activity): + expecting = expected.text + elif callable(expected): + expecting = inspect.getsourcefile(expected) + else: + expecting = str(expected) + raise RuntimeError( + f"TestAdapter.assert_reply({expecting}): {description} Timed out after " + f"{current - start}ms." + ) + if adapter.activity_buffer: + reply = adapter.activity_buffer.pop(0) + try: + await inspector(reply, description) + except Exception: + inspector(reply, description) + + else: + await asyncio.sleep(0.05) + await wait_for_activity() + + await wait_for_activity() + + return TestFlow(await test_flow_previous(), self.adapter) + + +def validate_activity(activity, expected) -> None: + """ + Helper method that compares activities + :param activity: + :param expected: + :return: + """ + iterable_expected = vars(expected).items() + + for attr, value in iterable_expected: + if value is not None and attr != "additional_properties": + assert value == getattr(activity, attr) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index f04be1c97..c2a9aa869 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -25,9 +25,9 @@ SimpleCredentialProvider, SkillValidation, CertificateAppCredentials, + AppCredentials, ) from botframework.connector.token_api import TokenApiClient -from botframework.connector.token_api.models import TokenStatus from botbuilder.schema import ( Activity, ActivityTypes, @@ -729,7 +729,11 @@ async def get_conversations(self, service_url: str, continuation_token: str = No return await client.conversations.get_conversations(continuation_token) async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, ) -> TokenResponse: """ @@ -741,6 +745,8 @@ async def get_user_token( :type connection_name: str :param magic_code" (Optional) user entered code to validate :str magic_code" str + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` :raises: An exception error @@ -761,24 +767,27 @@ async def get_user_token( "get_user_token() requires a connection_name but none was provided." ) - self.check_emulating_oauth_cards(context) - user_id = context.activity.from_property.id - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) + client = self._create_token_api_client(context, oauth_app_credentials) result = client.user_token.get_token( - user_id, connection_name, context.activity.channel_id, magic_code + context.activity.from_property.id, + connection_name, + context.activity.channel_id, + magic_code, ) - # TODO check form of response if result is None or result.token is None: return None return result async def sign_out_user( - self, context: TurnContext, connection_name: str = None, user_id: str = None - ) -> str: + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): """ Signs the user out with the token server. @@ -788,8 +797,8 @@ async def sign_out_user( :type connection_name: str :param user_id: User id of user to sign out :type user_id: str - - :returns: A task that represents the work queued to execute + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` """ if not context.activity.from_property or not context.activity.from_property.id: raise Exception( @@ -798,15 +807,17 @@ async def sign_out_user( if not user_id: user_id = context.activity.from_property.id - self.check_emulating_oauth_cards(context) - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) + client = self._create_token_api_client(context, oauth_app_credentials) client.user_token.sign_out( user_id, connection_name, context.activity.channel_id ) async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, ) -> str: """ Gets the raw sign-in link to be sent to the user for sign-in for a connection name. @@ -815,17 +826,16 @@ async def get_oauth_sign_in_link( :type context: :class:`botbuilder.core.TurnContext` :param connection_name: Name of the auth connection to use :type connection_name: str + :param final_redirect: The final URL that the OAuth flow will redirect to. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` - :returns: A task that represents the work queued to execute + :return: If the task completes successfully, the result contains the raw sign-in link + """ - .. note:: + client = self._create_token_api_client(context, oauth_app_credentials) - If the task completes successfully, the result contains the raw sign-in link - """ - self.check_emulating_oauth_cards(context) conversation = TurnContext.get_conversation_reference(context.activity) - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) state = TokenExchangeState( connection_name=connection_name, conversation=conversation, @@ -839,19 +849,27 @@ async def get_oauth_sign_in_link( return client.bot_sign_in.get_sign_in_url(final_state) async def get_token_status( - self, context: TurnContext, user_id: str = None, include_filter: str = None - ) -> List[TokenStatus]: - + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: """ Retrieves the token status for each configured connection for the given user. :param context: Context for the current turn of conversation with the user :type context: :class:`botbuilder.core.TurnContext` - :param user_id: The user Id for which token status is retrieved + :param connection_name: Name of the auth connection to use + :type connection_name: str + :param user_id: The user Id for which tokens are retrieved. If passing in None the userId is taken :type user_id: str :param include_filter: (Optional) Comma separated list of connection's to include. Blank will return token status for all configured connections. :type include_filter: str + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` :returns: Array of :class:`botframework.connector.token_api.modelsTokenStatus` """ @@ -863,18 +881,20 @@ async def get_token_status( "BotFrameworkAdapter.get_token_status(): missing from_property or from_property.id" ) - self.check_emulating_oauth_cards(context) - user_id = user_id or context.activity.from_property.id - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) + client = self._create_token_api_client(context, oauth_app_credentials) - # TODO check form of response + user_id = user_id or context.activity.from_property.id return client.user_token.get_token_status( user_id, context.activity.channel_id, include_filter ) async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, ) -> Dict[str, TokenResponse]: """ Retrieves Azure Active Directory tokens for particular resources on a configured connection. @@ -885,6 +905,12 @@ async def get_aad_tokens( :type connection_name: str :param resource_urls: The list of resource URLs to retrieve tokens for :type resource_urls: :class:`typing.List` + :param user_id: The user Id for which tokens are retrieved. If passing in null the userId is taken + from the Activity in the TurnContext. + :type user_id: str + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. + :type oauth_app_credentials: :class:`botframework.connector.auth.AppCredential` + :returns: Dictionary of resource Urls to the corresponding :class:'botbuilder.schema.TokenResponse` :rtype: :class:`typing.Dict` """ @@ -893,14 +919,12 @@ async def get_aad_tokens( "BotFrameworkAdapter.get_aad_tokens(): missing from_property or from_property.id" ) - self.check_emulating_oauth_cards(context) - user_id = context.activity.from_property.id - url = self.oauth_api_url(context) - client = self.create_token_api_client(url) - - # TODO check form of response + client = self._create_token_api_client(context, oauth_app_credentials) return client.user_token.get_aad_tokens( - user_id, connection_name, context.activity.channel_id, resource_urls + context.activity.from_property.id, + connection_name, + context.activity.channel_id, + resource_urls, ) async def create_connector_client( @@ -958,20 +982,31 @@ async def create_connector_client( return client - def create_token_api_client(self, service_url: str) -> TokenApiClient: - client = TokenApiClient(self._credentials, service_url) - client.config.add_user_agent(USER_AGENT) + def _create_token_api_client( + self, + url_or_context: Union[TurnContext, str], + oauth_app_credentials: AppCredentials = None, + ) -> TokenApiClient: + if isinstance(url_or_context, str): + app_credentials = ( + oauth_app_credentials if oauth_app_credentials else self._credentials + ) + client = TokenApiClient(app_credentials, url_or_context) + client.config.add_user_agent(USER_AGENT) + return client - return client + self.__check_emulating_oauth_cards(url_or_context) + url = self.__oauth_api_url(url_or_context) + return self._create_token_api_client(url) - async def emulate_oauth_cards( + async def __emulate_oauth_cards( self, context_or_service_url: Union[TurnContext, str], emulate: bool ): self._is_emulating_oauth_cards = emulate - url = self.oauth_api_url(context_or_service_url) + url = self.__oauth_api_url(context_or_service_url) await EmulatorApiClient.emulate_oauth_cards(self._credentials, url, emulate) - def oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: + def __oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: url = None if self._is_emulating_oauth_cards: url = ( @@ -991,7 +1026,7 @@ def oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: return url - def check_emulating_oauth_cards(self, context: TurnContext): + def __check_emulating_oauth_cards(self, context: TurnContext): if ( not self._is_emulating_oauth_cards and context.activity.channel_id == "emulator" diff --git a/libraries/botbuilder-core/botbuilder/core/user_token_provider.py b/libraries/botbuilder-core/botbuilder/core/user_token_provider.py index 4316a2f88..735af1e7a 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_token_provider.py +++ b/libraries/botbuilder-core/botbuilder/core/user_token_provider.py @@ -1,62 +1,113 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC, abstractmethod -from typing import Dict, List - -from botbuilder.schema import TokenResponse - -from .turn_context import TurnContext - - -class UserTokenProvider(ABC): - @abstractmethod - async def get_user_token( - self, context: TurnContext, connection_name: str, magic_code: str = None - ) -> TokenResponse: - """ - Retrieves the OAuth token for a user that is in a sign-in flow. - :param context: - :param connection_name: - :param magic_code: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def sign_out_user( - self, context: TurnContext, connection_name: str, user_id: str = None - ): - """ - Signs the user out with the token server. - :param context: - :param connection_name: - :param user_id: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_oauth_sign_in_link( - self, context: TurnContext, connection_name: str - ) -> str: - """ - Get the raw signin link to be sent to the user for signin for a connection name. - :param context: - :param connection_name: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def get_aad_tokens( - self, context: TurnContext, connection_name: str, resource_urls: List[str] - ) -> Dict[str, TokenResponse]: - """ - Retrieves Azure Active Directory tokens for particular resources on a configured connection. - :param context: - :param connection_name: - :param resource_urls: - :return: - """ - raise NotImplementedError() +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from typing import Dict, List + +from botbuilder.schema import TokenResponse +from botframework.connector.auth import AppCredentials + +from .turn_context import TurnContext + + +class UserTokenProvider(ABC): + @abstractmethod + async def get_user_token( + self, + context: TurnContext, + connection_name: str, + magic_code: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> TokenResponse: + """ + Retrieves the OAuth token for a user that is in a sign-in flow. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param magic_code: (Optional) Optional user entered code to validate. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def sign_out_user( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ): + """ + Signs the user out with the token server. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: User id of user to sign out. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_oauth_sign_in_link( + self, + context: TurnContext, + connection_name: str, + final_redirect: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> str: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param final_redirect: The final URL that the OAuth flow will redirect to. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_token_status( + self, + context: TurnContext, + connection_name: str = None, + user_id: str = None, + include_filter: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param user_id: The user Id for which token status is retrieved. + :param include_filter: Optional comma separated list of connection's to include. Blank will return token status + for all configured connections. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def get_aad_tokens( + self, + context: TurnContext, + connection_name: str, + resource_urls: List[str], + user_id: str = None, + oauth_app_credentials: AppCredentials = None, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + :param context: Context for the current turn of conversation with the user. + :param connection_name: Name of the auth connection to use. + :param resource_urls: The list of resource URLs to retrieve tokens for. + :param user_id: The user Id for which tokens are retrieved. If passing in None the userId is taken + from the Activity in the TurnContext. + :param oauth_app_credentials: (Optional) AppCredentials for OAuth. If None is supplied, the + Bots credentials are used. + :return: + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-core/tests/test_test_adapter.py b/libraries/botbuilder-core/tests/test_test_adapter.py index 1d095c222..4312ca352 100644 --- a/libraries/botbuilder-core/tests/test_test_adapter.py +++ b/libraries/botbuilder-core/tests/test_test_adapter.py @@ -1,137 +1,247 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import aiounittest -from botbuilder.schema import Activity, ConversationReference -from botbuilder.core import TurnContext -from botbuilder.core.adapters import TestAdapter - -RECEIVED_MESSAGE = Activity(type="message", text="received") -UPDATED_ACTIVITY = Activity(type="message", text="update") -DELETED_ACTIVITY_REFERENCE = ConversationReference(activity_id="1234") - - -class TestTestAdapter(aiounittest.AsyncTestCase): - async def test_should_call_bog_logic_when_receive_activity_is_called(self): - async def logic(context: TurnContext): - assert context - assert context.activity - assert context.activity.type == "message" - assert context.activity.text == "test" - assert context.activity.id - assert context.activity.from_property - assert context.activity.recipient - assert context.activity.conversation - assert context.activity.channel_id - assert context.activity.service_url - - adapter = TestAdapter(logic) - await adapter.receive_activity("test") - - async def test_should_support_receive_activity_with_activity(self): - async def logic(context: TurnContext): - assert context.activity.type == "message" - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.receive_activity(Activity(type="message", text="test")) - - async def test_should_set_activity_type_when_receive_activity_receives_activity_without_type( - self, - ): - async def logic(context: TurnContext): - assert context.activity.type == "message" - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.receive_activity(Activity(text="test")) - - async def test_should_support_custom_activity_id_in_receive_activity(self): - async def logic(context: TurnContext): - assert context.activity.id == "myId" - assert context.activity.type == "message" - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.receive_activity(Activity(type="message", text="test", id="myId")) - - async def test_should_call_bot_logic_when_send_is_called(self): - async def logic(context: TurnContext): - assert context.activity.text == "test" - - adapter = TestAdapter(logic) - await adapter.send("test") - - async def test_should_send_and_receive_when_test_is_called(self): - async def logic(context: TurnContext): - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - await adapter.test("test", "received") - - async def test_should_send_and_throw_assertion_error_when_test_is_called(self): - async def logic(context: TurnContext): - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - try: - await adapter.test("test", "foobar") - except AssertionError: - pass - else: - raise AssertionError("Assertion error should have been raised") - - async def test_tests_should_call_test_for_each_tuple(self): - counter = 0 - - async def logic(context: TurnContext): - nonlocal counter - counter += 1 - await context.send_activity(Activity(type="message", text=str(counter))) - - adapter = TestAdapter(logic) - await adapter.tests(("test", "1"), ("test", "2"), ("test", "3")) - assert counter == 3 - - async def test_tests_should_call_test_for_each_list(self): - counter = 0 - - async def logic(context: TurnContext): - nonlocal counter - counter += 1 - await context.send_activity(Activity(type="message", text=str(counter))) - - adapter = TestAdapter(logic) - await adapter.tests(["test", "1"], ["test", "2"], ["test", "3"]) - assert counter == 3 - - async def test_should_assert_reply_after_send(self): - async def logic(context: TurnContext): - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - test_flow = await adapter.send("test") - await test_flow.assert_reply("received") - - async def test_should_support_context_update_activity_call(self): - async def logic(context: TurnContext): - await context.update_activity(UPDATED_ACTIVITY) - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - await adapter.test("test", "received") - assert len(adapter.updated_activities) == 1 - assert adapter.updated_activities[0].text == UPDATED_ACTIVITY.text - - async def test_should_support_context_delete_activity_call(self): - async def logic(context: TurnContext): - await context.delete_activity(DELETED_ACTIVITY_REFERENCE) - await context.send_activity(RECEIVED_MESSAGE) - - adapter = TestAdapter(logic) - await adapter.test("test", "received") - assert len(adapter.deleted_activities) == 1 - assert ( - adapter.deleted_activities[0].activity_id - == DELETED_ACTIVITY_REFERENCE.activity_id - ) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest + +from botbuilder.core import TurnContext +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import Activity, ConversationReference, ChannelAccount +from botframework.connector.auth import MicrosoftAppCredentials + +RECEIVED_MESSAGE = Activity(type="message", text="received") +UPDATED_ACTIVITY = Activity(type="message", text="update") +DELETED_ACTIVITY_REFERENCE = ConversationReference(activity_id="1234") + + +class TestTestAdapter(aiounittest.AsyncTestCase): + async def test_should_call_bog_logic_when_receive_activity_is_called(self): + async def logic(context: TurnContext): + assert context + assert context.activity + assert context.activity.type == "message" + assert context.activity.text == "test" + assert context.activity.id + assert context.activity.from_property + assert context.activity.recipient + assert context.activity.conversation + assert context.activity.channel_id + assert context.activity.service_url + + adapter = TestAdapter(logic) + await adapter.receive_activity("test") + + async def test_should_support_receive_activity_with_activity(self): + async def logic(context: TurnContext): + assert context.activity.type == "message" + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.receive_activity(Activity(type="message", text="test")) + + async def test_should_set_activity_type_when_receive_activity_receives_activity_without_type( + self, + ): + async def logic(context: TurnContext): + assert context.activity.type == "message" + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.receive_activity(Activity(text="test")) + + async def test_should_support_custom_activity_id_in_receive_activity(self): + async def logic(context: TurnContext): + assert context.activity.id == "myId" + assert context.activity.type == "message" + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.receive_activity(Activity(type="message", text="test", id="myId")) + + async def test_should_call_bot_logic_when_send_is_called(self): + async def logic(context: TurnContext): + assert context.activity.text == "test" + + adapter = TestAdapter(logic) + await adapter.send("test") + + async def test_should_send_and_receive_when_test_is_called(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + await adapter.test("test", "received") + + async def test_should_send_and_throw_assertion_error_when_test_is_called(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + try: + await adapter.test("test", "foobar") + except AssertionError: + pass + else: + raise AssertionError("Assertion error should have been raised") + + async def test_tests_should_call_test_for_each_tuple(self): + counter = 0 + + async def logic(context: TurnContext): + nonlocal counter + counter += 1 + await context.send_activity(Activity(type="message", text=str(counter))) + + adapter = TestAdapter(logic) + await adapter.tests(("test", "1"), ("test", "2"), ("test", "3")) + assert counter == 3 + + async def test_tests_should_call_test_for_each_list(self): + counter = 0 + + async def logic(context: TurnContext): + nonlocal counter + counter += 1 + await context.send_activity(Activity(type="message", text=str(counter))) + + adapter = TestAdapter(logic) + await adapter.tests(["test", "1"], ["test", "2"], ["test", "3"]) + assert counter == 3 + + async def test_should_assert_reply_after_send(self): + async def logic(context: TurnContext): + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + test_flow = await adapter.send("test") + await test_flow.assert_reply("received") + + async def test_should_support_context_update_activity_call(self): + async def logic(context: TurnContext): + await context.update_activity(UPDATED_ACTIVITY) + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + await adapter.test("test", "received") + assert len(adapter.updated_activities) == 1 + assert adapter.updated_activities[0].text == UPDATED_ACTIVITY.text + + async def test_should_support_context_delete_activity_call(self): + async def logic(context: TurnContext): + await context.delete_activity(DELETED_ACTIVITY_REFERENCE) + await context.send_activity(RECEIVED_MESSAGE) + + adapter = TestAdapter(logic) + await adapter.test("test", "received") + assert len(adapter.deleted_activities) == 1 + assert ( + adapter.deleted_activities[0].activity_id + == DELETED_ACTIVITY_REFERENCE.activity_id + ) + + async def test_get_user_token_returns_null(self): + adapter = TestAdapter() + activity = Activity( + channel_id="directline", from_property=ChannelAccount(id="testuser") + ) + + turn_context = TurnContext(adapter, activity) + + token_response = await adapter.get_user_token(turn_context, "myConnection") + assert not token_response + + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, "myConnection", oauth_app_credentials=oauth_app_credentials + ) + assert not token_response + + async def test_get_user_token_returns_null_with_code(self): + adapter = TestAdapter() + activity = Activity( + channel_id="directline", from_property=ChannelAccount(id="testuser") + ) + + turn_context = TurnContext(adapter, activity) + + token_response = await adapter.get_user_token( + turn_context, "myConnection", "abc123" + ) + assert not token_response + + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, + "myConnection", + "abc123", + oauth_app_credentials=oauth_app_credentials, + ) + assert not token_response + + async def test_get_user_token_returns_token(self): + adapter = TestAdapter() + connection_name = "myConnection" + channel_id = "directline" + user_id = "testUser" + token = "abc123" + activity = Activity( + channel_id=channel_id, from_property=ChannelAccount(id=user_id) + ) + + turn_context = TurnContext(adapter, activity) + + adapter.add_user_token(connection_name, channel_id, user_id, token) + + token_response = await adapter.get_user_token(turn_context, connection_name) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, connection_name, oauth_app_credentials=oauth_app_credentials + ) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + async def test_get_user_token_returns_token_with_magice_code(self): + adapter = TestAdapter() + connection_name = "myConnection" + channel_id = "directline" + user_id = "testUser" + token = "abc123" + magic_code = "888999" + activity = Activity( + channel_id=channel_id, from_property=ChannelAccount(id=user_id) + ) + + turn_context = TurnContext(adapter, activity) + + adapter.add_user_token(connection_name, channel_id, user_id, token, magic_code) + + # First no magic_code + token_response = await adapter.get_user_token(turn_context, connection_name) + assert not token_response + + # Can be retrieved with magic code + token_response = await adapter.get_user_token( + turn_context, connection_name, magic_code + ) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + # Then can be retrieved without magic code + token_response = await adapter.get_user_token(turn_context, connection_name) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name + + # Then can be retrieved using customized AppCredentials + oauth_app_credentials = MicrosoftAppCredentials(None, None) + token_response = await adapter.get_user_token( + turn_context, connection_name, oauth_app_credentials=oauth_app_credentials + ) + assert token_response + assert token == token_response.token + assert connection_name == token_response.connection_name diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index a8cd05048..0f9a82453 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -143,7 +143,10 @@ async def begin_dialog( ) output = await dialog_context.context.adapter.get_user_token( - dialog_context.context, self._settings.connection_name, None + dialog_context.context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) if output is not None: @@ -220,6 +223,8 @@ async def get_user_token( :param context: Context for the current turn of conversation with the user :type context: :class:`TurnContext` + :param code: (Optional) Optional user entered code to validate. + :type code: str :return: A response that includes the user's token :rtype: :class:`TokenResponse` @@ -237,7 +242,10 @@ async def get_user_token( ) return await adapter.get_user_token( - context, self._settings.connection_name, code + context, + self._settings.connection_name, + code, + self._settings.oath_app_credentials, ) async def sign_out_user(self, context: TurnContext): @@ -260,7 +268,12 @@ async def sign_out_user(self, context: TurnContext): "OAuthPrompt.sign_out_user(): not supported for the current adapter." ) - return await adapter.sign_out_user(context, self._settings.connection_name) + return await adapter.sign_out_user( + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, + ) async def _send_oauth_card( self, context: TurnContext, prompt: Union[Activity, str] = None @@ -288,13 +301,19 @@ async def _send_oauth_card( "OAuthPrompt: get_oauth_sign_in_link() not supported by the current adapter" ) link = await context.adapter.get_oauth_sign_in_link( - context, self._settings.connection_name + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) elif bot_identity and SkillValidation.is_skill_claim( bot_identity.claims ): link = await context.adapter.get_oauth_sign_in_link( - context, self._settings.connection_name + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) card_action_type = ActionTypes.open_url @@ -325,7 +344,10 @@ async def _send_oauth_card( ) link = await context.adapter.get_oauth_sign_in_link( - context, self._settings.connection_name + context, + self._settings.connection_name, + None, + self._settings.oath_app_credentials, ) prompt.attachments.append( CardFactory.signin_card( diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py index 4eec4881a..1d8f04eca 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt_settings.py @@ -1,21 +1,30 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - - -class OAuthPromptSettings: - def __init__( - self, connection_name: str, title: str, text: str = None, timeout: int = None - ): - """ - Settings used to configure an `OAuthPrompt` instance. - Parameters: - connection_name (str): Name of the OAuth connection being used. - title (str): The title of the cards signin button. - text (str): (Optional) additional text included on the signin card. - timeout (int): (Optional) number of milliseconds the prompt will wait for the user to authenticate. - `OAuthPrompt` defaults value to `900,000` ms (15 minutes). - """ - self.connection_name = connection_name - self.title = title - self.text = text - self.timeout = timeout +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botframework.connector.auth import AppCredentials + + +class OAuthPromptSettings: + def __init__( + self, + connection_name: str, + title: str, + text: str = None, + timeout: int = None, + oauth_app_credentials: AppCredentials = None, + ): + """ + Settings used to configure an `OAuthPrompt` instance. + Parameters: + connection_name (str): Name of the OAuth connection being used. + title (str): The title of the cards signin button. + text (str): (Optional) additional text included on the signin card. + timeout (int): (Optional) number of milliseconds the prompt will wait for the user to authenticate. + `OAuthPrompt` defaults value to `900,000` ms (15 minutes). + oauth_app_credentials (AppCredentials): (Optional) AppCredentials to use for OAuth. If None, + the Bots credentials are used. + """ + self.connection_name = connection_name + self.title = title + self.text = text + self.timeout = timeout + self.oath_app_credentials = oauth_app_credentials