diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a7727956d..bf3443c6e 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -18,13 +18,17 @@ from botframework.connector import Channels, EmulatorApiClient from botframework.connector.aio import ConnectorClient from botframework.connector.auth import ( + AuthenticationConfiguration, AuthenticationConstants, ChannelValidation, + ChannelProvider, + ClaimsIdentity, GovernmentChannelValidation, GovernmentConstants, MicrosoftAppCredentials, JwtTokenValidation, SimpleCredentialProvider, + SkillValidation, ) from botframework.connector.token_api import TokenApiClient from botframework.connector.token_api.models import TokenStatus @@ -37,6 +41,7 @@ USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" OAUTH_ENDPOINT = "https://api.botframework.com" US_GOV_OAUTH_ENDPOINT = "https://api.botframework.azure.us" +BOT_IDENTITY_KEY = "BotIdentity" class TokenExchangeState(Model): @@ -72,6 +77,8 @@ def __init__( oauth_endpoint: str = None, open_id_metadata: str = None, channel_service: str = None, + channel_provider: ChannelProvider = None, + auth_configuration: AuthenticationConfiguration = None, ): self.app_id = app_id self.app_password = app_password @@ -79,6 +86,8 @@ def __init__( self.oauth_endpoint = oauth_endpoint self.open_id_metadata = open_id_metadata self.channel_service = channel_service + self.channel_provider = channel_provider + self.auth_configuration = auth_configuration or AuthenticationConfiguration() class BotFrameworkAdapter(BotAdapter, UserTokenProvider): @@ -90,6 +99,7 @@ def __init__(self, settings: BotFrameworkAdapterSettings): self.settings.channel_service = self.settings.channel_service or os.environ.get( AuthenticationConstants.CHANNEL_SERVICE ) + self.settings.open_id_metadata = ( self.settings.open_id_metadata or os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) @@ -163,7 +173,7 @@ async def create_conversation( # Create conversation parameters = ConversationParameters(bot=reference.bot) - client = self.create_connector_client(reference.service_url) + client = await self.create_connector_client(reference.service_url) # Mix in the tenant ID if specified. This is required for MS Teams. if reference.conversation is not None and reference.conversation.tenant_id: @@ -207,8 +217,9 @@ async def process_activity(self, req, auth_header: str, logic: Callable): activity = await self.parse_request(req) auth_header = auth_header or "" - await self.authenticate_request(activity, auth_header) + identity = await self.authenticate_request(activity, auth_header) context = self.create_context(activity) + context.turn_state[BOT_IDENTITY_KEY] = identity # Fix to assign tenant_id from channelData to Conversation.tenant_id. # MS Teams currently sends the tenant ID in channelData and the correct behavior is to expose @@ -228,7 +239,9 @@ async def process_activity(self, req, auth_header: str, logic: Callable): return await self.run_pipeline(context, logic) - async def authenticate_request(self, request: Activity, auth_header: str): + async def authenticate_request( + self, request: Activity, auth_header: str + ) -> ClaimsIdentity: """ Allows for the overriding of authentication in unit tests. :param request: @@ -240,11 +253,14 @@ async def authenticate_request(self, request: Activity, auth_header: str): auth_header, self._credential_provider, self.settings.channel_service, + self.settings.auth_configuration, ) if not claims.is_authenticated: raise Exception("Unauthorized Access. Request is not authorized") + return claims + def create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. @@ -306,7 +322,8 @@ async def update_activity(self, context: TurnContext, activity: Activity): :return: """ try: - client = self.create_connector_client(activity.service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(activity.service_url, identity) return await client.conversations.update_activity( activity.conversation.id, activity.id, activity ) @@ -324,7 +341,8 @@ async def delete_activity( :return: """ try: - client = self.create_connector_client(reference.service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(reference.service_url, identity) await client.conversations.delete_activity( reference.conversation.id, reference.activity_id ) @@ -365,7 +383,10 @@ async def send_activities( "BotFrameworkAdapter.send_activity(): conversation.id can not be None." ) - client = self.create_connector_client(activity.service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client( + activity.service_url, identity + ) if activity.type == "trace" and activity.channel_id != "emulator": pass elif activity.reply_to_id: @@ -409,7 +430,8 @@ async def delete_conversation_member( ) service_url = context.activity.service_url conversation_id = context.activity.conversation.id - client = self.create_connector_client(service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(service_url, identity) return await client.conversations.delete_conversation_member( conversation_id, member_id ) @@ -446,7 +468,8 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): ) service_url = context.activity.service_url conversation_id = context.activity.conversation.id - client = self.create_connector_client(service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(service_url, identity) return await client.conversations.get_activity_members( conversation_id, activity_id ) @@ -474,7 +497,8 @@ async def get_conversation_members(self, context: TurnContext): ) service_url = context.activity.service_url conversation_id = context.activity.conversation.id - client = self.create_connector_client(service_url) + identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) + client = await self.create_connector_client(service_url, identity) return await client.conversations.get_conversation_members(conversation_id) except Exception as error: raise error @@ -488,7 +512,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No :param continuation_token: :return: """ - client = self.create_connector_client(service_url) + client = await self.create_connector_client(service_url) return await client.conversations.get_conversations(continuation_token) async def get_user_token( @@ -595,13 +619,44 @@ async def get_aad_tokens( user_id, connection_name, context.activity.channel_id, resource_urls ) - def create_connector_client(self, service_url: str) -> ConnectorClient: + async def create_connector_client( + self, service_url: str, identity: ClaimsIdentity = None + ) -> ConnectorClient: """ Allows for mocking of the connector client in unit tests. :param service_url: + :param identity: :return: """ - client = ConnectorClient(self._credentials, base_url=service_url) + if identity: + bot_app_id_claim = identity.claims.get( + AuthenticationConstants.AUDIENCE_CLAIM + ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) + + credentials = None + if bot_app_id_claim and SkillValidation.is_skill_claim(identity.claims): + scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) + + password = await self._credential_provider.get_app_password( + bot_app_id_claim + ) + credentials = MicrosoftAppCredentials( + bot_app_id_claim, password, oauth_scope=scope + ) + if ( + self.settings.channel_provider + and self.settings.channel_provider.is_government() + ): + credentials.oauth_endpoint = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + ) + credentials.oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + else: + credentials = self._credentials + + client = ConnectorClient(credentials, base_url=service_url) client.config.add_user_agent(USER_AGENT) return client diff --git a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py new file mode 100644 index 000000000..3a579402b --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py @@ -0,0 +1,16 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .aiohttp_channel_service import aiohttp_channel_service_routes +from .bot_framework_http_client import BotFrameworkHttpClient +from .channel_service_handler import ChannelServiceHandler + +__all__ = [ + "aiohttp_channel_service_routes", + "BotFrameworkHttpClient", + "ChannelServiceHandler", +] diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py new file mode 100644 index 000000000..d61c0f0eb --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import json +from typing import List, Union, Type + +from aiohttp.web import RouteTableDef, Request, Response +from msrest.serialization import Model +from botbuilder.schema import ( + Activity, + AttachmentData, + ConversationParameters, + Transcript, +) + +from .channel_service_handler import ChannelServiceHandler + + +async def deserialize_from_body( + request: Request, target_model: Type[Model] +) -> Activity: + if "application/json" in request.headers["Content-Type"]: + body = await request.json() + else: + return Response(status=415) + + return target_model().deserialize(body) + + +def get_serialized_response(model_or_list: Union[Model, List[Model]]) -> Response: + if isinstance(model_or_list, Model): + json_obj = model_or_list.serialize() + else: + json_obj = [model.serialize() for model in model_or_list] + + return Response(body=json.dumps(json_obj), content_type="application/json") + + +def aiohttp_channel_service_routes( + handler: ChannelServiceHandler, base_url: str = "" +) -> RouteTableDef: + # pylint: disable=unused-variable + routes = RouteTableDef() + + @routes.post(base_url + "/v3/conversations/{conversation_id}/activities") + async def send_to_conversation(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_send_to_conversation( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.post( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def reply_to_activity(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_reply_to_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.put( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def update_activity(request: Request): + activity = await deserialize_from_body(request, Activity) + result = await handler.handle_update_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + activity, + ) + + return get_serialized_response(result) + + @routes.delete( + base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}" + ) + async def delete_activity(request: Request): + await handler.handle_delete_activity( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + ) + + return Response() + + @routes.get( + base_url + + "/v3/conversations/{conversation_id}/activities/{activity_id}/members" + ) + async def get_activity_members(request: Request): + result = await handler.handle_get_activity_members( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["activity_id"], + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/") + async def create_conversation(request: Request): + conversation_parameters = deserialize_from_body(request, ConversationParameters) + result = await handler.handle_create_conversation( + request.headers.get("Authorization"), conversation_parameters + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/") + async def get_conversation(request: Request): + # TODO: continuation token? + result = await handler.handle_get_conversations( + request.headers.get("Authorization") + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/v3/conversations/{conversation_id}/members") + async def get_conversation_members(request: Request): + result = await handler.handle_get_conversation_members( + request.headers.get("Authorization"), request.match_info["conversation_id"], + ) + + return get_serialized_response(result) + + @routes.get(base_url + "/v3/conversations/{conversation_id}/pagedmembers") + async def get_conversation_paged_members(request: Request): + # TODO: continuation token? page size? + result = await handler.handle_get_conversation_paged_members( + request.headers.get("Authorization"), request.match_info["conversation_id"], + ) + + return get_serialized_response(result) + + @routes.delete(base_url + "/v3/conversations/{conversation_id}/members/{member_id}") + async def delete_conversation_member(request: Request): + result = await handler.handle_delete_conversation_member( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + request.match_info["member_id"], + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/v3/conversations/{conversation_id}/activities/history") + async def send_conversation_history(request: Request): + transcript = deserialize_from_body(request, Transcript) + result = await handler.handle_send_conversation_history( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + transcript, + ) + + return get_serialized_response(result) + + @routes.post(base_url + "/v3/conversations/{conversation_id}/attachments") + async def upload_attachment(request: Request): + attachment_data = deserialize_from_body(request, AttachmentData) + result = await handler.handle_upload_attachment( + request.headers.get("Authorization"), + request.match_info["conversation_id"], + attachment_data, + ) + + return get_serialized_response(result) + + return routes diff --git a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py new file mode 100644 index 000000000..52a13230b --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +from typing import Dict +from logging import Logger +import aiohttp + +from botbuilder.core import InvokeResponse +from botbuilder.schema import Activity +from botframework.connector.auth import ( + ChannelProvider, + CredentialProvider, + GovernmentConstants, + MicrosoftAppCredentials, +) + + +class BotFrameworkHttpClient: + + """ + A skill host adapter implements API to forward activity to a skill and + implements routing ChannelAPI calls from the Skill up through the bot/adapter. + """ + + INVOKE_ACTIVITY_NAME = "SkillEvents.ChannelApiInvoke" + _BOT_IDENTITY_KEY = "BotIdentity" + _APP_CREDENTIALS_CACHE: Dict[str, MicrosoftAppCredentials] = {} + + def __init__( + self, + credential_provider: CredentialProvider, + channel_provider: ChannelProvider = None, + logger: Logger = None, + ): + if not credential_provider: + raise TypeError("credential_provider can't be None") + + self._credential_provider = credential_provider + self._channel_provider = channel_provider + self._logger = logger + self._session = aiohttp.ClientSession() + + async def post_activity( + self, + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, + conversation_id: str, + activity: Activity, + ) -> InvokeResponse: + app_credentials = await self._get_app_credentials(from_bot_id, to_bot_id) + + if not app_credentials: + raise RuntimeError("Unable to get appCredentials to connect to the skill") + + # Get token for the skill call + token = ( + app_credentials.get_access_token() + if app_credentials.microsoft_app_id + else None + ) + + # Capture current activity settings before changing them. + # TODO: DO we need to set the activity ID? (events that are created manually don't have it). + original_conversation_id = activity.conversation.id + original_service_url = activity.service_url + + try: + activity.conversation.id = conversation_id + activity.service_url = service_url + + headers_dict = { + "Content-type": "application/json; charset=utf-8", + } + if token: + headers_dict.update( + {"Authorization": f"Bearer {token}",} + ) + + json_content = json.dumps(activity.serialize()) + resp = await self._session.post( + to_url, data=json_content.encode("utf-8"), headers=headers_dict, + ) + resp.raise_for_status() + data = (await resp.read()).decode() + content = json.loads(data) if data else None + + if content: + return InvokeResponse(status=resp.status_code, body=content) + + finally: + # Restore activity properties. + activity.conversation.id = original_conversation_id + activity.service_url = original_service_url + + async def _get_app_credentials( + self, app_id: str, oauth_scope: str + ) -> MicrosoftAppCredentials: + if not app_id: + return MicrosoftAppCredentials(None, None) + + cache_key = f"{app_id}{oauth_scope}" + app_credentials = BotFrameworkHttpClient._APP_CREDENTIALS_CACHE.get(cache_key) + + if app_credentials: + return app_credentials + + app_password = await self._credential_provider.get_app_password(app_id) + app_credentials = MicrosoftAppCredentials( + app_id, app_password, oauth_scope=oauth_scope + ) + if self._channel_provider and self._channel_provider.is_government(): + app_credentials.oauth_endpoint = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + ) + app_credentials.oauth_scope = ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + BotFrameworkHttpClient._APP_CREDENTIALS_CACHE[cache_key] = app_credentials + return app_credentials diff --git a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py new file mode 100644 index 000000000..4b9222de7 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py @@ -0,0 +1,460 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.schema import ( + Activity, + AttachmentData, + ChannelAccount, + ConversationParameters, + ConversationsResult, + ConversationResourceResponse, + PagedMembersResult, + ResourceResponse, + Transcript, +) + +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, + JwtTokenValidation, +) + + +class ChannelServiceHandler: + """ + Initializes a new instance of the class, + using a credential provider. + """ + + def __init__( + self, + credential_provider: CredentialProvider, + auth_config: AuthenticationConfiguration, + channel_provider: ChannelProvider = None, + ): + if not credential_provider: + raise TypeError("credential_provider can't be None") + + if not auth_config: + raise TypeError("auth_config can't be None") + + self._credential_provider = credential_provider + self._auth_config = auth_config + self._channel_provider = channel_provider + + async def handle_send_to_conversation( + self, auth_header, conversation_id, activity + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_send_to_conversation( + claims_identity, conversation_id, activity + ) + + async def handle_reply_to_activity( + self, auth_header, conversation_id, activity_id, activity + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_reply_to_activity( + claims_identity, conversation_id, activity_id, activity + ) + + async def handle_update_activity( + self, auth_header, conversation_id, activity_id, activity + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_update_activity( + claims_identity, conversation_id, activity_id, activity + ) + + async def handle_delete_activity(self, auth_header, conversation_id, activity_id): + claims_identity = await self._authenticate(auth_header) + await self.on_delete_activity(claims_identity, conversation_id, activity_id) + + async def handle_get_activity_members( + self, auth_header, conversation_id, activity_id + ) -> List[ChannelAccount]: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_activity_members( + claims_identity, conversation_id, activity_id + ) + + async def handle_create_conversation( + self, auth_header, parameters: ConversationParameters + ) -> ConversationResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_create_conversation(claims_identity, parameters) + + async def handle_get_conversations( + self, auth_header, continuation_token: str = "" + ) -> ConversationsResult: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversations(claims_identity, continuation_token) + + async def handle_get_conversation_members( + self, auth_header, conversation_id + ) -> List[ChannelAccount]: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversation_members(claims_identity, conversation_id) + + async def handle_get_conversation_paged_members( + self, + auth_header, + conversation_id, + page_size: int = 0, + continuation_token: str = "", + ) -> PagedMembersResult: + claims_identity = await self._authenticate(auth_header) + return await self.on_get_conversation_paged_members( + claims_identity, conversation_id, page_size, continuation_token + ) + + async def handle_delete_conversation_member( + self, auth_header, conversation_id, member_id + ): + claims_identity = await self._authenticate(auth_header) + await self.on_delete_conversation_member( + claims_identity, conversation_id, member_id + ) + + async def handle_send_conversation_history( + self, auth_header, conversation_id, transcript: Transcript + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_send_conversation_history( + claims_identity, conversation_id, transcript + ) + + async def handle_upload_attachment( + self, auth_header, conversation_id, attachment_upload: AttachmentData + ) -> ResourceResponse: + claims_identity = await self._authenticate(auth_header) + return await self.on_upload_attachment( + claims_identity, conversation_id, attachment_upload + ) + + async def on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + """ + get_conversations() API for Skill + + List the Conversations in which this bot has participated. + + GET from this method with a skip token + + The return value is a ConversationsResult, which contains an array of + ConversationMembers and a skip token. If the skip token is not empty, then + there are further values to be returned. Call this method again with the + returned token to get more values. + + Each ConversationMembers object contains the ID of the conversation and an + array of ChannelAccounts that describe the members of the conversation. + + :param claims_identity: + :param conversation_id: + :param continuation_token: + :return: + """ + raise NotImplementedError() + + async def on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + """ + create_conversation() API for Skill + + Create a new Conversation. + + POST to this method with a + * Bot being the bot creating the conversation + * IsGroup set to true if this is not a direct message (default is false) + * Array containing the members to include in the conversation + + The return value is a ResourceResponse which contains a conversation id + which is suitable for use + in the message payload and REST API uris. + + Most channels only support the semantics of bots initiating a direct + message conversation. An example of how to do that would be: + + var resource = await connector.conversations.CreateConversation(new + ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { new + ChannelAccount("user1") } ); + await connect.Conversations.SendToConversationAsync(resource.Id, new + Activity() ... ) ; + + end. + + :param claims_identity: + :param parameters: + :return: + """ + raise NotImplementedError() + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + """ + send_to_conversation() API for Skill + + This method allows you to send an activity to the end of a conversation. + + This is slightly different from ReplyToActivity(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + + :param claims_identity: + :param conversation_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_send_conversation_history( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + transcript: Transcript, + ) -> ResourceResponse: + """ + send_conversation_history() API for Skill. + + This method allows you to upload the historic activities to the + conversation. + + Sender must ensure that the historic activities have unique ids and + appropriate timestamps. The ids are used by the client to deal with + duplicate activities and the timestamps are used by the client to render + the activities in the right order. + + :param claims_identity: + :param conversation_id: + :param transcript: + :return: + """ + raise NotImplementedError() + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + """ + update_activity() API for Skill. + + Edit an existing activity. + + Some channels allow you to edit an existing activity to reflect the new + state of a bot conversation. + + For example, you can remove buttons after someone has clicked "Approve" + button. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + """ + reply_to_activity() API for Skill. + + This method allows you to reply to an activity. + + This is slightly different from SendToConversation(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + """ + delete_activity() API for Skill. + + Delete an existing activity. + + Some channels allow you to delete an existing activity, and if successful + this method will remove the specified activity. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :return: + """ + raise NotImplementedError() + + async def on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + """ + get_conversation_members() API for Skill. + + Enumerate the members of a conversation. + + This REST API takes a ConversationId and returns a list of ChannelAccount + objects representing the members of the conversation. + + :param claims_identity: + :param conversation_id: + :return: + """ + raise NotImplementedError() + + async def on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + """ + get_conversation_paged_members() API for Skill. + + Enumerate the members of a conversation one page at a time. + + This REST API takes a ConversationId. Optionally a page_size and/or + continuation_token can be provided. It returns a PagedMembersResult, which + contains an array + of ChannelAccounts representing the members of the conversation and a + continuation token that can be used to get more values. + + One page of ChannelAccounts records are returned with each call. The number + of records in a page may vary between channels and calls. The page_size + parameter can be used as + a suggestion. If there are no additional results the response will not + contain a continuation token. If there are no members in the conversation + the Members will be empty or not present in the response. + + A response to a request that has a continuation token from a prior request + may rarely return members from a previous request. + + :param claims_identity: + :param conversation_id: + :param page_size: + :param continuation_token: + :return: + """ + raise NotImplementedError() + + async def on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + """ + delete_conversation_member() API for Skill. + + Deletes a member from a conversation. + + This REST API takes a ConversationId and a memberId (of type string) and + removes that member from the conversation. If that member was the last + member + of the conversation, the conversation will also be deleted. + + :param claims_identity: + :param conversation_id: + :param member_id: + :return: + """ + raise NotImplementedError() + + async def on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + """ + get_activity_members() API for Skill. + + Enumerate the members of an activity. + + This REST API takes a ConversationId and a ActivityId, returning an array + of ChannelAccount objects representing the members of the particular + activity in the conversation. + + :param claims_identity: + :param conversation_id: + :param activity_id: + :return: + """ + raise NotImplementedError() + + async def on_upload_attachment( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + attachment_upload: AttachmentData, + ) -> ResourceResponse: + """ + upload_attachment() API for Skill. + + Upload an attachment directly into a channel's blob storage. + + This is useful because it allows you to store data in a compliant store + when dealing with enterprises. + + The response is a ResourceResponse which contains an AttachmentId which is + suitable for using with the attachments API. + + :param claims_identity: + :param conversation_id: + :param attachment_upload: + :return: + """ + raise NotImplementedError() + + async def _authenticate(self, auth_header: str) -> ClaimsIdentity: + if not auth_header: + is_auth_disabled = ( + await self._credential_provider.is_authentication_disabled() + ) + if is_auth_disabled: + # In the scenario where Auth is disabled, we still want to have the + # IsAuthenticated flag set in the ClaimsIdentity. To do this requires + # adding in an empty claim. + return ClaimsIdentity({}, True) + + raise PermissionError() + + return await JwtTokenValidation.validate_auth_header( + auth_header, + self._credential_provider, + self._channel_provider, + "unknown", + auth_configuration=self._auth_config, + ) diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index f7ab3ae09..5b667ab06 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -35,6 +35,7 @@ "botbuilder.core", "botbuilder.core.adapters", "botbuilder.core.inspection", + "botbuilder.core.integration", ], install_requires=REQUIRES, classifiers=[ diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 6532b1e52..528bbf719 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -57,8 +57,8 @@ def __init__(self, settings=None): def aux_test_authenticate_request(self, request: Activity, auth_header: str): return super().authenticate_request(request, auth_header) - def aux_test_create_connector_client(self, service_url: str): - return super().create_connector_client(service_url) + async def aux_test_create_connector_client(self, service_url: str): + return await super().create_connector_client(service_url) async def authenticate_request(self, request: Activity, auth_header: str): self.tester.assertIsNotNone( @@ -71,7 +71,11 @@ async def authenticate_request(self, request: Activity, auth_header: str): ) return not self.fail_auth - def create_connector_client(self, service_url: str) -> ConnectorClient: + async def create_connector_client( + self, + service_url: str, + identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ) -> ConnectorClient: self.tester.assertIsNotNone( service_url, "create_connector_client() not passed service_url." ) @@ -181,9 +185,9 @@ async def aux_func(context): class TestBotFrameworkAdapter(aiounittest.AsyncTestCase): - def test_should_create_connector_client(self): + async def test_should_create_connector_client(self): adapter = AdapterUnderTest() - client = adapter.aux_test_create_connector_client(REFERENCE.service_url) + client = await adapter.aux_test_create_connector_client(REFERENCE.service_url) self.assertIsNotNone(client, "client not returned.") self.assertIsNotNone(client.conversations, "invalid client returned.") diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index 6d6b0b63c..8d90791bb 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -9,6 +9,7 @@ # regenerated. # -------------------------------------------------------------------------- # pylint: disable=missing-docstring +from .authentication_constants import * from .government_constants import * from .channel_provider import * from .simple_channel_provider import * @@ -19,5 +20,4 @@ from .channel_validation import * from .emulator_validation import * from .jwt_token_extractor import * -from .authentication_constants import * from .authentication_configuration import * diff --git a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py index ee0bc0315..7e9344c79 100644 --- a/libraries/botframework-connector/botframework/connector/auth/channel_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/channel_validation.py @@ -2,7 +2,7 @@ from .authentication_configuration import AuthenticationConfiguration from .verify_options import VerifyOptions -from .constants import Constants +from .authentication_constants import AuthenticationConstants from .jwt_token_extractor import JwtTokenExtractor from .claims_identity import ClaimsIdentity from .credential_provider import CredentialProvider @@ -18,7 +18,7 @@ class ChannelValidation: # TO BOT FROM CHANNEL: Token validation parameters when connecting to a bot # TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS = VerifyOptions( - issuer=[Constants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], + issuer=[AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER], # Audience validation takes place manually in code. audience=None, clock_tolerance=5 * 60, @@ -48,10 +48,8 @@ async def authenticate_channel_token_with_service_url( :return: A valid ClaimsIdentity. :raises Exception: """ - identity = await asyncio.ensure_future( - ChannelValidation.authenticate_channel_token( - auth_header, credentials, channel_id, auth_configuration - ) + identity = await ChannelValidation.authenticate_channel_token( + auth_header, credentials, channel_id, auth_configuration ) service_url_claim = identity.get_claim_value( @@ -87,19 +85,17 @@ async def authenticate_channel_token( metadata_endpoint = ( ChannelValidation.open_id_metadata_endpoint if ChannelValidation.open_id_metadata_endpoint - else Constants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL + else AuthenticationConstants.TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL ) token_extractor = JwtTokenExtractor( ChannelValidation.TO_BOT_FROM_CHANNEL_TOKEN_VALIDATION_PARAMETERS, metadata_endpoint, - Constants.ALLOWED_SIGNING_ALGORITHMS, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, ) - identity = await asyncio.ensure_future( - token_extractor.get_identity_from_auth_header( - auth_header, channel_id, auth_configuration.required_endorsements - ) + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id, auth_configuration.required_endorsements ) return await ChannelValidation.validate_identity(identity, credentials) @@ -123,15 +119,15 @@ async def validate_identity( # Look for the "aud" claim, but only if issued from the Bot Framework if ( - identity.get_claim_value(Constants.ISSUER_CLAIM) - != Constants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) + != AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER ): # The relevant Audience Claim MUST be present. Not Authorized. raise Exception("Unauthorized. Audience Claim MUST be present.") # The AppId from the claim in the token must match the AppId specified by the developer. # Note that the Bot Framework uses the Audience claim ("aud") to pass the AppID. - aud_claim = identity.get_claim_value(Constants.AUDIENCE_CLAIM) + aud_claim = identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM) is_valid_app_id = await asyncio.ensure_future( credentials.is_valid_appid(aud_claim or "") ) diff --git a/libraries/botframework-connector/botframework/connector/auth/constants.py b/libraries/botframework-connector/botframework/connector/auth/constants.py deleted file mode 100644 index 03a95a908..000000000 --- a/libraries/botframework-connector/botframework/connector/auth/constants.py +++ /dev/null @@ -1,31 +0,0 @@ -class Constants: # pylint: disable=too-few-public-methods - """ - TO CHANNEL FROM BOT: Login URL prefix - """ - - TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX = "https://login.microsoftonline.com/" - - """ - TO CHANNEL FROM BOT: Login URL token endpoint path - """ - TO_CHANNEL_FROM_BOT_TOKEN_ENDPOINT_PATH = "/oauth2/v2.0/token" - - """ - TO CHANNEL FROM BOT: Default tenant from which to obtain a token for bot to channel communication - """ - DEFAULT_CHANNEL_AUTH_TENANT = "botframework.com" - - TO_BOT_FROM_CHANNEL_TOKEN_ISSUER = "https://api.botframework.com" - - TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL = ( - "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" - ) - TO_BOT_FROM_CHANNEL_OPEN_ID_METADATA_URL = ( - "https://login.botframework.com/v1/.well-known/openidconfiguration" - ) - - ALLOWED_SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512"] - - AUTHORIZED_PARTY = "azp" - AUDIENCE_CLAIM = "aud" - ISSUER_CLAIM = "iss" diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 2657e6222..12738f388 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -8,7 +8,7 @@ from .jwt_token_extractor import JwtTokenExtractor from .verify_options import VerifyOptions -from .constants import Constants +from .authentication_constants import AuthenticationConstants from .credential_provider import CredentialProvider from .claims_identity import ClaimsIdentity from .government_constants import GovernmentConstants @@ -112,13 +112,13 @@ async def authenticate_emulator_token( open_id_metadata = ( GovernmentConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL if is_gov - else Constants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL + else AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPEN_ID_METADATA_URL ) token_extractor = JwtTokenExtractor( EmulatorValidation.TO_BOT_FROM_EMULATOR_TOKEN_VALIDATION_PARAMETERS, open_id_metadata, - Constants.ALLOWED_SIGNING_ALGORITHMS, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, ) identity = await asyncio.ensure_future( @@ -158,7 +158,9 @@ async def authenticate_emulator_token( app_id = app_id_claim elif version_claim == "2.0": # Emulator, "2.0" puts the AppId in the "azp" claim. - app_authz_claim = identity.get_claim_value(Constants.AUTHORIZED_PARTY) + app_authz_claim = identity.get_claim_value( + AuthenticationConstants.AUTHORIZED_PARTY + ) if not app_authz_claim: # No claim around AppID. Not Authorized. raise Exception( diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index ef080c5d4..c4a0b26e3 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -27,6 +27,7 @@ async def authenticate_request( auth_header: str, credentials: CredentialProvider, channel_service_or_provider: Union[str, ChannelProvider] = "", + auth_configuration: AuthenticationConfiguration = None, ) -> ClaimsIdentity: """Authenticates the request and sets the service url in the set of trusted urls. :param activity: The incoming Activity from the Bot Framework or the Emulator @@ -34,7 +35,8 @@ async def authenticate_request( :param auth_header: The Bearer token included as part of the request :type auth_header: str :param credentials: The set of valid credentials, such as the Bot Application ID - :param channel_service: String for the channel service + :param channel_service_or_provider: String for the channel service + :param auth_configuration: Authentication configuration :type credentials: CredentialProvider :raises Exception: @@ -55,6 +57,7 @@ async def authenticate_request( channel_service_or_provider, activity.channel_id, activity.service_url, + auth_configuration, ) # On the standard Auth path, we need to trust the URL that was incoming. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 317293ede..180fda6dd 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -1,12 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - from datetime import datetime, timedelta from urllib.parse import urlparse + +from adal import AuthenticationContext import requests from msrest.authentication import Authentication -from .constants import Constants +from .authentication_constants import AuthenticationConstants # TODO: Decide to move this to Constants or viceversa (when porting OAuth) AUTH_SETTINGS = { @@ -34,9 +35,9 @@ def __init__(self): def from_json(json_values): result = _OAuthResponse() try: - result.token_type = json_values["token_type"] - result.access_token = json_values["access_token"] - result.expires_in = json_values["expires_in"] + result.token_type = json_values["tokenType"] + result.access_token = json_values["accessToken"] + result.expires_in = json_values["expiresIn"] except KeyError: pass return result @@ -79,15 +80,16 @@ def __init__( tenant = ( channel_auth_tenant if channel_auth_tenant - else Constants.DEFAULT_CHANNEL_AUTH_TENANT + else AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT ) self.oauth_endpoint = ( - Constants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX - + tenant - + Constants.TO_CHANNEL_FROM_BOT_TOKEN_ENDPOINT_PATH + AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + tenant ) - self.oauth_scope = oauth_scope or AUTH_SETTINGS["refreshScope"] - self.token_cache_key = app_id + "-cache" + self.oauth_scope = ( + oauth_scope or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER + ) + self.token_cache_key = app_id + "-cache" if app_id else None + self.authentication_context = AuthenticationContext(self.oauth_endpoint) # pylint: disable=arguments-differ def signed_session(self, session: requests.Session = None) -> requests.Session: @@ -140,19 +142,14 @@ def refresh_token(self) -> _OAuthResponse: """ returns: _OAuthResponse """ - options = { - "grant_type": "client_credentials", - "client_id": self.microsoft_app_id, - "client_secret": self.microsoft_app_password, - "scope": self.oauth_scope, - } - response = requests.post(self.oauth_endpoint, data=options) - response.raise_for_status() + token = self.authentication_context.acquire_token_with_client_credentials( + self.oauth_scope, self.microsoft_app_id, self.microsoft_app_password + ) - oauth_response = _OAuthResponse.from_json(response.json()) + oauth_response = _OAuthResponse.from_json(token) oauth_response.expiration_time = datetime.now() + timedelta( - seconds=(oauth_response.expires_in - 300) + seconds=(int(oauth_response.expires_in) - 300) ) return oauth_response diff --git a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py index 900fd927b..c276b8e48 100644 --- a/libraries/botframework-connector/tests/test_microsoft_app_credentials.py +++ b/libraries/botframework-connector/tests/test_microsoft_app_credentials.py @@ -7,7 +7,7 @@ class TestMicrosoftAppCredentials(aiounittest.AsyncTestCase): async def test_app_credentials(self): default_scope_case_1 = MicrosoftAppCredentials("some_app", "some_password") assert ( - AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER == default_scope_case_1.oauth_scope ) @@ -16,7 +16,7 @@ async def test_app_credentials(self): "some_app", "some_password", "some_tenant" ) assert ( - AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER == default_scope_case_2.oauth_scope ) diff --git a/samples/experimental/test-protocol/app.py b/samples/experimental/test-protocol/app.py new file mode 100644 index 000000000..e95d2f1be --- /dev/null +++ b/samples/experimental/test-protocol/app.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp import web +from aiohttp.web import Request, Response + +from botframework.connector.auth import AuthenticationConfiguration, SimpleCredentialProvider +from botbuilder.core.integration import BotFrameworkHttpClient, aiohttp_channel_service_routes +from botbuilder.schema import Activity + +from config import DefaultConfig +from routing_id_factory import RoutingIdFactory +from routing_handler import RoutingHandler + + +CONFIG = DefaultConfig() +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = BotFrameworkHttpClient(CREDENTIAL_PROVIDER) +AUTH_CONFIG = AuthenticationConfiguration() + +TO_URI = CONFIG.NEXT +SERVICE_URL = CONFIG.SERVICE_URL + +FACTORY = RoutingIdFactory() + +ROUTING_HANDLER = RoutingHandler(FACTORY, CREDENTIAL_PROVIDER, AUTH_CONFIG) + + +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + inbound_activity: Activity = Activity().deserialize(body) + + current_conversation_id = inbound_activity.conversation.id + current_service_url = inbound_activity.service_url + + next_conversation_id = FACTORY.create_skill_conversation_id(current_conversation_id, current_service_url) + + await CLIENT.post_activity(CONFIG.APP_ID, CONFIG.SKILL_APP_ID, TO_URI, SERVICE_URL, next_conversation_id, inbound_activity) + return Response(status=201) + +APP = web.Application() + +APP.router.add_post("/api/messages", messages) +APP.router.add_routes(aiohttp_channel_service_routes(ROUTING_HANDLER, "/api/connector")) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/test-protocol/config.py b/samples/experimental/test-protocol/config.py new file mode 100644 index 000000000..9a6ec94ea --- /dev/null +++ b/samples/experimental/test-protocol/config.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3428 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + NEXT = "http://localhost:3978/api/messages" + SERVICE_URL = "http://localhost:3428/api/connector" + SKILL_APP_ID = "" diff --git a/samples/experimental/test-protocol/routing_handler.py b/samples/experimental/test-protocol/routing_handler.py new file mode 100644 index 000000000..0de21123b --- /dev/null +++ b/samples/experimental/test-protocol/routing_handler.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core.integration import ChannelServiceHandler +from botbuilder.schema import ( + Activity, + ChannelAccount, + ConversationParameters, + ConversationResourceResponse, + ConversationsResult, + PagedMembersResult, + ResourceResponse +) +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, + MicrosoftAppCredentials +) + +from routing_id_factory import RoutingIdFactory + + +class RoutingHandler(ChannelServiceHandler): + def __init__( + self, + conversation_id_factory: RoutingIdFactory, + credential_provider: CredentialProvider, + auth_configuration: AuthenticationConfiguration, + channel_provider: ChannelProvider = None + ): + super().__init__(credential_provider, auth_configuration, channel_provider) + self._factory = conversation_id_factory + self._credentials = MicrosoftAppCredentials(None, None) + + async def on_reply_to_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.send_to_conversation(back_conversation_id, activity) + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + activity.conversation.id = back_conversation_id + activity.service_url = back_service_url + + return await connector_client.conversations.update_activity(back_conversation_id, activity.id, activity) + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + back_conversation_id, back_service_url = self._factory.get_conversation_info(conversation_id) + connector_client = self._get_connector_client(back_service_url) + + return await connector_client.conversations.delete_activity(back_conversation_id, activity_id) + + async def on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + # This call will be used in Teams scenarios. + + # Scenario #1 - creating a thread with an activity in a Channel in a Team + # In order to know the serviceUrl in the case of Teams we would need to look it up based upon the + # TeamsChannelData. + # The inbound activity will contain the TeamsChannelData and so will the ConversationParameters. + + # Scenario #2 - starting a one on one conversation with a particular user + # - needs further analysis - + + back_service_url = "http://tempuri" + connector_client = self._get_connector_client(back_service_url) + + return await connector_client.conversations.create_conversation(parameters) + + async def on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + return await super().on_delete_conversation_member(claims_identity, conversation_id, member_id) + + async def on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + return await super().on_get_activity_members(claims_identity, conversation_id, activity_id) + + async def on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + return await super().on_get_conversation_members(claims_identity, conversation_id) + + async def on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + return await super().on_get_conversations(claims_identity, continuation_token) + + async def on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + return await super().on_get_conversation_paged_members(claims_identity, conversation_id, continuation_token) + + def _get_connector_client(self, service_url: str): + return ConnectorClient(self._credentials, service_url) diff --git a/samples/experimental/test-protocol/routing_id_factory.py b/samples/experimental/test-protocol/routing_id_factory.py new file mode 100644 index 000000000..c5ddb7524 --- /dev/null +++ b/samples/experimental/test-protocol/routing_id_factory.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from typing import Dict, Tuple + + +class RoutingIdFactory: + def __init__(self): + self._forward_x_ref: Dict[str, str] = {} + self._backward_x_ref: Dict[str, Tuple[str, str]] = {} + + def create_skill_conversation_id(self, conversation_id: str, service_url: str) -> str: + result = self._forward_x_ref.get(conversation_id, str(uuid4())) + + self._forward_x_ref[conversation_id] = result + self._backward_x_ref[result] = (conversation_id, service_url) + + return result + + def get_conversation_info(self, encoded_conversation_id) -> Tuple[str, str]: + return self._backward_x_ref[encoded_conversation_id]