diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 32178f3e7..c5c038353 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -4,6 +4,8 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +from botbuilder.schema import InvokeResponse + from . import conversation_reference_extension from .about import __version__ @@ -18,12 +20,13 @@ from .bot_telemetry_client import BotTelemetryClient, Severity from .card_factory import CardFactory from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler +from .cloud_adapter_base import CloudAdapterBase +from .cloud_channel_service_handler import CloudChannelServiceHandler from .component_registration import ComponentRegistration from .conversation_state import ConversationState from .oauth.extended_user_token_provider import ExtendedUserTokenProvider from .oauth.user_token_provider import UserTokenProvider from .intent_score import IntentScore -from .invoke_response import InvokeResponse from .memory_storage import MemoryStorage from .memory_transcript_store import MemoryTranscriptStore from .message_factory import MessageFactory @@ -63,6 +66,8 @@ "calculate_change_hash", "CardFactory", "ChannelServiceHandler", + "CloudAdapterBase", + "CloudChannelServiceHandler", "ComponentRegistration", "ConversationState", "conversation_reference_extension", diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index da7510167..be847739e 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -9,6 +9,7 @@ AdaptiveCardInvokeResponse, AdaptiveCardInvokeValue, ChannelAccount, + InvokeResponse, MessageReaction, SignInConstants, ) @@ -16,7 +17,6 @@ from .bot import Bot from .serializer_helper import serializer_helper from .bot_framework_adapter import BotFrameworkAdapter -from .invoke_response import InvokeResponse from .turn_context import TurnContext diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index 5c7d26396..cb073bc51 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -17,6 +17,7 @@ class BotAdapter(ABC): BOT_OAUTH_SCOPE_KEY = "botbuilder.core.BotAdapter.OAuthScope" BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" BOT_CALLBACK_HANDLER_KEY = "BotCallbackHandler" + _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" def __init__( self, on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index a6a247663..63ef5fdc3 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -47,6 +47,7 @@ ConversationParameters, ConversationReference, ExpectedReplies, + InvokeResponse, TokenResponse, ResourceResponse, DeliveryModes, @@ -60,7 +61,6 @@ ExtendedUserTokenProvider, ) from .turn_context import TurnContext -from .invoke_response import InvokeResponse from .conversation_reference_extension import get_continuation_activity USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" @@ -186,8 +186,6 @@ class BotFrameworkAdapter( upon the activity, both before and after the bot logic runs. """ - _INVOKE_RESPONSE_KEY = "BotFrameworkAdapter.InvokeResponse" - def __init__(self, settings: BotFrameworkAdapterSettings): """ Initializes a new instance of the :class:`BotFrameworkAdapter` class. diff --git a/libraries/botbuilder-core/botbuilder/core/cloud_adapter_base.py b/libraries/botbuilder-core/botbuilder/core/cloud_adapter_base.py new file mode 100644 index 000000000..53a6af025 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/cloud_adapter_base.py @@ -0,0 +1,339 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from asyncio import sleep +from copy import Error +from http import HTTPStatus +from typing import Awaitable, Callable, List, Union + +from botbuilder.core.invoke_response import InvokeResponse + +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationReference, + DeliveryModes, + ExpectedReplies, + ResourceResponse, +) +from botframework.connector import Channels, ConnectorClient +from botframework.connector.auth import ( + AuthenticationConstants, + BotFrameworkAuthentication, + ClaimsIdentity, +) +from botframework.connector.auth.authenticate_request_result import ( + AuthenticateRequestResult, +) +from botframework.connector.auth.connector_factory import ConnectorFactory +from botframework.connector.auth.user_token_client import UserTokenClient +from .bot_adapter import BotAdapter +from .conversation_reference_extension import get_continuation_activity +from .turn_context import TurnContext + + +class CloudAdapterBase(BotAdapter, ABC): + CONNECTOR_FACTORY_KEY = "ConnectorFactory" + USER_TOKEN_CLIENT_KEY = "UserTokenClient" + + def __init__( + self, bot_framework_authentication: BotFrameworkAuthentication + ) -> None: + super().__init__() + + if not bot_framework_authentication: + raise TypeError("Expected BotFrameworkAuthentication but got None instead") + + self.bot_framework_authentication = bot_framework_authentication + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + if not context: + raise TypeError("Expected TurnContext but got None instead") + + if activities is None: + raise TypeError("Expected Activities list but got None instead") + + if len(activities) == 0: + raise TypeError("Expecting one or more activities, but the list was empty.") + + responses = [] + + for activity in activities: + activity.id = None + + response = ResourceResponse() + + if activity.type == "delay": + delay_time = int((activity.value or 1000) / 1000) + await sleep(delay_time) + elif activity.type == ActivityTypes.invoke_response: + context.turn_state[self._INVOKE_RESPONSE_KEY] = activity + elif ( + activity.type == ActivityTypes.trace + and activity.channel_id != Channels.emulator + ): + # no-op + pass + else: + connector_client: ConnectorClient = context.turn_state.get( + self.BOT_CONNECTOR_CLIENT_KEY + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + if activity.reply_to_id: + response = await connector_client.conversations.reply_to_activity( + activity.conversation.id, activity.reply_to_id, activity + ) + else: + response = await connector_client.conversations.send_to_conversation( + activity.conversation.id, activity + ) + + response = response or ResourceResponse(activity.id or "") + + responses.append(response) + + return responses + + async def update_activity(self, context: TurnContext, activity: Activity): + if not context: + raise TypeError("Expected TurnContext but got None instead") + + if activity is None: + raise TypeError("Expected Activity but got None instead") + + connector_client: ConnectorClient = context.turn_state.get( + self.BOT_CONNECTOR_CLIENT_KEY + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + response = await connector_client.conversations.update_activity( + activity.conversation.id, activity.reply_to_id, activity + ) + + response_id = response.id if response and response.id else None + + return ResourceResponse(id=response_id) if response_id else None + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + if not context: + raise TypeError("Expected TurnContext but got None instead") + + if not reference: + raise TypeError("Expected ConversationReference but got None instead") + + connector_client: ConnectorClient = context.turn_state.get( + self.BOT_CONNECTOR_CLIENT_KEY + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + await connector_client.conversations.delete_activity( + reference.conversation.id, reference.activity_id + ) + + async def continue_conversation( # pylint: disable=arguments-differ + self, reference: ConversationReference, callback: Callable, + ): + """ + Sends a proactive message to a conversation. + Call this method to proactively send a message to a conversation. + Most channels require a user to initiate a conversation with a bot before the bot can send activities + to the user. + + :param reference: A reference to the conversation to continue. + :type reference: :class:`botbuilder.schema.ConversationReference` + :param callback: The method to call for the resulting bot turn. + :type callback: :class:`typing.Callable` + """ + return await self.process_proactive( + self.create_claims_identity(), + get_continuation_activity(reference), + None, + callback, + ) + + async def continue_conversation_with_claims( + self, + claims_identity: ClaimsIdentity, + reference: ConversationReference, + audience: str, + logic: Callable[[TurnContext], Awaitable], + ): + return await self.process_proactive( + claims_identity, get_continuation_activity(reference), audience, logic + ) + + async def process_proactive( + self, + claims_identity: ClaimsIdentity, + continuation_activity: Activity, + audience: str, + logic: Callable[[TurnContext], Awaitable], + ): + # Create the connector factory and the inbound request, extracting parameters and then create a + # connector for outbound requests. + connector_factory = self.bot_framework_authentication.create_connector_factory( + claims_identity + ) + + # Create the connector client to use for outbound requests. + connector_client = await connector_factory.create( + continuation_activity.service_url, audience + ) + + # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) + user_token_client = await self.bot_framework_authentication.create_user_token_client( + claims_identity + ) + + # Create a turn context and run the pipeline. + context = self._create_turn_context( + continuation_activity, + claims_identity, + audience, + connector_client, + user_token_client, + logic, + connector_factory, + ) + + # Run the pipeline + await self.run_pipeline(context, logic) + + async def process_activity( + self, + auth_header_or_authenticate_request_result: Union[ + str, AuthenticateRequestResult + ], + activity: Activity, + logic: Callable[[TurnContext], Awaitable], + ): + """ + Creates a turn context and runs the middleware pipeline for an incoming activity. + + :param auth_header: The HTTP authentication header of the request + :type auth_header: :class:`typing.Union[typing.str, AuthenticateRequestResult]` + :param activity: The incoming activity + :type activity: :class:`Activity` + :param logic: The logic to execute at the end of the adapter's middleware pipeline. + :type logic: :class:`typing.Callable` + + :return: A task that represents the work queued to execute. + + .. remarks:: + This class processes an activity received by the bots web server. This includes any messages + sent from a user and is the method that drives what's often referred to as the + bots *reactive messaging* flow. + Call this method to reactively send a message to a conversation. + If the task completes successfully, then an :class:`InvokeResponse` is returned; + otherwise. `null` is returned. + """ + # Authenticate the inbound request, extracting parameters and create a ConnectorFactory for creating a + # Connector for outbound requests. + authenticate_request_result = ( + await self.bot_framework_authentication.authenticate_request( + activity, auth_header_or_authenticate_request_result + ) + if isinstance(auth_header_or_authenticate_request_result, str) + else auth_header_or_authenticate_request_result + ) + + # Set the caller_id on the activity + activity.caller_id = authenticate_request_result.caller_id + + # Create the connector client to use for outbound requests. + connector_client = ( + await authenticate_request_result.connector_factory.create( + activity.service_url, authenticate_request_result.audience + ) + if authenticate_request_result.connector_factory + else None + ) + + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + # Create a UserTokenClient instance for the application to use. + # (For example, it would be used in a sign-in prompt.) + user_token_client = await self.bot_framework_authentication.create_user_token_client( + authenticate_request_result.claims_identity + ) + + # Create a turn context and run the pipeline. + context = self._create_turn_context( + activity, + authenticate_request_result.claims_identity, + authenticate_request_result.audience, + connector_client, + user_token_client, + logic, + authenticate_request_result.connector_factory, + ) + + # Run the pipeline + await self.run_pipeline(context, logic) + + # If there are any results they will have been left on the TurnContext. + return self._process_turn_results(context) + + def create_claims_identity(self, bot_app_id: str = "") -> ClaimsIdentity: + return ClaimsIdentity( + { + AuthenticationConstants.AUDIENCE_CLAIM: bot_app_id, + AuthenticationConstants.APP_ID_CLAIM: bot_app_id, + }, + True, + ) + + def _create_turn_context( + self, + activity: Activity, + claims_identity: ClaimsIdentity, + oauth_scope: str, + connector_client: ConnectorClient, + user_token_client: UserTokenClient, + logic: Callable[[TurnContext], Awaitable], + connector_factory: ConnectorFactory, + ) -> TurnContext: + context = TurnContext(self, activity) + + context.turn_state[self.BOT_IDENTITY_KEY] = claims_identity + context.turn_state[self.BOT_CONNECTOR_CLIENT_KEY] = connector_client + context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client + + context.turn_state[self.BOT_CALLBACK_HANDLER_KEY] = logic + + context.turn_state[self.CONNECTOR_FACTORY_KEY] = connector_factory + context.turn_state[self.BOT_OAUTH_SCOPE_KEY] = oauth_scope + + return context + + def _process_turn_results(self, context: TurnContext) -> InvokeResponse: + # Handle ExpectedReplies scenarios where all activities have been + # buffered and sent back at once in an invoke response. + if context.activity.delivery_mode == DeliveryModes.expect_replies: + return InvokeResponse( + status=HTTPStatus.OK, + body=ExpectedReplies(activities=context.buffered_reply_activities), + ) + + # Handle Invoke scenarios where the bot will return a specific body and return code. + if context.activity.type == ActivityTypes.invoke: + activity_invoke_response: Activity = context.turn_state.get( + self._INVOKE_RESPONSE_KEY + ) + if not activity_invoke_response: + return InvokeResponse(status=HTTPStatus.NOT_IMPLEMENTED) + + return activity_invoke_response.value + + # No body to return + return None diff --git a/libraries/botbuilder-core/botbuilder/core/cloud_channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/cloud_channel_service_handler.py new file mode 100644 index 000000000..f3769d753 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/cloud_channel_service_handler.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botframework.connector.auth import BotFrameworkAuthentication, ClaimsIdentity + +from .channel_service_handler import ChannelServiceHandler + + +class CloudChannelServiceHandler(ChannelServiceHandler): + def __init__( # pylint: disable=super-init-not-called + self, auth: BotFrameworkAuthentication + ): + if not auth: + raise TypeError("Auth can't be None") + self._auth = auth + + async def _authenticate(self, auth_header: str) -> ClaimsIdentity: + return await self._auth.authenticate_channel_request(auth_header) diff --git a/libraries/botbuilder-core/botbuilder/core/configuration_service_client_credentials_factory.py b/libraries/botbuilder-core/botbuilder/core/configuration_service_client_credentials_factory.py new file mode 100644 index 000000000..cd9fbefc5 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/configuration_service_client_credentials_factory.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any + +from botframework.connector.auth import PasswordServiceClientCredentialFactory + + +class ConfigurationServiceClientCredentialFactory( + PasswordServiceClientCredentialFactory +): + def __init__(self, configuration: Any) -> None: + if not hasattr(configuration, "APP_ID"): + raise Exception("Property 'APP_ID' is expected in configuration object") + if not hasattr(configuration, "APP_PASSWORD"): + raise Exception( + "Property 'APP_PASSWORD' is expected in configuration object" + ) + super().__init__(configuration.APP_ID, configuration.APP_PASSWORD) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py index 0437ff48e..b922a692d 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py @@ -8,8 +8,8 @@ from .bot_framework_skill import BotFrameworkSkill from .bot_framework_client import BotFrameworkClient from .conversation_id_factory import ConversationIdFactoryBase -from .skill_conversation_id_factory import SkillConversationIdFactory from .skill_handler import SkillHandler +from .skill_conversation_id_factory import SkillConversationIdFactory from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions from .skill_conversation_reference import SkillConversationReference diff --git a/libraries/botbuilder-core/botbuilder/core/skills/_skill_handler_impl.py b/libraries/botbuilder-core/botbuilder/core/skills/_skill_handler_impl.py new file mode 100644 index 000000000..ff58f2e02 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/_skill_handler_impl.py @@ -0,0 +1,296 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from logging import Logger +from typing import Callable + +from botbuilder.core import Bot, BotAdapter, TurnContext +from botbuilder.schema import ( + Activity, + ActivityTypes, + ResourceResponse, + CallerIdConstants, +) +from botframework.connector.auth import ( + ClaimsIdentity, + JwtTokenValidation, +) +from .skill_conversation_reference import SkillConversationReference +from .conversation_id_factory import ConversationIdFactoryBase + +from .skill_handler import SkillHandler + + +class _SkillHandlerImpl(SkillHandler): + def __init__( # pylint: disable=super-init-not-called + self, + skill_conversation_reference_key: str, + adapter: BotAdapter, + bot: Bot, + conversation_id_factory: ConversationIdFactoryBase, + get_oauth_scope: Callable[[], str], + logger: Logger = None, + ): + if not skill_conversation_reference_key: + raise TypeError("skill_conversation_reference_key can't be None") + if not adapter: + raise TypeError("adapter can't be None") + if not bot: + raise TypeError("bot can't be None") + if not conversation_id_factory: + raise TypeError("conversation_id_factory can't be None") + + self._skill_conversation_reference_key = skill_conversation_reference_key + self._adapter = adapter + self._bot = bot + self._conversation_id_factory = conversation_id_factory + self._get_oauth_scope = get_oauth_scope or (lambda: "") + self._logger = logger + + 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(conversation_id) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversation_id,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: Claims identity for the bot. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param conversation_id:The conversation ID. + :type conversation_id: str + :param activity: Activity to send. + :type activity: Activity + :return: + """ + return await self._process_activity( + claims_identity, conversation_id, None, activity, + ) + + 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(conversation_id) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversation_id,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: Claims identity for the bot. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param conversation_id:The conversation ID. + :type conversation_id: str + :param activity_id: Activity ID to send. + :type activity_id: str + :param activity: Activity to send. + :type activity: Activity + :return: + """ + return await self._process_activity( + claims_identity, conversation_id, activity_id, activity, + ) + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str + ): + skill_conversation_reference = await self._get_skill_conversation_reference( + conversation_id + ) + + async def callback(turn_context: TurnContext): + turn_context.turn_state[ + self.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference + await turn_context.delete_activity(activity_id) + + await self._adapter.continue_conversation( + skill_conversation_reference.conversation_reference, + callback, + claims_identity=claims_identity, + audience=skill_conversation_reference.oauth_scope, + ) + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + skill_conversation_reference = await self._get_skill_conversation_reference( + conversation_id + ) + + resource_response: ResourceResponse = None + + async def callback(turn_context: TurnContext): + nonlocal resource_response + turn_context.turn_state[ + self.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference + activity.apply_conversation_reference( + skill_conversation_reference.conversation_reference + ) + turn_context.activity.id = activity_id + turn_context.activity.caller_id = ( + f"{CallerIdConstants.bot_to_bot_prefix}" + f"{JwtTokenValidation.get_app_id_from_claims(claims_identity.claims)}" + ) + resource_response = await turn_context.update_activity(activity) + + await self._adapter.continue_conversation( + skill_conversation_reference.conversation_reference, + callback, + claims_identity=claims_identity, + audience=skill_conversation_reference.oauth_scope, + ) + + return resource_response or ResourceResponse(id=str(uuid4()).replace("-", "")) + + @staticmethod + def _apply_skill_activity_to_turn_context_activity( + context: TurnContext, activity: Activity + ): + context.activity.type = activity.type + context.activity.text = activity.text + context.activity.code = activity.code + context.activity.name = activity.name + context.activity.relates_to = activity.relates_to + + context.activity.reply_to_id = activity.reply_to_id + context.activity.value = activity.value + context.activity.entities = activity.entities + context.activity.locale = activity.locale + context.activity.local_timestamp = activity.local_timestamp + context.activity.timestamp = activity.timestamp + context.activity.channel_data = activity.channel_data + context.activity.additional_properties = activity.additional_properties + + async def _process_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + reply_to_activity_id: str, + activity: Activity, + ) -> ResourceResponse: + skill_conversation_reference = await self._get_skill_conversation_reference( + conversation_id + ) + + # If an activity is sent, return the ResourceResponse + resource_response: ResourceResponse = None + + async def callback(context: TurnContext): + nonlocal resource_response + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference + + TurnContext.apply_conversation_reference( + activity, skill_conversation_reference.conversation_reference + ) + + context.activity.id = reply_to_activity_id + + app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + context.activity.caller_id = ( + f"{CallerIdConstants.bot_to_bot_prefix}{app_id}" + ) + + if activity.type == ActivityTypes.end_of_conversation: + await self._conversation_id_factory.delete_conversation_reference( + conversation_id + ) + await self._send_to_bot(activity, context) + elif activity.type == ActivityTypes.event: + await self._send_to_bot(activity, context) + elif activity.type in (ActivityTypes.command, ActivityTypes.command_result): + if activity.name.startswith("application/"): + # Send to channel and capture the resource response for the SendActivityCall so we can return it. + resource_response = await context.send_activity(activity) + else: + await self._send_to_bot(activity, context) + else: + # Capture the resource response for the SendActivityCall so we can return it. + resource_response = await context.send_activity(activity) + + await self._adapter.continue_conversation( + skill_conversation_reference.conversation_reference, + callback, + claims_identity=claims_identity, + audience=skill_conversation_reference.oauth_scope, + ) + + if not resource_response: + resource_response = ResourceResponse(id=str(uuid4())) + + return resource_response + + async def _get_skill_conversation_reference( + self, conversation_id: str + ) -> SkillConversationReference: + # Get the SkillsConversationReference + try: + skill_conversation_reference = await self._conversation_id_factory.get_skill_conversation_reference( + conversation_id + ) + except (NotImplementedError, AttributeError): + if self._logger: + self._logger.log( + 30, + "Got NotImplementedError when trying to call get_skill_conversation_reference() " + "on the SkillConversationIdFactory, attempting to use deprecated " + "get_conversation_reference() method instead.", + ) + + # ConversationIdFactory can return either a SkillConversationReference (the newer way), + # or a ConversationReference (the old way, but still here for compatibility). If a + # ConversationReference is returned, build a new SkillConversationReference to simplify + # the remainder of this method. + conversation_reference_result = await self._conversation_id_factory.get_conversation_reference( + conversation_id + ) + skill_conversation_reference: SkillConversationReference = SkillConversationReference( + conversation_reference=conversation_reference_result, + oauth_scope=self._get_oauth_scope(), + ) + + if not skill_conversation_reference: + raise KeyError("SkillConversationReference not found") + + if not skill_conversation_reference.conversation_reference: + raise KeyError("conversationReference not found") + + return skill_conversation_reference + + async def _send_to_bot(self, activity: Activity, context: TurnContext): + _SkillHandlerImpl._apply_skill_activity_to_turn_context_activity( + context, activity + ) + await self._bot.on_turn(context) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/cloud_skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/cloud_skill_handler.py new file mode 100644 index 000000000..4ebba8c67 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/cloud_skill_handler.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger +from botbuilder.core import BotAdapter, Bot, CloudChannelServiceHandler +from botbuilder.schema import Activity, ResourceResponse +from botframework.connector.auth import BotFrameworkAuthentication, ClaimsIdentity + +from .conversation_id_factory import ConversationIdFactoryBase +from .skill_handler import SkillHandler +from ._skill_handler_impl import _SkillHandlerImpl + + +class CloudSkillHandler(CloudChannelServiceHandler): + SKILL_CONVERSATION_REFERENCE_KEY = SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + + def __init__( + self, + adapter: BotAdapter, + bot: Bot, + conversation_id_factory: ConversationIdFactoryBase, + auth: BotFrameworkAuthentication, + logger: Logger = None, + ): + super().__init__(auth) + + if not adapter: + raise TypeError("adapter can't be None") + if not bot: + raise TypeError("bot can't be None") + if not conversation_id_factory: + raise TypeError("conversation_id_factory can't be None") + + self._inner = _SkillHandlerImpl( + self.SKILL_CONVERSATION_REFERENCE_KEY, + adapter, + bot, + conversation_id_factory, + auth.get_originating_audience, + logger, + ) + + 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(conversation_id) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversation_id,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: Claims identity for the bot. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param conversation_id:The conversation ID. + :type conversation_id: str + :param activity: Activity to send. + :type activity: Activity + :return: + """ + return await self._inner.on_send_to_conversation( + claims_identity, conversation_id, activity, + ) + + 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(conversation_id) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversation_id,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: Claims identity for the bot. + :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param conversation_id:The conversation ID. + :type conversation_id: str + :param activity_id: Activity ID to send. + :type activity_id: str + :param activity: Activity to send. + :type activity: Activity + :return: + """ + return await self._inner.on_reply_to_activity( + claims_identity, conversation_id, activity_id, activity, + ) + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str + ): + await self._inner.on_delete_activity( + claims_identity, conversation_id, activity_id + ) + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + return await self._inner.on_update_activity( + claims_identity, conversation_id, activity_id, activity + ) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py index 3fd322cad..6cee0d0bc 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py @@ -1,86 +1,86 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from uuid import uuid4 as uuid -from botbuilder.core import TurnContext, Storage -from .conversation_id_factory import ConversationIdFactoryBase -from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions -from .skill_conversation_reference import SkillConversationReference -from .skill_conversation_reference import ConversationReference - - -class SkillConversationIdFactory(ConversationIdFactoryBase): - def __init__(self, storage: Storage): - if not storage: - raise TypeError("storage can't be None") - - self._storage = storage - - async def create_skill_conversation_id( # pylint: disable=arguments-differ - self, options: SkillConversationIdFactoryOptions - ) -> str: - """ - Creates a new `SkillConversationReference`. - - :param options: Creation options to use when creating the `SkillConversationReference`. - :type options: :class:`botbuilder.core.skills.SkillConversationIdFactoryOptions` - :return: ID of the created `SkillConversationReference`. - """ - - if not options: - raise TypeError("options can't be None") - - conversation_reference = TurnContext.get_conversation_reference( - options.activity - ) - - skill_conversation_id = str(uuid()) - - # Create the SkillConversationReference instance. - skill_conversation_reference = SkillConversationReference( - conversation_reference=conversation_reference, - oauth_scope=options.from_bot_oauth_scope, - ) - - # Store the SkillConversationReference using the skill_conversation_id as a key. - skill_conversation_info = {skill_conversation_id: skill_conversation_reference} - - await self._storage.write(skill_conversation_info) - - # Return the generated skill_conversation_id (that will be also used as the conversation ID to call the skill). - return skill_conversation_id - - async def get_conversation_reference( - self, skill_conversation_id: str - ) -> ConversationReference: - return await super().get_conversation_reference(skill_conversation_id) - - async def get_skill_conversation_reference( - self, skill_conversation_id: str - ) -> SkillConversationReference: - """ - Retrieve a `SkillConversationReference` with the specified ID. - - :param skill_conversation_id: The ID of the `SkillConversationReference` to retrieve. - :type skill_conversation_id: str - :return: `SkillConversationReference` for the specified ID; None if not found. - """ - - if not skill_conversation_id: - raise TypeError("skill_conversation_id can't be None") - - # Get the SkillConversationReference from storage for the given skill_conversation_id. - skill_conversation_reference = await self._storage.read([skill_conversation_id]) - - return skill_conversation_reference.get(skill_conversation_id) - - async def delete_conversation_reference(self, skill_conversation_id: str): - """ - Deletes the `SkillConversationReference` with the specified ID. - - :param skill_conversation_id: The ID of the `SkillConversationReference` to be deleted. - :type skill_conversation_id: str - """ - - # Delete the SkillConversationReference from storage. - await self._storage.delete([skill_conversation_id]) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 as uuid +from botbuilder.core import TurnContext, Storage +from .conversation_id_factory import ConversationIdFactoryBase +from .skill_conversation_id_factory_options import SkillConversationIdFactoryOptions +from .skill_conversation_reference import SkillConversationReference +from .skill_conversation_reference import ConversationReference + + +class SkillConversationIdFactory(ConversationIdFactoryBase): + def __init__(self, storage: Storage): + if not storage: + raise TypeError("storage can't be None") + + self._storage = storage + + async def create_skill_conversation_id( # pylint: disable=arguments-differ + self, options: SkillConversationIdFactoryOptions + ) -> str: + """ + Creates a new `SkillConversationReference`. + + :param options: Creation options to use when creating the `SkillConversationReference`. + :type options: :class:`botbuilder.core.skills.SkillConversationIdFactoryOptions` + :return: ID of the created `SkillConversationReference`. + """ + + if not options: + raise TypeError("options can't be None") + + conversation_reference = TurnContext.get_conversation_reference( + options.activity + ) + + skill_conversation_id = str(uuid()) + + # Create the SkillConversationReference instance. + skill_conversation_reference = SkillConversationReference( + conversation_reference=conversation_reference, + oauth_scope=options.from_bot_oauth_scope, + ) + + # Store the SkillConversationReference using the skill_conversation_id as a key. + skill_conversation_info = {skill_conversation_id: skill_conversation_reference} + + await self._storage.write(skill_conversation_info) + + # Return the generated skill_conversation_id (that will be also used as the conversation ID to call the skill). + return skill_conversation_id + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + return await super().get_conversation_reference(skill_conversation_id) + + async def get_skill_conversation_reference( + self, skill_conversation_id: str + ) -> SkillConversationReference: + """ + Retrieve a `SkillConversationReference` with the specified ID. + + :param skill_conversation_id: The ID of the `SkillConversationReference` to retrieve. + :type skill_conversation_id: str + :return: `SkillConversationReference` for the specified ID; None if not found. + """ + + if not skill_conversation_id: + raise TypeError("skill_conversation_id can't be None") + + # Get the SkillConversationReference from storage for the given skill_conversation_id. + skill_conversation_reference = await self._storage.read([skill_conversation_id]) + + return skill_conversation_reference.get(skill_conversation_id) + + async def delete_conversation_reference(self, skill_conversation_id: str): + """ + Deletes the `SkillConversationReference` with the specified ID. + + :param skill_conversation_id: The ID of the `SkillConversationReference` to be deleted. + :type skill_conversation_id: str + """ + + # Delete the SkillConversationReference from storage. + await self._storage.delete([skill_conversation_id]) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 6bc31dc2c..a67d4b567 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -1,15 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from uuid import uuid4 -from logging import Logger, getLogger +from logging import Logger -from botbuilder.core import Bot, BotAdapter, ChannelServiceHandler, TurnContext +from botbuilder.core import Bot, BotAdapter, ChannelServiceHandler from botbuilder.schema import ( Activity, - ActivityTypes, ResourceResponse, - CallerIdConstants, ) from botframework.connector.auth import ( AuthenticationConfiguration, @@ -18,9 +15,7 @@ ClaimsIdentity, CredentialProvider, GovernmentConstants, - JwtTokenValidation, ) -from .skill_conversation_reference import SkillConversationReference from .conversation_id_factory import ConversationIdFactoryBase @@ -40,6 +35,7 @@ def __init__( channel_provider: ChannelProvider = None, logger: Logger = None, ): + # pylint: disable=import-outside-toplevel super().__init__(credential_provider, auth_configuration, channel_provider) if not adapter: @@ -49,10 +45,25 @@ def __init__( if not conversation_id_factory: raise TypeError("conversation_id_factory can't be None") - self._adapter = adapter - self._bot = bot - self._conversation_id_factory = conversation_id_factory - self._logger = logger or getLogger() + self._logger = logger + + def aux_func(): + nonlocal self + return ( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + if self._channel_provider and self._channel_provider.is_government() + else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + from ._skill_handler_impl import _SkillHandlerImpl + + self._inner = _SkillHandlerImpl( + self.SKILL_CONVERSATION_REFERENCE_KEY, + adapter, + bot, + conversation_id_factory, + aux_func, + ) async def on_send_to_conversation( self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, @@ -81,8 +92,8 @@ async def on_send_to_conversation( :type activity: Activity :return: """ - return await self._process_activity( - claims_identity, conversation_id, None, activity, + return await self._inner.on_send_to_conversation( + claims_identity, conversation_id, activity, ) async def on_reply_to_activity( @@ -118,28 +129,15 @@ async def on_reply_to_activity( :type activity: Activity :return: """ - return await self._process_activity( + return await self._inner.on_reply_to_activity( claims_identity, conversation_id, activity_id, activity, ) async def on_delete_activity( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str ): - skill_conversation_reference = await self._get_skill_conversation_reference( - conversation_id - ) - - async def callback(turn_context: TurnContext): - turn_context.turn_state[ - self.SKILL_CONVERSATION_REFERENCE_KEY - ] = skill_conversation_reference - await turn_context.delete_activity(activity_id) - - await self._adapter.continue_conversation( - skill_conversation_reference.conversation_reference, - callback, - claims_identity=claims_identity, - audience=skill_conversation_reference.oauth_scope, + await self._inner.on_delete_activity( + claims_identity, conversation_id, activity_id ) async def on_update_activity( @@ -149,166 +147,6 @@ async def on_update_activity( activity_id: str, activity: Activity, ) -> ResourceResponse: - skill_conversation_reference = await self._get_skill_conversation_reference( - conversation_id - ) - - resource_response: ResourceResponse = None - - async def callback(turn_context: TurnContext): - nonlocal resource_response - turn_context.turn_state[ - self.SKILL_CONVERSATION_REFERENCE_KEY - ] = skill_conversation_reference - activity.apply_conversation_reference( - skill_conversation_reference.conversation_reference - ) - turn_context.activity.id = activity_id - turn_context.activity.caller_id = ( - f"{CallerIdConstants.bot_to_bot_prefix}" - f"{JwtTokenValidation.get_app_id_from_claims(claims_identity.claims)}" - ) - resource_response = await turn_context.update_activity(activity) - - await self._adapter.continue_conversation( - skill_conversation_reference.conversation_reference, - callback, - claims_identity=claims_identity, - audience=skill_conversation_reference.oauth_scope, - ) - - return resource_response or ResourceResponse(id=str(uuid4()).replace("-", "")) - - async def _get_skill_conversation_reference( - self, conversation_id: str - ) -> SkillConversationReference: - try: - skill_conversation_reference = await self._conversation_id_factory.get_skill_conversation_reference( - conversation_id - ) - except NotImplementedError: - self._logger.warning( - "Got NotImplementedError when trying to call get_skill_conversation_reference() " - "on the SkillConversationIdFactory, attempting to use deprecated " - "get_conversation_reference() method instead." - ) - - # Attempt to get SkillConversationReference using deprecated method. - # this catch should be removed once we remove the deprecated method. - # We need to use the deprecated method for backward compatibility. - conversation_reference = await self._conversation_id_factory.get_conversation_reference( - conversation_id - ) - - if isinstance(conversation_reference, SkillConversationReference): - skill_conversation_reference: SkillConversationReference = conversation_reference - else: - skill_conversation_reference: SkillConversationReference = SkillConversationReference( - conversation_reference=conversation_reference, - oauth_scope=( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - if self._channel_provider - and self._channel_provider.is_government() - else AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - ), - ) - - if not skill_conversation_reference: - raise KeyError("SkillConversationReference not found") - - if not skill_conversation_reference.conversation_reference: - raise KeyError("conversationReference not found") - - return skill_conversation_reference - - async def _process_activity( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - reply_to_activity_id: str, - activity: Activity, - ) -> ResourceResponse: - skill_conversation_reference = await self._get_skill_conversation_reference( - conversation_id + return await self._inner.on_update_activity( + claims_identity, conversation_id, activity_id, activity ) - - # If an activity is sent, return the ResourceResponse - resource_response: ResourceResponse = None - - async def callback(context: TurnContext): - nonlocal resource_response - context.turn_state[ - SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY - ] = skill_conversation_reference - - TurnContext.apply_conversation_reference( - activity, skill_conversation_reference.conversation_reference - ) - - context.activity.id = reply_to_activity_id - - app_id = JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) - context.activity.caller_id = ( - f"{CallerIdConstants.bot_to_bot_prefix}{app_id}" - ) - - if activity.type == ActivityTypes.end_of_conversation: - await self._conversation_id_factory.delete_conversation_reference( - conversation_id - ) - self._apply_eoc_to_turn_context_activity(context, activity) - await self._bot.on_turn(context) - elif activity.type == ActivityTypes.event: - self._apply_event_to_turn_context_activity(context, activity) - await self._bot.on_turn(context) - else: - resource_response = await context.send_activity(activity) - - await self._adapter.continue_conversation( - skill_conversation_reference.conversation_reference, - callback, - claims_identity=claims_identity, - audience=skill_conversation_reference.oauth_scope, - ) - - if not resource_response: - resource_response = ResourceResponse(id=str(uuid4())) - - return resource_response - - @staticmethod - def _apply_eoc_to_turn_context_activity( - context: TurnContext, end_of_conversation_activity: Activity - ): - context.activity.type = end_of_conversation_activity.type - context.activity.text = end_of_conversation_activity.text - context.activity.code = end_of_conversation_activity.code - - context.activity.reply_to_id = end_of_conversation_activity.reply_to_id - context.activity.value = end_of_conversation_activity.value - context.activity.entities = end_of_conversation_activity.entities - context.activity.locale = end_of_conversation_activity.locale - context.activity.local_timestamp = end_of_conversation_activity.local_timestamp - context.activity.timestamp = end_of_conversation_activity.timestamp - context.activity.channel_data = end_of_conversation_activity.channel_data - context.activity.additional_properties = ( - end_of_conversation_activity.additional_properties - ) - - @staticmethod - def _apply_event_to_turn_context_activity( - context: TurnContext, event_activity: Activity - ): - context.activity.type = event_activity.type - context.activity.name = event_activity.name - context.activity.value = event_activity.value - context.activity.relates_to = event_activity.relates_to - - context.activity.reply_to_id = event_activity.reply_to_id - context.activity.value = event_activity.value - context.activity.entities = event_activity.entities - context.activity.locale = event_activity.locale - context.activity.local_timestamp = event_activity.local_timestamp - context.activity.timestamp = event_activity.timestamp - context.activity.channel_data = event_activity.channel_data - context.activity.additional_properties = event_activity.additional_properties diff --git a/libraries/botbuilder-core/botbuilder/core/streaming/__init__.py b/libraries/botbuilder-core/botbuilder/core/streaming/__init__.py index b92ba04be..4d029ae52 100644 --- a/libraries/botbuilder-core/botbuilder/core/streaming/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/streaming/__init__.py @@ -3,12 +3,14 @@ from .bot_framework_http_adapter_base import BotFrameworkHttpAdapterBase from .streaming_activity_processor import StreamingActivityProcessor +from .streaming_http_client import StreamingHttpDriver from .streaming_request_handler import StreamingRequestHandler from .version_info import VersionInfo __all__ = [ "BotFrameworkHttpAdapterBase", "StreamingActivityProcessor", + "StreamingHttpDriver", "StreamingRequestHandler", "VersionInfo", ] diff --git a/libraries/botbuilder-core/botbuilder/core/streaming/streaming_request_handler.py b/libraries/botbuilder-core/botbuilder/core/streaming/streaming_request_handler.py index 9b9ac4e8d..66f36c110 100644 --- a/libraries/botbuilder-core/botbuilder/core/streaming/streaming_request_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/streaming/streaming_request_handler.py @@ -82,7 +82,10 @@ def forget_conversation(self, conversation_id: str): del self._conversations[conversation_id] async def process_request( - self, request: ReceiveRequest, logger: Logger, context: object + self, + request: ReceiveRequest, + logger: Logger, # pylint: disable=unused-argument + context: object, # pylint: disable=unused-argument ) -> StreamingResponse: # pylint: disable=pointless-string-statement response = StreamingResponse() diff --git a/libraries/botbuilder-core/tests/streaming/test_streaming_request_handler.py b/libraries/botbuilder-core/tests/streaming/test_streaming_request_handler.py index 39e6802a6..262c995e7 100644 --- a/libraries/botbuilder-core/tests/streaming/test_streaming_request_handler.py +++ b/libraries/botbuilder-core/tests/streaming/test_streaming_request_handler.py @@ -17,6 +17,7 @@ class MockWebSocket(WebSocket): + # pylint: disable=unused-argument def __init__(self): super(MockWebSocket, self).__init__() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/_user_token_access.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/_user_token_access.py new file mode 100644 index 000000000..9b9b36b51 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/_user_token_access.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +from botbuilder.core import TurnContext +from botbuilder.core.bot_framework_adapter import TokenExchangeRequest +from botbuilder.core.oauth import ConnectorClientBuilder, ExtendedUserTokenProvider +from botbuilder.schema import TokenResponse +from botframework.connector import ConnectorClient +from botframework.connector.auth import ClaimsIdentity, ConnectorFactory +from botframework.connector.auth.user_token_client import UserTokenClient +from botframework.connector.token_api.models import SignInUrlResponse + +from .prompts.oauth_prompt_settings import OAuthPromptSettings + + +class _UserTokenAccess(ABC): + @staticmethod + async def get_user_token( + turn_context: TurnContext, settings: OAuthPromptSettings, magic_code: str + ) -> TokenResponse: + user_token_client: UserTokenClient = turn_context.turn_state.get( + UserTokenClient.__name__, None + ) + if user_token_client: + return await user_token_client.get_user_token( + turn_context.activity.from_property.id, + settings.connection_name, + turn_context.activity.channel_id, + magic_code, + ) + if isinstance(turn_context.adapter, ExtendedUserTokenProvider): + return await turn_context.adapter.get_user_token( + turn_context, + settings.connection_name, + magic_code, + settings.oath_app_credentials, + ) + + raise TypeError("OAuthPrompt is not supported by the current adapter") + + @staticmethod + async def get_sign_in_resource( + turn_context: TurnContext, settings: OAuthPromptSettings + ) -> SignInUrlResponse: + user_token_client: UserTokenClient = turn_context.turn_state.get( + UserTokenClient.__name__, None + ) + if user_token_client: + return await user_token_client.get_sign_in_resource( + settings.connection_name, turn_context.activity, None + ) + if isinstance(turn_context.adapter, ExtendedUserTokenProvider): + return await turn_context.adapter.get_sign_in_resource_from_user_and_credentials( + turn_context, + settings.oath_app_credentials, + settings.connection_name, + turn_context.activity.from_property.id + if turn_context.activity and turn_context.activity.from_property + else None, + ) + + raise TypeError("OAuthPrompt is not supported by the current adapter") + + @staticmethod + async def sign_out_user(turn_context: TurnContext, settings: OAuthPromptSettings): + user_token_client: UserTokenClient = turn_context.turn_state.get( + UserTokenClient.__name__, None + ) + if user_token_client: + return await user_token_client.sign_out_user( + turn_context.activity.from_property.id, + settings.connection_name, + turn_context.activity.channel_id, + ) + if isinstance(turn_context.adapter, ExtendedUserTokenProvider): + return await turn_context.adapter.sign_out_user( + turn_context, + settings.connection_name, + turn_context.activity.from_property.id + if turn_context.activity and turn_context.activity.from_property + else None, + settings.oath_app_credentials, + ) + + raise TypeError("OAuthPrompt is not supported by the current adapter") + + @staticmethod + async def exchange_token( + turn_context: TurnContext, + settings: OAuthPromptSettings, + token_exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + user_token_client: UserTokenClient = turn_context.turn_state.get( + UserTokenClient.__name__, None + ) + user_id = turn_context.activity.from_property.id + if user_token_client: + channel_id = turn_context.activity.channel_id + return await user_token_client.exchange_token( + user_id, channel_id, token_exchange_request, + ) + if isinstance(turn_context.adapter, ExtendedUserTokenProvider): + return await turn_context.adapter.exchange_token( + turn_context, settings.connection_name, user_id, token_exchange_request, + ) + + raise TypeError("OAuthPrompt is not supported by the current adapter") + + @staticmethod + async def create_connector_client( + turn_context: TurnContext, + service_url: str, + claims_identity: ClaimsIdentity, + audience: str, + ) -> ConnectorClient: + connector_factory: ConnectorFactory = turn_context.turn_state.get( + ConnectorFactory.__name__, None + ) + if connector_factory: + return await connector_factory.create(service_url, audience) + if isinstance(turn_context.adapter, ConnectorClientBuilder): + return await turn_context.adapter.create_connector_client( + service_url, claims_identity, audience, + ) + + raise TypeError("OAuthPrompt is not supported by the current adapter") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py index d3d532b22..cd36ac632 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py @@ -65,7 +65,6 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: Channels.emulator: 100, Channels.direct_line: 100, Channels.webchat: 100, - Channels.cortana: 100, } return ( button_cnt <= max_actions[channel_id] @@ -74,7 +73,7 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: ) @staticmethod - def has_message_feed(channel_id: str) -> bool: + def has_message_feed(_: str) -> bool: """Determine if a Channel has a Message Feed. Args: @@ -84,7 +83,7 @@ def has_message_feed(channel_id: str) -> bool: bool: True if the Channel has a Message Feed, False if it does not. """ - return not channel_id == Channels.cortana + return True @staticmethod def max_action_title_length( # pylint: disable=unused-argument diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py index c9d8bb5a9..67a3e8ff5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py @@ -12,19 +12,13 @@ SkillValidation, JwtTokenValidation, ) -from botframework.connector.token_api.models import SignInUrlResponse from botbuilder.core import ( CardFactory, - ExtendedUserTokenProvider, MessageFactory, InvokeResponse, TurnContext, BotAdapter, ) -from botbuilder.core.oauth import ( - ConnectorClientBuilder, - UserTokenProvider, -) from botbuilder.core.bot_framework_adapter import TokenExchangeRequest from botbuilder.dialogs import Dialog, DialogContext, DialogTurnResult from botbuilder.schema import ( @@ -46,6 +40,8 @@ from .prompt_validator_context import PromptValidatorContext from .prompt_recognizer_result import PromptRecognizerResult +from .._user_token_access import _UserTokenAccess + class CallerInfo: def __init__(self, caller_service_url: str = None, scope: str = None): @@ -169,19 +165,12 @@ async def begin_dialog( dialog_context.context ) - if not isinstance(dialog_context.context.adapter, UserTokenProvider): - raise TypeError( - "OAuthPrompt.begin_dialog(): not supported by the current adapter" - ) - - output = await dialog_context.context.adapter.get_user_token( - dialog_context.context, - self._settings.connection_name, - None, - self._settings.oath_app_credentials, + output = await _UserTokenAccess.get_user_token( + dialog_context.context, self._settings, None ) if output is not None: + # Return token return await dialog_context.end_dialog(output) await self._send_oauth_card(dialog_context.context, options.prompt) @@ -279,20 +268,7 @@ async def get_user_token( If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ - adapter = context.adapter - - # Validate adapter type - if not hasattr(adapter, "get_user_token"): - raise Exception( - "OAuthPrompt.get_user_token(): not supported for the current adapter." - ) - - return await adapter.get_user_token( - context, - self._settings.connection_name, - code, - self._settings.oath_app_credentials, - ) + return await _UserTokenAccess.get_user_token(context, self._settings, code) async def sign_out_user(self, context: TurnContext): """ @@ -306,20 +282,7 @@ async def sign_out_user(self, context: TurnContext): If the task is successful and the user already has a token or the user successfully signs in, the result contains the user's token. """ - adapter = context.adapter - - # Validate adapter type - if not hasattr(adapter, "sign_out_user"): - raise Exception( - "OAuthPrompt.sign_out_user(): not supported for the current adapter." - ) - - return await adapter.sign_out_user( - context, - self._settings.connection_name, - None, - self._settings.oath_app_credentials, - ) + return await _UserTokenAccess.sign_out_user(context, self._settings) @staticmethod def __create_caller_info(context: TurnContext) -> CallerInfo: @@ -347,13 +310,9 @@ async def _send_oauth_card( att.content_type == CardFactory.content_types.oauth_card for att in prompt.attachments ): - adapter: ExtendedUserTokenProvider = context.adapter card_action_type = ActionTypes.signin - sign_in_resource: SignInUrlResponse = await adapter.get_sign_in_resource_from_user_and_credentials( - context, - self._settings.oath_app_credentials, - self._settings.connection_name, - context.activity.from_property.id, + sign_in_resource = await _UserTokenAccess.get_sign_in_resource( + context, self._settings ) link = sign_in_resource.sign_in_link bot_identity: ClaimsIdentity = context.turn_state.get( @@ -448,16 +407,9 @@ async def _recognize_token( if state: # set the ServiceUrl to the skill host's Url dialog_context.context.activity.service_url = state.caller_service_url - - # recreate a ConnectorClient and set it in TurnState so replies use the correct one - if not isinstance(context.adapter, ConnectorClientBuilder): - raise TypeError( - "OAuthPrompt: IConnectorClientProvider interface not implemented by the current adapter" - ) - - connector_client_builder: ConnectorClientBuilder = context.adapter claims_identity = context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY) - connector_client = await connector_client_builder.create_connector_client( + connector_client = await _UserTokenAccess.create_connector_client( + context, dialog_context.context.activity.service_url, claims_identity, state.scope, @@ -470,7 +422,9 @@ async def _recognize_token( elif OAuthPrompt._is_teams_verification_invoke(context): code = context.activity.value["state"] try: - token = await self.get_user_token(context, code) + token = await _UserTokenAccess.get_user_token( + context, self._settings, code + ) if token is not None: await context.send_activity( Activity( @@ -538,15 +492,11 @@ async def _recognize_token( ) else: # No errors. Proceed with token exchange. - extended_user_token_provider: ExtendedUserTokenProvider = context.adapter - token_exchange_response = None try: - token_exchange_response = await extended_user_token_provider.exchange_token_from_credentials( + token_exchange_response = await _UserTokenAccess.exchange_token( context, - self._settings.oath_app_credentials, - self._settings.connection_name, - context.activity.from_property.id, + self._settings, TokenExchangeRequest(token=context.activity.value.token), ) except: @@ -577,7 +527,9 @@ async def _recognize_token( elif context.activity.type == ActivityTypes.message and context.activity.text: match = re.match(r"(? Optional[Response]: + raise NotImplementedError() diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py index 164818e87..a331380fa 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/bot_framework_http_client.py @@ -3,7 +3,7 @@ # pylint: disable=no-member import json -from typing import Dict +from typing import Dict, List, Tuple from logging import Logger import aiohttp @@ -115,7 +115,7 @@ async def post_activity( async def _post_content( self, to_url: str, token: str, activity: Activity - ) -> (int, object): + ) -> Tuple[int, object]: headers_dict = { "Content-type": "application/json; charset=utf-8", } @@ -140,7 +140,7 @@ async def post_buffered_activity( service_url: str, conversation_id: str, activity: Activity, - ) -> [Activity]: + ) -> List[Activity]: """ Helper method to return a list of activities when an Activity is being sent with DeliveryMode == expectReplies. diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/cloud_adapter.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/cloud_adapter.py new file mode 100644 index 000000000..32fba5c97 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/cloud_adapter.py @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Callable, Optional + +from aiohttp.web import ( + Request, + Response, + json_response, + WebSocketResponse, + HTTPBadRequest, + HTTPMethodNotAllowed, + HTTPUnauthorized, + HTTPUnsupportedMediaType, +) +from botbuilder.core import ( + Bot, + CloudAdapterBase, + InvokeResponse, + TurnContext, +) +from botbuilder.core.streaming import ( + StreamingActivityProcessor, + StreamingHttpDriver, + StreamingRequestHandler, +) +from botbuilder.schema import Activity +from botbuilder.integration.aiohttp.streaming import AiohttpWebSocket +from botframework.connector import AsyncBfPipeline, BotFrameworkConnectorConfiguration +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import ( + AuthenticateRequestResult, + BotFrameworkAuthentication, + BotFrameworkAuthenticationFactory, + ConnectorFactory, + MicrosoftAppCredentials, +) + +from .bot_framework_http_adapter_integration_base import ( + BotFrameworkHttpAdapterIntegrationBase, +) + + +class CloudAdapter(CloudAdapterBase, BotFrameworkHttpAdapterIntegrationBase): + def __init__(self, bot_framework_authentication: BotFrameworkAuthentication = None): + """ + Initializes a new instance of the CloudAdapter class. + + :param bot_framework_authentication: Optional BotFrameworkAuthentication instance + """ + # pylint: disable=invalid-name + if not bot_framework_authentication: + bot_framework_authentication = BotFrameworkAuthenticationFactory.create() + + self._AUTH_HEADER_NAME = "authorization" + self._CHANNEL_ID_HEADER_NAME = "channelid" + super().__init__(bot_framework_authentication) + + async def process( + self, request: Request, bot: Bot, ws_response: WebSocketResponse = None + ) -> Optional[Response]: + if not request: + raise TypeError("request can't be None") + # if ws_response is None: + # raise TypeError("ws_response can't be None") + if not bot: + raise TypeError("bot can't be None") + try: + # Only GET requests for web socket connects are allowed + if ( + request.method == "GET" + and ws_response + and ws_response.can_prepare(request) + ): + # All socket communication will be handled by the internal streaming-specific BotAdapter + await self._connect(bot, request, ws_response) + elif request.method == "POST": + # Deserialize the incoming Activity + if "application/json" in request.headers["Content-Type"]: + body = await request.json() + else: + raise HTTPUnsupportedMediaType() + + activity: Activity = Activity().deserialize(body) + + # A POST request must contain an Activity + if not activity.type: + raise HTTPBadRequest + + # Grab the auth header from the inbound http request + auth_header = ( + request.headers["Authorization"] + if "Authorization" in request.headers + else "" + ) + + # Process the inbound activity with the bot + invoke_response = await self.process_activity( + auth_header, activity, bot.on_turn + ) + + # Write the response, serializing the InvokeResponse + if invoke_response: + return json_response( + data=invoke_response.body, status=invoke_response.status + ) + return Response(status=201) + else: + raise HTTPMethodNotAllowed + except (HTTPUnauthorized, PermissionError) as _: + raise HTTPUnauthorized + + async def _connect( + self, bot: Bot, request: Request, ws_response: WebSocketResponse + ): + if ws_response is None: + raise TypeError("ws_response can't be None") + + # Grab the auth header from the inbound http request + auth_header = request.headers.get(self._AUTH_HEADER_NAME) + # Grab the channelId which should be in the http headers + channel_id = request.headers.get(self._CHANNEL_ID_HEADER_NAME) + + authentication_request_result = await self.bot_framework_authentication.authenticate_streaming_request( + auth_header, channel_id + ) + + # Transition the request to a WebSocket connection + await ws_response.prepare(request) + bf_web_socket = AiohttpWebSocket(ws_response) + + streaming_activity_processor = _StreamingActivityProcessor( + authentication_request_result, self, bot, bf_web_socket + ) + + await streaming_activity_processor.listen() + + +class _StreamingActivityProcessor(StreamingActivityProcessor): + def __init__( + self, + authenticate_request_result: AuthenticateRequestResult, + adapter: CloudAdapter, + bot: Bot, + web_socket: AiohttpWebSocket = None, + ) -> None: + self._authenticate_request_result = authenticate_request_result + self._adapter = adapter + + # Internal reuse of the existing StreamingRequestHandler class + self._request_handler = StreamingRequestHandler(bot, self, web_socket) + + # Fix up the connector factory so connector create from it will send over this connection + self._authenticate_request_result.connector_factory = _StreamingConnectorFactory( + self._request_handler + ) + + async def listen(self): + await self._request_handler.listen() + + async def process_streaming_activity( + self, + activity: Activity, + bot_callback_handler: Callable[[TurnContext], Awaitable], + ) -> InvokeResponse: + return await self._adapter.process_activity( + self._authenticate_request_result, activity, bot_callback_handler + ) + + +class _StreamingConnectorFactory(ConnectorFactory): + def __init__(self, request_handler: StreamingRequestHandler) -> None: + self._request_handler = request_handler + self._service_url = None + + async def create( + self, service_url: str, audience: str # pylint: disable=unused-argument + ) -> ConnectorClient: + if not self._service_url: + self._service_url = service_url + elif service_url != self._service_url: + raise RuntimeError( + "This is a streaming scenario, all connectors from this factory must all be for the same url." + ) + + # TODO: investigate if Driver and pipeline should be moved here + streaming_driver = StreamingHttpDriver(self._request_handler) + config = BotFrameworkConnectorConfiguration( + MicrosoftAppCredentials.empty(), + service_url, + pipeline_type=AsyncBfPipeline, + driver=streaming_driver, + ) + streaming_driver.config = config + connector_client = ConnectorClient(None, custom_configuration=config) + + return connector_client diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_bot_framework_authentication.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_bot_framework_authentication.py new file mode 100644 index 000000000..39b036058 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_bot_framework_authentication.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger +from typing import Any + +from botbuilder.integration.aiohttp import ConfigurationServiceClientCredentialFactory +from botbuilder.schema import Activity +from botframework.connector import HttpClientFactory +from botframework.connector.auth import ( + BotFrameworkAuthentication, + ClaimsIdentity, + UserTokenClient, + ConnectorFactory, + AuthenticateRequestResult, + ServiceClientCredentialsFactory, + AuthenticationConfiguration, + BotFrameworkAuthenticationFactory, +) +from botframework.connector.skills import BotFrameworkClient + + +class ConfigurationBotFrameworkAuthentication(BotFrameworkAuthentication): + def __init__( + self, + configuration: Any, + *, + credentials_factory: ServiceClientCredentialsFactory = None, + auth_configuration: AuthenticationConfiguration = None, + http_client_factory: HttpClientFactory = None, + logger: Logger = None + ): + self._inner: BotFrameworkAuthentication = BotFrameworkAuthenticationFactory.create( + channel_service=getattr(configuration, "CHANNEL_SERVICE", None), + validate_authority=getattr(configuration, "VALIDATE_AUTHORITY", True), + to_channel_from_bot_login_url=getattr( + configuration, "TO_CHANNEL_FROM_BOT_LOGIN_URL", None + ), + to_channel_from_bot_oauth_scope=getattr( + configuration, "TO_CHANNEL_FROM_BOT_OAUTH_SCOPE", None + ), + to_bot_from_channel_token_issuer=getattr( + configuration, "TO_BOT_FROM_CHANNEL_TOKEN_ISSUER", None + ), + oauth_url=getattr(configuration, "OAUTH_URL", None), + to_bot_from_channel_open_id_metadata_url=getattr( + configuration, "TO_BOT_FROM_CHANNEL_OPENID_METADATA_URL", None + ), + to_bot_from_emulator_open_id_metadata_url=getattr( + configuration, "TO_BOT_FROM_EMULATOR_OPENID_METADATA_URL", None + ), + caller_id=getattr(configuration, "CALLER_ID", None), + credential_factory=( + credentials_factory + if credentials_factory + else ConfigurationServiceClientCredentialFactory(configuration) + ), + auth_configuration=( + auth_configuration + if auth_configuration + else AuthenticationConfiguration() + ), + http_client_factory=http_client_factory, + logger=logger, + ) + + async def authenticate_request( + self, activity: Activity, auth_header: str + ) -> AuthenticateRequestResult: + return await self._inner.authenticate_request(activity, auth_header) + + async def authenticate_streaming_request( + self, auth_header: str, channel_id_header: str + ) -> AuthenticateRequestResult: + return await self._inner.authenticate_streaming_request( + auth_header, channel_id_header + ) + + def create_connector_factory( + self, claims_identity: ClaimsIdentity + ) -> ConnectorFactory: + return self._inner.create_connector_factory(claims_identity) + + async def create_user_token_client( + self, claims_identity: ClaimsIdentity + ) -> UserTokenClient: + return await self._inner.create_user_token_client(claims_identity) + + def create_bot_framework_client(self) -> BotFrameworkClient: + return self._inner.create_bot_framework_client() + + def get_originating_audience(self) -> str: + return self._inner.get_originating_audience() + + async def authenticate_channel_request(self, auth_header: str) -> ClaimsIdentity: + return await self._inner.authenticate_channel_request(auth_header) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_service_client_credential_factory.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_service_client_credential_factory.py new file mode 100644 index 000000000..b620e3b68 --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/configuration_service_client_credential_factory.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger +from typing import Any + +from botframework.connector.auth import PasswordServiceClientCredentialFactory + + +class ConfigurationServiceClientCredentialFactory( + PasswordServiceClientCredentialFactory +): + def __init__(self, configuration: Any, *, logger: Logger = None) -> None: + super().__init__( + app_id=getattr(configuration, "APP_ID", None), + password=getattr(configuration, "APP_PASSWORD", None), + logger=logger, + ) diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py index 71aaa71cf..6c95b5619 100644 --- a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/__init__.py @@ -1,4 +1,5 @@ +from .aio_http_client_factory import AioHttpClientFactory from .skill_http_client import SkillHttpClient -__all__ = ["SkillHttpClient"] +__all__ = ["AioHttpClientFactory", "SkillHttpClient"] diff --git a/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/aio_http_client_factory.py b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/aio_http_client_factory.py new file mode 100644 index 000000000..84235e86b --- /dev/null +++ b/libraries/botbuilder-integration-aiohttp/botbuilder/integration/aiohttp/skills/aio_http_client_factory.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from aiohttp import ClientSession, ClientResponse, ClientResponseError + +from botframework.connector import ( + HttpClientBase, + HttpClientFactory, + HttpRequest, + HttpResponseBase, +) + + +class _HttpResponseImpl(HttpResponseBase): + def __init__(self, client_response: ClientResponse) -> None: + self._client_response = client_response + + @property + def status_code(self): + return self._client_response.status + + async def is_succesful(self) -> bool: + try: + self._client_response.raise_for_status() + return True + except ClientResponseError: + return False + + async def read_content_str(self) -> str: + return (await self._client_response.read()).decode() + + +class _HttpClientImplementation(HttpClientBase): + def __init__(self) -> None: + self._session = ClientSession() + + async def post(self, *, request: HttpRequest) -> HttpResponseBase: + aio_response = await self._session.post( + request.request_uri, data=request.content, headers=request.headers + ) + + return _HttpResponseImpl(aio_response) + + +class AioHttpClientFactory(HttpClientFactory): + def create_client(self) -> HttpClientBase: + return _HttpClientImplementation() diff --git a/libraries/botbuilder-schema/botbuilder/schema/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/__init__.py index 0e99068e6..8e3eea34e 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/__init__.py @@ -30,6 +30,7 @@ from ._models_py3 import GeoCoordinates from ._models_py3 import HeroCard from ._models_py3 import InnerHttpError +from ._models_py3 import InvokeResponse from ._models_py3 import MediaCard from ._models_py3 import MediaEventValue from ._models_py3 import MediaUrl @@ -105,6 +106,7 @@ "GeoCoordinates", "HeroCard", "InnerHttpError", + "InvokeResponse", "MediaCard", "MediaEventValue", "MediaUrl", diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py index 289944b5a..2c1fbebcc 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py @@ -29,6 +29,8 @@ class ActivityTypes(str, Enum): suggestion = "suggestion" trace = "trace" handoff = "handoff" + command = "command" + command_result = "commandResult" class TextFormatTypes(str, Enum): diff --git a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py index 7bc8e77ce..43fc72e59 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/_models_py3.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List + from botbuilder.schema._connector_client_enums import ActivityTypes from datetime import datetime from enum import Enum @@ -744,7 +746,7 @@ def get_conversation_reference(self): service_url=self.service_url, ) - def get_mentions(self) -> [Mention]: + def get_mentions(self) -> List[Mention]: """ Resolves the mentions from the entities of this activity. @@ -1703,6 +1705,44 @@ def __init__(self, *, status_code: int = None, body=None, **kwargs) -> None: self.body = body +class InvokeResponse(Model): + """ + Tuple class containing an HTTP Status Code and a JSON serializable + object. The HTTP Status code is, in the invoke activity scenario, what will + be set in the resulting POST. The Body of the resulting POST will be + JSON serialized content. + + The body content is defined by the producer. The caller must know what + the content is and deserialize as needed. + """ + + _attribute_map = { + "status": {"key": "status", "type": "int"}, + "body": {"key": "body", "type": "object"}, + } + + def __init__(self, *, status: int = None, body: object = None, **kwargs): + """ + Gets or sets the HTTP status and/or body code for the response + :param status: The HTTP status code. + :param body: The JSON serializable body content for the response. This object + must be serializable by the core Python json routines. The caller is responsible + for serializing more complex/nested objects into native classes (lists and + dictionaries of strings are acceptable). + """ + super().__init__(**kwargs) + self.status = status + self.body = body + + def is_successful_status_code(self) -> bool: + """ + Gets a value indicating whether the invoke response was successful. + :return: A value that indicates if the HTTP response was successful. true if status is in + the Successful range (200-299); otherwise false. + """ + return 200 <= self.status <= 299 + + class MediaCard(Model): """Media card. diff --git a/libraries/botframework-connector/botframework/connector/__init__.py b/libraries/botframework-connector/botframework/connector/__init__.py index cea241543..e167a32ad 100644 --- a/libraries/botframework-connector/botframework/connector/__init__.py +++ b/libraries/botframework-connector/botframework/connector/__init__.py @@ -10,9 +10,12 @@ from .emulator_api_client import EmulatorApiClient from .version import VERSION -# TODO: Experimental from .aiohttp_bf_pipeline import AsyncBfPipeline from .bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from .http_client_base import HttpClientBase +from .http_client_factory import HttpClientFactory +from .http_request import HttpRequest +from .http_response_base import HttpResponseBase __all__ = [ "AsyncBfPipeline", @@ -20,6 +23,10 @@ "ConnectorClient", "EmulatorApiClient", "BotFrameworkConnectorConfiguration", + "HttpClientBase", + "HttpClientFactory", + "HttpRequest", + "HttpResponseBase", ] __version__ = VERSION diff --git a/libraries/botframework-connector/botframework/connector/_not_implemented_http_client.py b/libraries/botframework-connector/botframework/connector/_not_implemented_http_client.py new file mode 100644 index 000000000..898df2f45 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/_not_implemented_http_client.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .http_client_base import HttpClientBase +from .http_request import HttpRequest +from .http_response_base import HttpResponseBase + + +class _NotImplementedHttpClient(HttpClientBase): + async def post( + self, *, request: HttpRequest # pylint: disable=unused-argument + ) -> HttpResponseBase: + raise RuntimeError( + "Please provide an http implementation for the skill BotFrameworkClient" + ) diff --git a/libraries/botframework-connector/botframework/connector/about.py b/libraries/botframework-connector/botframework/connector/about.py new file mode 100644 index 000000000..86722da0f --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/about.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botframework-connector" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.14.0" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py b/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py index 86a303d51..e77867cb6 100644 --- a/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py +++ b/libraries/botframework-connector/botframework/connector/aio/_connector_client_async.py @@ -17,7 +17,6 @@ from .. import models -# TODO: experimental from ..bot_framework_sdk_client_async import ( BotFrameworkSDKClientAsync, BotFrameworkConnectorConfiguration, @@ -63,7 +62,7 @@ def __init__( pipeline_type: Optional[Type[AsyncPipeline]] = None, sender: Optional[AsyncHTTPSender] = None, driver: Optional[AsyncHttpDriver] = None, - custom_configuration: [BotFrameworkConnectorConfiguration] = None, + custom_configuration: Optional[BotFrameworkConnectorConfiguration] = None, ): if custom_configuration: self.config = custom_configuration diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index d5f273e0f..fd34db01a 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -6,8 +6,12 @@ # -------------------------------------------------------------------------- # pylint: disable=missing-docstring from .authentication_constants import * +from .authenticate_request_result import * +from .bot_framework_authentication import * +from .bot_framework_authentication_factory import * from .government_constants import * from .channel_provider import * +from .connector_factory import * from .simple_channel_provider import * from .app_credentials import * from .microsoft_app_credentials import * @@ -19,4 +23,7 @@ from .channel_validation import * from .emulator_validation import * from .jwt_token_extractor import * +from .password_service_client_credential_factory import * +from .service_client_credentials_factory import * +from .user_token_client import * from .authentication_configuration import * diff --git a/libraries/botframework-connector/botframework/connector/auth/_bot_framework_client_impl.py b/libraries/botframework-connector/botframework/connector/auth/_bot_framework_client_impl.py new file mode 100644 index 000000000..aeff33376 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_bot_framework_client_impl.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import deepcopy +from json import dumps, loads +from logging import Logger + +from botbuilder.schema import ( + Activity, + ConversationReference, + ConversationAccount, + ChannelAccount, + InvokeResponse, + RoleTypes, +) + +from ..http_client_factory import HttpClientFactory +from ..http_request import HttpRequest +from .._not_implemented_http_client import _NotImplementedHttpClient +from ..skills.bot_framework_client import BotFrameworkClient + +from .service_client_credentials_factory import ServiceClientCredentialsFactory + + +class _BotFrameworkClientImpl(BotFrameworkClient): + def __init__( + self, + credentials_factory: ServiceClientCredentialsFactory, + http_client_factory: HttpClientFactory, + login_endpoint: str, + logger: Logger = None, + ): + self._credentials_factory = credentials_factory + self._http_client = ( + http_client_factory.create_client() + if http_client_factory + else _NotImplementedHttpClient() + ) + self._login_endpoint = login_endpoint + self._logger = logger + + 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: + if not from_bot_id: + raise TypeError("from_bot_id") + if not to_bot_id: + raise TypeError("to_bot_id") + if not to_url: + raise TypeError("to_url") + if not service_url: + raise TypeError("service_url") + if not conversation_id: + raise TypeError("conversation_id") + if not activity: + raise TypeError("activity") + + if self._logger: + self._logger.log(20, f"post to skill '{to_bot_id}' at '{to_url}'") + + credentials = await self._credentials_factory.create_credentials( + from_bot_id, to_bot_id, self._login_endpoint, True + ) + + # Get token for the skill call + token = credentials.get_access_token() if credentials.microsoft_app_id else None + + # Clone the activity so we can modify it before sending without impacting the original object. + activity_copy = deepcopy(activity) + + # Apply the appropriate addressing to the newly created Activity. + activity_copy.relates_to = ConversationReference( + service_url=activity_copy.service_url, + activity_id=activity_copy.id, + channel_id=activity_copy.channel_id, + conversation=ConversationAccount( + id=activity_copy.conversation.id, + name=activity_copy.conversation.name, + conversation_type=activity_copy.conversation.conversation_type, + aad_object_id=activity_copy.conversation.aad_object_id, + is_group=activity_copy.conversation.is_group, + role=activity_copy.conversation.role, + tenant_id=activity_copy.conversation.tenant_id, + properties=activity_copy.conversation.properties, + ), + bot=None, + ) + activity_copy.conversation.id = conversation_id + activity_copy.service_url = service_url + if not activity_copy.recipient: + activity_copy.recipient = ChannelAccount(role=RoleTypes.skill) + else: + activity_copy.recipient.role = RoleTypes.skill + + headers_dict = { + "Content-type": "application/json; charset=utf-8", + } + if token: + headers_dict.update( + {"Authorization": f"Bearer {token}",} + ) + json_content = dumps(activity_copy.serialize()).encode("utf-8") + + request = HttpRequest( + request_uri=to_url, content=json_content, headers=headers_dict + ) + response = await self._http_client.post(request=request) + + data = await response.read_content_str() + + if not await response.is_succesful() and self._logger: + # Otherwise we can assume we don't have to deserialize - so just log the content so it's not lost. + self._logger.log( + 40, + f"Bot Framework call failed to '{to_url}' returning '{int(response.status_code)}' and '{data}'", + ) + + return InvokeResponse( + status=response.status_code, body=loads(data) if data else None + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/_built_in_bot_framework_authentication.py b/libraries/botframework-connector/botframework/connector/auth/_built_in_bot_framework_authentication.py new file mode 100644 index 000000000..25c5b0acd --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_built_in_bot_framework_authentication.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger +from typing import Optional + +from botbuilder.schema import Activity + +from ..bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from ..http_client_factory import HttpClientFactory +from ..skills.bot_framework_client import BotFrameworkClient + +from ._bot_framework_client_impl import _BotFrameworkClientImpl +from ._user_token_client_impl import _UserTokenClientImpl +from ._connector_factory_impl import _ConnectorFactoryImpl +from .authenticate_request_result import AuthenticateRequestResult +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants +from .bot_framework_authentication import BotFrameworkAuthentication +from .claims_identity import ClaimsIdentity +from .channel_provider import ChannelProvider +from .connector_factory import ConnectorFactory +from .credential_provider import _DelegatingCredentialProvider +from .jwt_token_validation import JwtTokenValidation +from .service_client_credentials_factory import ServiceClientCredentialsFactory +from .skill_validation import SkillValidation +from .simple_channel_provider import SimpleChannelProvider +from .user_token_client import UserTokenClient + + +class _BuiltinBotFrameworkAuthentication(BotFrameworkAuthentication): + def __init__( + self, + to_channel_from_bot_oauth_scope: str, + login_endpoint: str, + caller_id: str, + channel_service: str, + oauth_endpoint: str, + credentials_factory: ServiceClientCredentialsFactory, + auth_configuration: AuthenticationConfiguration, + http_client_factory: HttpClientFactory, + connector_client_configuration: BotFrameworkConnectorConfiguration, + logger: Logger, + ): + self._to_channel_from_bot_oauth_scope = to_channel_from_bot_oauth_scope + self._login_endpoint = login_endpoint + self._caller_id = caller_id + self._channel_service = channel_service + self._oauth_endpoint = oauth_endpoint + self._credentials_factory = credentials_factory + self._auth_configuration = auth_configuration + self._http_client_factory = http_client_factory + self._connector_client_configuration = connector_client_configuration + self._logger = logger + + @staticmethod + def get_app_id(claims_identity: ClaimsIdentity) -> str: + # For requests from channel App Id is in Audience claim of JWT token. For emulator it is in AppId claim. For + # unauthenticated requests we have anonymous claimsIdentity provided auth is disabled. + # For Activities coming from Emulator AppId claim contains the Bot's AAD AppId. + app_id = claims_identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM) + if app_id is None: + app_id = claims_identity.get_claim_value( + AuthenticationConstants.APP_ID_CLAIM + ) + return app_id + + async def authenticate_request( + self, activity: Activity, auth_header: str + ) -> AuthenticateRequestResult: + credential_provider = _DelegatingCredentialProvider(self._credentials_factory) + + claims_identity = await JwtTokenValidation.authenticate_request( + activity, + auth_header, + credential_provider, + self._get_channel_provider(), + self._auth_configuration, + ) + + outbound_audience = ( + JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + if SkillValidation.is_skill_claim(claims_identity.claims) + else self._to_channel_from_bot_oauth_scope + ) + + caller_id = await self.generate_caller_id( + credential_factory=self._credentials_factory, + claims_identity=claims_identity, + caller_id=self._caller_id, + ) + + connector_factory = _ConnectorFactoryImpl( + app_id=_BuiltinBotFrameworkAuthentication.get_app_id(claims_identity), + to_channel_from_bot_oauth_scope=self._to_channel_from_bot_oauth_scope, + login_endpoint=self._login_endpoint, + validate_authority=True, + credential_factory=self._credentials_factory, + connector_client_configuration=self._connector_client_configuration, + logger=self._logger, + ) + + result = AuthenticateRequestResult() + result.claims_identity = claims_identity + result.audience = outbound_audience + result.caller_id = caller_id + result.connector_factory = connector_factory + + return result + + async def authenticate_streaming_request( + self, auth_header: str, channel_id_header: str + ) -> AuthenticateRequestResult: + credential_provider = _DelegatingCredentialProvider(self._credentials_factory) + + if channel_id_header is None: + is_auth_disabled = ( + await self._credentials_factory.is_authentication_disabled() + ) + if not is_auth_disabled: + raise PermissionError("Unauthorized Access. Request is not authorized") + + claims_identity = await JwtTokenValidation.validate_auth_header( + auth_header, + credential_provider, + self._get_channel_provider(), + channel_id_header, + ) + + outbound_audience = ( + JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + if SkillValidation.is_skill_claim(claims_identity.claims) + else self._to_channel_from_bot_oauth_scope + ) + + caller_id = await self.generate_caller_id( + credential_factory=self._credentials_factory, + claims_identity=claims_identity, + caller_id=self._caller_id, + ) + + result = AuthenticateRequestResult() + result.claims_identity = claims_identity + result.audience = outbound_audience + result.caller_id = caller_id + + return result + + def create_connector_factory( + self, claims_identity: ClaimsIdentity + ) -> ConnectorFactory: + return _ConnectorFactoryImpl( + app_id=_BuiltinBotFrameworkAuthentication.get_app_id(claims_identity), + to_channel_from_bot_oauth_scope=self._to_channel_from_bot_oauth_scope, + login_endpoint=self._login_endpoint, + validate_authority=True, + credential_factory=self._credentials_factory, + connector_client_configuration=self._connector_client_configuration, + logger=self._logger, + ) + + async def create_user_token_client( + self, claims_identity: ClaimsIdentity + ) -> UserTokenClient: + app_id = _BuiltinBotFrameworkAuthentication.get_app_id(claims_identity) + + credentials = await self._credentials_factory.create_credentials( + app_id, + audience=self._to_channel_from_bot_oauth_scope, + login_endpoint=self._login_endpoint, + validate_authority=True, + ) + + return _UserTokenClientImpl(app_id, credentials, self._oauth_endpoint) + + def create_bot_framework_client(self) -> BotFrameworkClient: + return _BotFrameworkClientImpl( + self._credentials_factory, + self._http_client_factory, + self._login_endpoint, + self._logger, + ) + + def get_originating_audience(self) -> str: + return self._to_channel_from_bot_oauth_scope + + async def authenticate_channel_request(self, auth_header: str) -> ClaimsIdentity: + credential_provider = _DelegatingCredentialProvider(self._credentials_factory) + + if auth_header is None: + is_auth_disabled = await credential_provider.is_authentication_disabled() + if not is_auth_disabled: + # No auth header. Auth is required. Request is not authorized. + raise PermissionError("Unauthorized Access. Request is not authorized") + + # 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. + # Since ChannelServiceHandler calls are always a skill callback call, we set the skill claim too. + return SkillValidation.create_anonymous_skill_claim() + + return await JwtTokenValidation.validate_auth_header( + auth_header, + credential_provider, + channel_service_or_provider=self._get_channel_provider(), + channel_id="unknown", + auth_configuration=self._auth_configuration, + ) + + def _get_channel_provider(self) -> Optional[ChannelProvider]: + return ( + SimpleChannelProvider(self._channel_service) + if self._channel_service is not None + else None + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/_connector_factory_impl.py b/libraries/botframework-connector/botframework/connector/auth/_connector_factory_impl.py new file mode 100644 index 000000000..9c7d83af7 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_connector_factory_impl.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger + +from botframework.connector.aio import ConnectorClient + +from ..about import __version__ +from ..bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from .connector_factory import ConnectorFactory +from .service_client_credentials_factory import ServiceClientCredentialsFactory + +USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" + + +class _ConnectorFactoryImpl(ConnectorFactory): + def __init__( + self, + app_id: str, + to_channel_from_bot_oauth_scope: str, + login_endpoint: str, + validate_authority: bool, + credential_factory: ServiceClientCredentialsFactory, + connector_client_configuration: BotFrameworkConnectorConfiguration = None, + logger: Logger = None, + ) -> None: + self._app_id = app_id + self._to_channel_from_bot_oauth_scope = to_channel_from_bot_oauth_scope + self._login_endpoint = login_endpoint + self._validate_authority = validate_authority + self._credential_factory = credential_factory + self._connector_client_configuration = connector_client_configuration + self._logger = logger + + async def create(self, service_url: str, audience: str = None) -> ConnectorClient: + # Use the credentials factory to create credentails specific to this particular cloud environment. + credentials = await self._credential_factory.create_credentials( + self._app_id, + audience or self._to_channel_from_bot_oauth_scope, + self._login_endpoint, + self._validate_authority, + ) + + # A new connector client for making calls against this serviceUrl using credentials derived + # from the current appId and the specified audience. + if self._connector_client_configuration: + client = ConnectorClient( + credentials, + base_url=service_url, + custom_configuration=self._connector_client_configuration, + ) + else: + client = ConnectorClient(credentials, base_url=service_url) + client.config.add_user_agent(USER_AGENT) + return client diff --git a/libraries/botframework-connector/botframework/connector/auth/_government_cloud_bot_framework_authentication.py b/libraries/botframework-connector/botframework/connector/auth/_government_cloud_bot_framework_authentication.py new file mode 100644 index 000000000..cbdaa61dc --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_government_cloud_bot_framework_authentication.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger + +from botbuilder.schema import CallerIdConstants + +from ..bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from ..http_client_factory import HttpClientFactory +from ._built_in_bot_framework_authentication import _BuiltinBotFrameworkAuthentication +from .authentication_configuration import AuthenticationConfiguration +from .government_constants import GovernmentConstants +from .service_client_credentials_factory import ServiceClientCredentialsFactory + + +class _GovernmentCloudBotFrameworkAuthentication(_BuiltinBotFrameworkAuthentication): + def __init__( + self, + credentials_factory: ServiceClientCredentialsFactory, + auth_configuration: AuthenticationConfiguration, + http_client_factory: HttpClientFactory, + connector_client_configuration: BotFrameworkConnectorConfiguration = None, + logger: Logger = None, + ): + super(_GovernmentCloudBotFrameworkAuthentication, self).__init__( + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL, + CallerIdConstants.us_gov_channel, + GovernmentConstants.CHANNEL_SERVICE, + GovernmentConstants.OAUTH_URL_GOV, + credentials_factory, + auth_configuration, + http_client_factory, + connector_client_configuration, + logger, + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/_parameterized_bot_framework_authentication.py b/libraries/botframework-connector/botframework/connector/auth/_parameterized_bot_framework_authentication.py new file mode 100644 index 000000000..3d857eccb --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_parameterized_bot_framework_authentication.py @@ -0,0 +1,504 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger +from typing import Dict, Optional + +from botbuilder.schema import Activity, RoleTypes + +from ..bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from ..http_client_factory import HttpClientFactory +from ..channels import Channels +from ..skills.bot_framework_client import BotFrameworkClient + +from .bot_framework_authentication import BotFrameworkAuthentication +from .claims_identity import ClaimsIdentity +from .user_token_client import UserTokenClient +from .connector_factory import ConnectorFactory +from .authenticate_request_result import AuthenticateRequestResult +from .service_client_credentials_factory import ServiceClientCredentialsFactory +from .authentication_configuration import AuthenticationConfiguration +from .verify_options import VerifyOptions +from .jwt_token_validation import JwtTokenValidation +from .skill_validation import SkillValidation +from .authentication_constants import AuthenticationConstants +from .emulator_validation import EmulatorValidation +from .jwt_token_extractor import JwtTokenExtractor +from ._bot_framework_client_impl import _BotFrameworkClientImpl +from ._built_in_bot_framework_authentication import _BuiltinBotFrameworkAuthentication +from ._user_token_client_impl import _UserTokenClientImpl +from ._connector_factory_impl import _ConnectorFactoryImpl + + +class _ParameterizedBotFrameworkAuthentication(BotFrameworkAuthentication): + def __init__( + self, + validate_authority: bool, + to_channel_from_bot_login_url: str, + to_channel_from_bot_oauth_scope: str, + to_bot_from_channel_token_issuer: str, + oauth_url: str, + to_bot_from_channel_open_id_metadata_url: str, + to_bot_from_emulator_open_id_metadata_url: str, + caller_id: str, + credentials_factory: ServiceClientCredentialsFactory, + auth_configuration: AuthenticationConfiguration, + http_client_factory: HttpClientFactory, + connector_client_configuration: BotFrameworkConnectorConfiguration = None, + logger: Logger = None, + ): + self._validate_authority = validate_authority + self._to_channel_from_bot_login_url = to_channel_from_bot_login_url + self._to_channel_from_bot_oauth_scope = to_channel_from_bot_oauth_scope + self._to_bot_from_channel_token_issuer = to_bot_from_channel_token_issuer + self._oauth_url = oauth_url + self._to_bot_from_channel_open_id_metadata_url = ( + to_bot_from_channel_open_id_metadata_url + ) + self._to_bot_from_emulator_open_id_metadata_url = ( + to_bot_from_emulator_open_id_metadata_url + ) + self._caller_id = caller_id + self._credentials_factory = credentials_factory + self._auth_configuration = auth_configuration + self._http_client_factory = http_client_factory + self._connector_client_configuration = connector_client_configuration + self._logger = logger + + async def authenticate_request( + self, activity: Activity, auth_header: str + ) -> AuthenticateRequestResult: + claims_identity = await self._jwt_token_validation_authenticate_request( + activity, auth_header + ) + + outbound_audience = ( + JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + if SkillValidation.is_skill_claim(claims_identity.claims) + else self._to_channel_from_bot_oauth_scope + ) + + caller_id = await self.generate_caller_id( + credential_factory=self._credentials_factory, + claims_identity=claims_identity, + caller_id=self._caller_id, + ) + + connector_factory = _ConnectorFactoryImpl( + app_id=_BuiltinBotFrameworkAuthentication.get_app_id(claims_identity), + to_channel_from_bot_oauth_scope=self._to_channel_from_bot_oauth_scope, + login_endpoint=self._to_channel_from_bot_login_url, + validate_authority=self._validate_authority, + credential_factory=self._credentials_factory, + connector_client_configuration=self._connector_client_configuration, + logger=self._logger, + ) + + result = AuthenticateRequestResult() + result.claims_identity = claims_identity + result.audience = outbound_audience + result.caller_id = caller_id + result.connector_factory = connector_factory + + return result + + async def authenticate_streaming_request( + self, auth_header: str, channel_id_header: str + ) -> AuthenticateRequestResult: + if channel_id_header is None: + is_auth_disabled = ( + await self._credentials_factory.is_authentication_disabled() + ) + if not is_auth_disabled: + raise PermissionError("Unauthorized Access. Request is not authorized") + + claims_identity = await self._jwt_token_validation_validate_auth_header( + auth_header, channel_id_header + ) + + outbound_audience = ( + JwtTokenValidation.get_app_id_from_claims(claims_identity.claims) + if SkillValidation.is_skill_claim(claims_identity.claims) + else self._to_channel_from_bot_oauth_scope + ) + + caller_id = await self.generate_caller_id( + credential_factory=self._credentials_factory, + claims_identity=claims_identity, + caller_id=self._caller_id, + ) + + result = AuthenticateRequestResult() + result.claims_identity = claims_identity + result.audience = outbound_audience + result.caller_id = caller_id + + return result + + def create_connector_factory( + self, claims_identity: ClaimsIdentity + ) -> ConnectorFactory: + return _ConnectorFactoryImpl( + app_id=_BuiltinBotFrameworkAuthentication.get_app_id(claims_identity), + to_channel_from_bot_oauth_scope=self._to_channel_from_bot_oauth_scope, + login_endpoint=self._to_channel_from_bot_login_url, + validate_authority=self._validate_authority, + credential_factory=self._credentials_factory, + connector_client_configuration=self._connector_client_configuration, + logger=self._logger, + ) + + async def create_user_token_client( + self, claims_identity: ClaimsIdentity + ) -> UserTokenClient: + app_id = _BuiltinBotFrameworkAuthentication.get_app_id(claims_identity) + + credentials = await self._credentials_factory.create_credentials( + app_id, + audience=self._to_channel_from_bot_oauth_scope, + login_endpoint=self._to_channel_from_bot_login_url, + validate_authority=self._validate_authority, + ) + + return _UserTokenClientImpl(app_id, credentials, self._oauth_url) + + def create_bot_framework_client(self) -> BotFrameworkClient: + return _BotFrameworkClientImpl( + self._credentials_factory, + self._http_client_factory, + self._to_channel_from_bot_login_url, + self._logger, + ) + + def get_originating_audience(self) -> str: + return self._to_channel_from_bot_oauth_scope + + async def authenticate_channel_request(self, auth_header: str) -> ClaimsIdentity: + return await self._jwt_token_validation_validate_auth_header( + auth_header, channel_id="unknown" + ) + + async def _jwt_token_validation_authenticate_request( + self, activity: Activity, auth_header: str + ) -> ClaimsIdentity: + if auth_header is None: + is_auth_disabled = ( + await self._credentials_factory.is_authentication_disabled() + ) + if not is_auth_disabled: + # No Auth Header. Auth is required. Request is not authorized. + raise PermissionError("Unauthorized Access. Request is not authorized") + + # Check if the activity is for a skill call and is coming from the Emulator. + if ( + activity.channel_id == Channels.emulator + and activity.recipient.role == RoleTypes.skill + ): + # Return an anonymous claim with an anonymous skill AppId + return SkillValidation.create_anonymous_skill_claim() + + # 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, AuthenticationConstants.ANONYMOUS_AUTH_TYPE) + + # Validate the header and extract claims. + claims_identity = await self._jwt_token_validation_validate_auth_header( + auth_header, activity.channel_id, activity.service_url + ) + + return claims_identity + + async def _jwt_token_validation_validate_auth_header( + self, auth_header: str, channel_id: str, service_url: Optional[str] = None + ) -> ClaimsIdentity: + identity = await self._jwt_token_validation_authenticate_token( + auth_header, channel_id, service_url + ) + + await self._jwt_token_validation_validate_claims(identity.claims) + + return identity + + async def _jwt_token_validation_validate_claims(self, claims: Dict[str, object]): + if self._auth_configuration.claims_validator: + # Call the validation method if defined (it should throw an exception if the validation fails) + await self._auth_configuration.claims_validator([claims]) + elif SkillValidation.is_skill_claim(claims): + raise PermissionError( + "ClaimsValidator is required for validation of Skill Host calls." + ) + + async def _jwt_token_validation_authenticate_token( + self, auth_header: str, channel_id: str, service_url: str + ) -> ClaimsIdentity: + if SkillValidation.is_skill_token(auth_header): + return await self._skill_validation_authenticate_channel_token( + auth_header, channel_id + ) + + if EmulatorValidation.is_token_from_emulator(auth_header): + return await self._emulator_validation_authenticate_emulator_token( + auth_header, channel_id + ) + + return await self._government_channel_validation_authenticate_channel_token( + auth_header, service_url, channel_id + ) + + # // The following code is based on SkillValidation.authenticate_channel_token + async def _skill_validation_authenticate_channel_token( + self, auth_header: str, channel_id: str + ) -> Optional[ClaimsIdentity]: + if not auth_header: + return None + + validation_params = VerifyOptions( + issuer=[ + # TODO: presumably this table should also come from configuration + # Auth v3.1, 1.0 token + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + # Auth v3.1, 2.0 token + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + # Auth v3.2, 1.0 token + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + # Auth v3.2, 2.0 token + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + # Auth for US Gov, 1.0 token + "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", + # Auth for US Gov, 2.0 token + "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", + ], + audience=None, # Audience validation takes place manually in code. + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + # TODO: what should the openIdMetadataUrl be here? + token_extractor = JwtTokenExtractor( + validation_params, + metadata_url=self._to_bot_from_emulator_open_id_metadata_url, + allowed_algorithms=AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + parts = auth_header.split(" ") + if len(parts) != 2: + return None + + identity = await token_extractor.get_identity( + schema=parts[0], + parameter=parts[1], + channel_id=channel_id, + required_endorsements=self._auth_configuration.required_endorsements, + ) + + await self._skill_validation_validate_identity(identity) + + return identity + + async def _skill_validation_validate_identity(self, identity: ClaimsIdentity): + if identity is None: + # No valid identity. Not Authorized. + raise PermissionError("Invalid Identity") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Token Not Authenticated") + + version_claim = identity.get_claim_value(AuthenticationConstants.VERSION_CLAIM) + if not version_claim: + # No version claim + raise PermissionError( + f"'{AuthenticationConstants.VERSION_CLAIM}' claim is required on skill Tokens." + ) + + # Look for the "aud" claim, but only if issued from the Bot Framework + audience_claim = identity.get_claim_value( + AuthenticationConstants.AUDIENCE_CLAIM + ) + if not audience_claim: + # Claim is not present or doesn't have a value. Not Authorized. + raise PermissionError( + f"'{AuthenticationConstants.AUDIENCE_CLAIM}' claim is required on skill Tokens." + ) + + is_valid_app_id = await self._credentials_factory.is_valid_app_id( + audience_claim + ) + if not is_valid_app_id: + # The AppId is not valid. Not Authorized. + raise PermissionError("Invalid audience.") + + app_id = JwtTokenValidation.get_app_id_from_claims(identity.claims) + if not app_id: + # Invalid appId + raise PermissionError("Invalid appId.") + + # The following code is based on EmulatorValidation.authenticate_emulator_token + async def _emulator_validation_authenticate_emulator_token( + self, auth_header: str, channel_id: str + ) -> Optional[ClaimsIdentity]: + if not auth_header: + return None + + to_bot_from_emulator_validation_params = VerifyOptions( + issuer=[ + # TODO: presumably this table should also come from configuration + # Auth v3.1, 1.0 token + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + # Auth v3.1, 2.0 token + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + # Auth v3.2, 1.0 token + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + # Auth v3.2, 2.0 token + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + # Auth for US Gov, 1.0 token + "https://sts.windows.net/cab8a31a-1906-4287-a0d8-4eef66b95f6e/", + # Auth for US Gov, 2.0 token + "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0", + ], + audience=None, # Audience validation takes place manually in code. + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + token_extractor = JwtTokenExtractor( + to_bot_from_emulator_validation_params, + metadata_url=self._to_bot_from_emulator_open_id_metadata_url, + allowed_algorithms=AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + parts = auth_header.split(" ") + if len(parts) != 2: + return None + + identity = await token_extractor.get_identity( + schema=parts[0], + parameter=parts[1], + channel_id=channel_id, + required_endorsements=self._auth_configuration.required_endorsements, + ) + + if identity is None: + # No valid identity. Not Authorized. + raise PermissionError("Invalid Identity") + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError("Token Not Authenticated") + + # Now check that the AppID in the claim set matches + # what we're looking for. Note that in a multi-tenant bot, this value + # comes from developer code that may be reaching out to a service, hence the + # Async validation. + version_claim = identity.get_claim_value(AuthenticationConstants.VERSION_CLAIM) + if version_claim is None: + raise PermissionError("'ver' claim is required on Emulator Tokens.") + + # The Emulator, depending on Version, sends the AppId via either the + # appid claim (Version 1) or the Authorized Party claim (Version 2). + if not version_claim or version_claim == "1.0": + # either no Version or a version of "1.0" means we should look for + # the claim in the "appid" claim. + app_id = identity.get_claim_value(AuthenticationConstants.APP_ID_CLAIM) + if not app_id: + # No claim around AppID. Not Authorized. + raise PermissionError( + "'appid' claim is required on Emulator Token version '1.0'." + ) + elif version_claim == "2.0": + app_id = identity.get_claim_value(AuthenticationConstants.AUTHORIZED_PARTY) + if not app_id: + raise PermissionError( + "'azp' claim is required on Emulator Token version '2.0'." + ) + else: + # Unknown Version. Not Authorized. + raise PermissionError(f"Unknown Emulator Token version '{version_claim}'.") + + is_valid_app_id = await self._credentials_factory.is_valid_app_id(app_id) + if not is_valid_app_id: + raise PermissionError(f"Invalid AppId passed on token: {app_id}") + + return identity + + async def _government_channel_validation_authenticate_channel_token( + self, auth_header: str, service_url: str, channel_id: str + ) -> Optional[ClaimsIdentity]: + if not auth_header: + return None + + validation_params = VerifyOptions( + issuer=[self._to_bot_from_channel_token_issuer], + audience=None, # Audience validation takes place in JwtTokenExtractor + clock_tolerance=5 * 60, + ignore_expiration=False, + ) + + token_extractor = JwtTokenExtractor( + validation_params, + metadata_url=self._to_bot_from_channel_open_id_metadata_url, + allowed_algorithms=AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, + ) + + parts = auth_header.split(" ") + if len(parts) != 2: + return None + + identity = await token_extractor.get_identity( + schema=parts[0], + parameter=parts[1], + channel_id=channel_id, + required_endorsements=self._auth_configuration.required_endorsements, + ) + + await self._government_channel_validation_validate_identity( + identity, service_url + ) + + return identity + + async def _government_channel_validation_validate_identity( + self, identity: ClaimsIdentity, service_url: str + ): + if identity is None: + # No valid identity. Not Authorized. + raise PermissionError() + + if not identity.is_authenticated: + # The token is in some way invalid. Not Authorized. + raise PermissionError() + + # Now check that the AppID in the claim set matches + # what we're looking for. Note that in a multi-tenant bot, this value + # comes from developer code that may be reaching out to a service, hence the + # Async validation. + + # Look for the "aud" claim, but only if issued from the Bot Framework + issuer = identity.get_claim_value(AuthenticationConstants.ISSUER_CLAIM) + if issuer != self._to_bot_from_channel_token_issuer: + raise PermissionError() + + app_id = identity.get_claim_value(AuthenticationConstants.AUDIENCE_CLAIM) + if not app_id: + # The relevant audience Claim MUST be present. Not Authorized. + raise PermissionError() + + # The AppId from the claim in the token must match the AppId specified by the developer. + # In this case, the token is destined for the app, so we find the app ID in the audience claim. + is_valid_app_id = await self._credentials_factory.is_valid_app_id(app_id) + if not is_valid_app_id: + # The AppId is not valid. Not Authorized. + raise PermissionError(f"Invalid AppId passed on token: {app_id}") + + if service_url is not None: + service_url_claim = identity.get_claim_value( + AuthenticationConstants.SERVICE_URL_CLAIM + ) + if not service_url_claim: + # Claim must be present. Not Authorized. + raise PermissionError() + + if service_url_claim != service_url: + # Claim must match. Not Authorized. + raise PermissionError() diff --git a/libraries/botframework-connector/botframework/connector/auth/_public_cloud_bot_framework_authentication.py b/libraries/botframework-connector/botframework/connector/auth/_public_cloud_bot_framework_authentication.py new file mode 100644 index 000000000..1e34a0ab8 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_public_cloud_bot_framework_authentication.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger + +from botbuilder.schema import CallerIdConstants + +from ..bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from ..http_client_factory import HttpClientFactory + +from .service_client_credentials_factory import ServiceClientCredentialsFactory +from .authentication_configuration import AuthenticationConfiguration +from .authentication_constants import AuthenticationConstants +from ._built_in_bot_framework_authentication import _BuiltinBotFrameworkAuthentication + + +class _PublicCloudBotFrameworkAuthentication(_BuiltinBotFrameworkAuthentication): + def __init__( + self, + credentials_factory: ServiceClientCredentialsFactory, + auth_configuration: AuthenticationConfiguration, + http_client_factory: HttpClientFactory, + connector_client_configuration: BotFrameworkConnectorConfiguration = None, + logger: Logger = None, + ): + super(_PublicCloudBotFrameworkAuthentication, self).__init__( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX, + CallerIdConstants.public_azure_channel, + "", # channel_service + AuthenticationConstants.OAUTH_URL, + credentials_factory, + auth_configuration, + http_client_factory, + connector_client_configuration, + logger, + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/_user_token_client_impl.py b/libraries/botframework-connector/botframework/connector/auth/_user_token_client_impl.py new file mode 100644 index 000000000..10603542b --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/_user_token_client_impl.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict, List + +from botbuilder.schema import Activity, TokenResponse + +from botframework.connector.token_api import TokenApiClientConfiguration +from botframework.connector.token_api.aio import TokenApiClient +from botframework.connector.token_api.models import ( + SignInUrlResponse, + TokenExchangeRequest, + TokenStatus, +) + +from .app_credentials import AppCredentials +from .user_token_client import UserTokenClient + + +class _UserTokenClientImpl(UserTokenClient): + def __init__( + self, + app_id: str, + credentials: AppCredentials, + oauth_endpoint: str, + client_configuration: TokenApiClientConfiguration = None, + ) -> None: + super().__init__() + self._app_id = app_id + self._client = TokenApiClient(credentials, oauth_endpoint) + if client_configuration: + self._client.config = client_configuration + + async def get_user_token( + self, user_id: str, connection_name: str, channel_id: str, magic_code: str + ) -> TokenResponse: + if user_id is None or not isinstance(user_id, str): + raise TypeError("user_id") + if connection_name is None or not isinstance(connection_name, str): + raise TypeError("connection_name") + if channel_id is None or not isinstance(channel_id, str): + raise TypeError("channel_id") + + result = await self._client.user_token.get_token( + user_id, connection_name, channel_id=channel_id, code=magic_code + ) + + if result is None or result.token is None: + return None + + return result + + async def get_sign_in_resource( + self, connection_name: str, activity: Activity, final_redirect: str + ) -> SignInUrlResponse: + if connection_name is None or not isinstance(connection_name, str): + raise TypeError("connection_name") + if activity is None or not isinstance(activity, Activity): + raise TypeError("activity") + + result = await self._client.bot_sign_in.get_sign_in_resource( + UserTokenClient.create_token_exchange_state( + self._app_id, connection_name, activity + ), + final_redirect=final_redirect, + ) + + return result + + async def sign_out_user(self, user_id: str, connection_name: str, channel_id: str): + if user_id is None or not isinstance(user_id, str): + raise TypeError("user_id") + if connection_name is None or not isinstance(connection_name, str): + raise TypeError("connection_name") + if channel_id is None or not isinstance(channel_id, str): + raise TypeError("channel_id") + + await self._client.user_token.sign_out(user_id, connection_name, channel_id) + + async def get_token_status( + self, user_id: str, channel_id: str, include_filter: str + ) -> List[TokenStatus]: + if user_id is None or not isinstance(user_id, str): + raise TypeError("user_id") + if channel_id is None or not isinstance(channel_id, str): + raise TypeError("channel_id") + + result = await self._client.user_token.get_token_status( + user_id, channel_id, include_filter + ) + + return result + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: List[str], + channel_id: str, + ) -> Dict[str, TokenResponse]: + if user_id is None or not isinstance(user_id, str): + raise TypeError("user_id") + if connection_name is None or not isinstance(connection_name, str): + raise TypeError("connection_name") + if channel_id is None or not isinstance(channel_id, str): + raise TypeError("channel_id") + + result = await self._client.user_token.get_aad_tokens( + user_id, connection_name, channel_id, resource_urls + ) + + return result + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + if user_id is None or not isinstance(user_id, str): + raise TypeError("user_id") + if connection_name is None or not isinstance(connection_name, str): + raise TypeError("connection_name") + if channel_id is None or not isinstance(channel_id, str): + raise TypeError("channel_id") + + (uri, token) = ( + (exchange_request.uri, exchange_request.token) + if exchange_request + else (None, None) + ) + + result = await self._client.user_token.exchange_async( + user_id, connection_name, channel_id, uri, token + ) + + return result diff --git a/libraries/botframework-connector/botframework/connector/auth/authenticate_request_result.py b/libraries/botframework-connector/botframework/connector/auth/authenticate_request_result.py new file mode 100644 index 000000000..4d9013abf --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/authenticate_request_result.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .claims_identity import ClaimsIdentity +from .connector_factory import ConnectorFactory + + +class AuthenticateRequestResult: + def __init__(self) -> None: + # A value for the Audience. + self.audience: str = None + # A value for the ClaimsIdentity. + self.claims_identity: ClaimsIdentity = None + # A value for the caller id. + self.caller_id: str = None + # A value for the ConnectorFactory. + self.connector_factory: ConnectorFactory = None diff --git a/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py b/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py index 294223f18..8a10a2bcd 100644 --- a/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py +++ b/libraries/botframework-connector/botframework/connector/auth/authentication_constants.py @@ -28,6 +28,11 @@ class AuthenticationConstants(ABC): # TO BOT FROM CHANNEL: Token issuer TO_BOT_FROM_CHANNEL_TOKEN_ISSUER = "https://api.botframework.com" + """ + OAuth Url used to get a token from OAuthApiClient. + """ + OAUTH_URL = "https://api.botframework.com" + # Application Setting Key for the OpenIdMetadataUrl value. BOT_OPEN_ID_METADATA_KEY = "BotOpenIdMetadata" diff --git a/libraries/botframework-connector/botframework/connector/auth/bot_framework_authentication.py b/libraries/botframework-connector/botframework/connector/auth/bot_framework_authentication.py new file mode 100644 index 000000000..ea1d96d62 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/bot_framework_authentication.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +from botbuilder.schema import Activity, CallerIdConstants + +from botframework.connector.skills import BotFrameworkClient + +from .authenticate_request_result import AuthenticateRequestResult +from .claims_identity import ClaimsIdentity +from .connector_factory import ConnectorFactory +from .jwt_token_validation import JwtTokenValidation +from .user_token_client import UserTokenClient +from .service_client_credentials_factory import ServiceClientCredentialsFactory +from .skill_validation import SkillValidation + + +class BotFrameworkAuthentication(ABC): + @abstractmethod + async def authenticate_request( + self, activity: Activity, auth_header: str + ) -> AuthenticateRequestResult: + """ + Validate Bot Framework Protocol requests. + + :param activity: The inbound Activity. + :param auth_header: The HTTP auth header. + :return: An AuthenticateRequestResult. + """ + raise NotImplementedError() + + @abstractmethod + async def authenticate_streaming_request( + self, auth_header: str, channel_id_header: str + ) -> AuthenticateRequestResult: + """ + Validate Bot Framework Protocol requests. + + :param auth_header: The HTTP auth header. + :param channel_id_header: The channel ID HTTP header. + :return: An AuthenticateRequestResult. + """ + raise NotImplementedError() + + @abstractmethod + def create_connector_factory( + self, claims_identity: ClaimsIdentity + ) -> ConnectorFactory: + """ + Creates a ConnectorFactory that can be used to create ConnectorClients that can use credentials + from this particular Cloud Environment. + + :param claims_identity: The inbound Activity's ClaimsIdentity. + :return: A ConnectorFactory. + """ + raise NotImplementedError() + + @abstractmethod + async def create_user_token_client( + self, claims_identity: ClaimsIdentity + ) -> UserTokenClient: + """ + Creates the appropriate UserTokenClient instance. + + :param claims_identity: The inbound Activity's ClaimsIdentity. + :return: An UserTokenClient. + """ + raise NotImplementedError() + + def create_bot_framework_client(self) -> BotFrameworkClient: + """ + Creates a BotFrameworkClient for calling Skills. + + :return: A BotFrameworkClient. + """ + raise Exception("NotImplemented") + + def get_originating_audience(self) -> str: + """ + Gets the originating audience from Bot OAuth scope. + + :return: The originating audience. + """ + raise Exception("NotImplemented") + + async def authenticate_channel_request(self, auth_header: str) -> ClaimsIdentity: + """ + Authenticate Bot Framework Protocol request to Skills. + + :param auth_header: The HTTP auth header in the skill request. + :return: A ClaimsIdentity. + """ + raise Exception("NotImplemented") + + async def generate_caller_id( + self, + *, + credential_factory: ServiceClientCredentialsFactory, + claims_identity: ClaimsIdentity, + caller_id: str, + ) -> str: + """ + Generates the appropriate caller_id to write onto the Activity, this might be None. + + :param credential_factory A ServiceClientCredentialsFactory to use. + :param claims_identity The inbound claims. + :param caller_id The default caller_id to use if this is not a skill. + :return: The caller_id, this might be None. + """ + # Is the bot accepting all incoming messages? + if await credential_factory.is_authentication_disabled(): + # Return None so that the caller_id is cleared. + return None + + # Is the activity from another bot? + return ( + f"{CallerIdConstants.bot_to_bot_prefix}{JwtTokenValidation.get_app_id_from_claims(claims_identity.claims)}" + if SkillValidation.is_skill_claim(claims_identity.claims) + else caller_id + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/bot_framework_authentication_factory.py b/libraries/botframework-connector/botframework/connector/auth/bot_framework_authentication_factory.py new file mode 100644 index 000000000..45643d465 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/bot_framework_authentication_factory.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from logging import Logger + +from ..bot_framework_sdk_client_async import BotFrameworkConnectorConfiguration +from ..http_client_factory import HttpClientFactory + +from ._government_cloud_bot_framework_authentication import ( + _GovernmentCloudBotFrameworkAuthentication, +) +from ._parameterized_bot_framework_authentication import ( + _ParameterizedBotFrameworkAuthentication, +) +from ._public_cloud_bot_framework_authentication import ( + _PublicCloudBotFrameworkAuthentication, +) + +from .authentication_configuration import AuthenticationConfiguration +from .bot_framework_authentication import BotFrameworkAuthentication +from .government_constants import GovernmentConstants +from .password_service_client_credential_factory import ( + PasswordServiceClientCredentialFactory, +) +from .service_client_credentials_factory import ServiceClientCredentialsFactory + + +class BotFrameworkAuthenticationFactory: + @staticmethod + def create( + *, + channel_service: str = None, + validate_authority: bool = False, + to_channel_from_bot_login_url: str = None, + to_channel_from_bot_oauth_scope: str = None, + to_bot_from_channel_token_issuer: str = None, + oauth_url: str = None, + to_bot_from_channel_open_id_metadata_url: str = None, + to_bot_from_emulator_open_id_metadata_url: str = None, + caller_id: str = None, + credential_factory: ServiceClientCredentialsFactory = PasswordServiceClientCredentialFactory(), + auth_configuration: AuthenticationConfiguration = AuthenticationConfiguration(), + http_client_factory: HttpClientFactory = None, + connector_client_configuration: BotFrameworkConnectorConfiguration = None, + logger: Logger = None + ) -> BotFrameworkAuthentication: + """ + Creates the appropriate BotFrameworkAuthentication instance. + + :param channel_service: The Channel Service. + :param validate_authority: The validate authority value to use. + :param to_channel_from_bot_login_url: The to Channel from bot login url. + :param to_channel_from_bot_oauth_scope: The to Channel from bot oauth scope. + :param to_bot_from_channel_token_issuer: The to bot from Channel Token Issuer. + :param oauth_url: The oAuth url. + :param to_bot_from_channel_open_id_metadata_url: The to bot from Channel Open Id Metadata url. + :param to_bot_from_emulator_open_id_metadata_url: The to bot from Emulator Open Id Metadata url. + :param caller_id: The Microsoft app password. + :param credential_factory: The ServiceClientCredentialsFactory to use to create credentials. + :param auth_configuration: The AuthenticationConfiguration to use. + :param http_client_factory: The HttpClientFactory to use for a skill BotFrameworkClient. + :param connector_client_configuration: Configuration to use custom http pipeline for the connector + :param logger: The Logger to use. + :return: A new BotFrameworkAuthentication instance. + """ + # pylint: disable=too-many-boolean-expressions + if ( + to_channel_from_bot_login_url + or to_channel_from_bot_oauth_scope + or to_bot_from_channel_token_issuer + or oauth_url + or to_bot_from_channel_open_id_metadata_url + or to_bot_from_emulator_open_id_metadata_url + or caller_id + ): + # if we have any of the 'parameterized' properties defined we'll assume this is the parameterized code + return _ParameterizedBotFrameworkAuthentication( + validate_authority, + to_channel_from_bot_login_url, + to_channel_from_bot_oauth_scope, + to_bot_from_channel_token_issuer, + oauth_url, + to_bot_from_channel_open_id_metadata_url, + to_bot_from_emulator_open_id_metadata_url, + caller_id, + credential_factory, + auth_configuration, + http_client_factory, + connector_client_configuration, + logger, + ) + # else apply the built in default behavior, which is either the public cloud or the gov cloud + # depending on whether we have a channelService value present + if not channel_service: + return _PublicCloudBotFrameworkAuthentication( + credential_factory, + auth_configuration, + http_client_factory, + connector_client_configuration, + logger, + ) + if channel_service == GovernmentConstants.CHANNEL_SERVICE: + return _GovernmentCloudBotFrameworkAuthentication( + credential_factory, + auth_configuration, + http_client_factory, + connector_client_configuration, + logger, + ) + + # The ChannelService value is used an indicator of which built in set of constants to use. + # If it is not recognized, a full configuration is expected. + raise ValueError("The provided channel_service value is not supported.") diff --git a/libraries/botframework-connector/botframework/connector/auth/connector_factory.py b/libraries/botframework-connector/botframework/connector/auth/connector_factory.py new file mode 100644 index 000000000..2cbadccf9 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/connector_factory.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +from botframework.connector.aio import ConnectorClient + + +class ConnectorFactory(ABC): + @abstractmethod + async def create(self, service_url: str, audience: str) -> ConnectorClient: + """ + A factory method used to create ConnectorClient instances. + :param service_url: The url for the client. + :param audience: The audience for the credentials the client will use. + :returns: A ConnectorClient for sending activities to the audience at the service_url. + """ + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/credential_provider.py b/libraries/botframework-connector/botframework/connector/auth/credential_provider.py index 7d41c2464..b9a83a37f 100644 --- a/libraries/botframework-connector/botframework/connector/auth/credential_provider.py +++ b/libraries/botframework-connector/botframework/connector/auth/credential_provider.py @@ -4,7 +4,7 @@ class CredentialProvider: """CredentialProvider. - This class allows Bots to provide their own implemention + This class allows Bots to provide their own implementation of what is, and what is not, a valid appId and password. This is useful in the case of multi-tenant bots, where the bot may need to call out to a service to determine if a particular @@ -20,7 +20,7 @@ async def is_valid_appid(self, app_id: str) -> bool: :param app_id: bot appid :return: true if it is a valid AppId """ - raise NotImplementedError + raise NotImplementedError() async def get_app_password(self, app_id: str) -> str: """Get the app password for a given bot appId, if it is not a valid appId, return Null @@ -31,7 +31,7 @@ async def get_app_password(self, app_id: str) -> str: :param app_id: bot appid :return: password or null for invalid appid """ - raise NotImplementedError + raise NotImplementedError() async def is_authentication_disabled(self) -> bool: """Checks if bot authentication is disabled. @@ -42,7 +42,7 @@ async def is_authentication_disabled(self) -> bool: :return: true if bot authentication is disabled. """ - raise NotImplementedError + raise NotImplementedError() class SimpleCredentialProvider(CredentialProvider): @@ -58,3 +58,17 @@ async def get_app_password(self, app_id: str) -> str: async def is_authentication_disabled(self) -> bool: return not self.app_id + + +class _DelegatingCredentialProvider(CredentialProvider): + def __init__(self, credentials_factory: "botframework.connector.auth"): + self._credentials_factory = credentials_factory + + async def is_valid_appid(self, app_id: str) -> bool: + return await self._credentials_factory.is_valid_app_id(app_id) + + async def get_app_password(self, app_id: str) -> str: + raise NotImplementedError() + + async def is_authentication_disabled(self) -> bool: + return await self._credentials_factory.is_authentication_disabled() diff --git a/libraries/botframework-connector/botframework/connector/auth/government_constants.py b/libraries/botframework-connector/botframework/connector/auth/government_constants.py index 550eb3e3f..c15c8e41e 100644 --- a/libraries/botframework-connector/botframework/connector/auth/government_constants.py +++ b/libraries/botframework-connector/botframework/connector/auth/government_constants.py @@ -29,6 +29,11 @@ class GovernmentConstants(ABC): """ TO_BOT_FROM_CHANNEL_TOKEN_ISSUER = "https://api.botframework.us" + """ + OAuth Url used to get a token from OAuthApiClient. + """ + OAUTH_URL_GOV = "https://api.botframework.azure.us" + """ TO BOT FROM CHANNEL: OpenID metadata document for tokens coming from MSA """ diff --git a/libraries/botframework-connector/botframework/connector/auth/password_service_client_credential_factory.py b/libraries/botframework-connector/botframework/connector/auth/password_service_client_credential_factory.py new file mode 100644 index 000000000..1e14b496c --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/password_service_client_credential_factory.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from logging import Logger + +from msrest.authentication import Authentication + +from .authentication_constants import AuthenticationConstants +from .government_constants import GovernmentConstants +from .microsoft_app_credentials import MicrosoftAppCredentials +from .service_client_credentials_factory import ServiceClientCredentialsFactory + + +class PasswordServiceClientCredentialFactory(ServiceClientCredentialsFactory): + def __init__( + self, app_id: str = None, password: str = None, *, logger: Logger = None + ) -> None: + self.app_id = app_id + self.password = password + self._logger = logger + + async def is_valid_app_id(self, app_id: str) -> bool: + return app_id == self.app_id + + async def is_authentication_disabled(self) -> bool: + return not self.app_id + + async def create_credentials( + self, app_id: str, audience: str, login_endpoint: str, validate_authority: bool + ) -> Authentication: + if await self.is_authentication_disabled(): + return MicrosoftAppCredentials.empty() + + if not await self.is_valid_app_id(app_id): + raise Exception("Invalid app_id") + + credentials: MicrosoftAppCredentials = None + normalized_endpoint = login_endpoint.lower() if login_endpoint else "" + + if normalized_endpoint.startswith( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX + ): + # TODO: Unpack necessity of these empty credentials based on the + # loginEndpoint as no tokensare fetched when auth is disabled. + credentials = ( + MicrosoftAppCredentials.empty() + if not app_id + else MicrosoftAppCredentials(app_id, self.password, None, audience) + ) + elif normalized_endpoint == GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL: + credentials = ( + MicrosoftAppCredentials( + None, + None, + None, + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ) + if not app_id + else MicrosoftAppCredentials(app_id, self.password, None, audience) + ) + normalized_endpoint = login_endpoint + else: + credentials = ( + _PrivateCloudAppCredentials( + None, None, None, normalized_endpoint, validate_authority + ) + if not app_id + else MicrosoftAppCredentials( + app_id, + self.password, + audience, + normalized_endpoint, + validate_authority, + ) + ) + + return credentials + + +class _PrivateCloudAppCredentials(MicrosoftAppCredentials): + def __init__( + self, + app_id: str, + password: str, + oauth_scope: str, + oauth_endpoint: str, + validate_authority: bool, + ): + super().__init__( + app_id, password, channel_auth_tenant=None, oauth_scope=oauth_scope + ) + + self.oauth_endpoint = oauth_endpoint + self._validate_authority = validate_authority + + @property + def validate_authority(self): + return self._validate_authority diff --git a/libraries/botframework-connector/botframework/connector/auth/service_client_credentials_factory.py b/libraries/botframework-connector/botframework/connector/auth/service_client_credentials_factory.py new file mode 100644 index 000000000..1c765ad9a --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/service_client_credentials_factory.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +from .app_credentials import AppCredentials + + +class ServiceClientCredentialsFactory(ABC): + @abstractmethod + async def is_valid_app_id(self, app_id: str) -> bool: + """ + Validates an app ID. + + :param app_id: The app ID to validate. + :returns: The result is true if `app_id` is valid for the controller; otherwise, false. + """ + raise NotImplementedError() + + @abstractmethod + async def is_authentication_disabled(self) -> bool: + """ + Checks whether bot authentication is disabled. + + :returns: If bot authentication is disabled, the result is true; otherwise, false. + """ + raise NotImplementedError() + + @abstractmethod + async def create_credentials( + self, app_id: str, audience: str, login_endpoint: str, validate_authority: bool + ) -> AppCredentials: + """ + A factory method for creating AppCredentials. + + :param app_id: The appId. + :param audience: The audience. + :param login_endpoint: The login url. + :param validate_authority: The validate authority value to use. + :returns: An AppCredentials object. + """ + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/auth/user_token_client.py b/libraries/botframework-connector/botframework/connector/auth/user_token_client.py new file mode 100644 index 000000000..01911ef91 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/user_token_client.py @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from base64 import b64encode +from json import dumps +from typing import Dict, List + +from botbuilder.schema import Activity, TokenResponse, TokenExchangeState + +from botframework.connector.token_api.models import ( + SignInUrlResponse, + TokenExchangeRequest, + TokenStatus, +) + + +class UserTokenClient(ABC): + @abstractmethod + async def get_user_token( + self, user_id: str, connection_name: str, channel_id: str, magic_code: str + ) -> TokenResponse: + """ + Attempts to retrieve the token for a user that's in a login flow. + + :param user_id: The user id that will be associated with the token. + :param connection_name: Name of the auth connection to use. + :param channel_id: The channel Id that will be associated with the token. + :param magic_code: (Optional) Optional user entered code to validate. + :return: A TokenResponse object. + """ + raise NotImplementedError() + + @abstractmethod + async def get_sign_in_resource( + self, connection_name: str, activity: Activity, final_redirect: str + ) -> SignInUrlResponse: + """ + Get the raw signin link to be sent to the user for signin for a connection name. + + :param connection_name: Name of the auth connection to use. + :param activity: The Activity from which to derive the token exchange state. + :param final_redirect: The final URL that the OAuth flow will redirect to. + :return: A SignInUrlResponse. + """ + raise NotImplementedError() + + @abstractmethod + async def sign_out_user(self, user_id: str, connection_name: str, channel_id: str): + """ + Signs the user out with the token server. + + :param user_id: The user id that will be associated with the token. + :param connection_name: Name of the auth connection to use. + :param channel_id: The channel Id that will be associated with the token. + """ + raise NotImplementedError() + + @abstractmethod + async def get_token_status( + self, user_id: str, channel_id: str, include_filter: str + ) -> List[TokenStatus]: + """ + Retrieves the token status for each configured connection for the given user. + + :param user_id: The user id that will be associated with the token. + :param channel_id: The channel Id that will be associated with the token. + :param include_filter: The include filter. + :return: A list of TokenStatus objects. + """ + raise NotImplementedError() + + @abstractmethod + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: List[str], + channel_id: str, + ) -> Dict[str, TokenResponse]: + """ + Retrieves Azure Active Directory tokens for particular resources on a configured connection. + + :param user_id: The user id that will be associated with the token. + :param connection_name: Name of the auth connection to use. + :param resource_urls: The list of resource URLs to retrieve tokens for. + :param channel_id: The channel Id that will be associated with the token. + :return: A Dictionary of resource_urls to the corresponding TokenResponse. + """ + raise NotImplementedError() + + @abstractmethod + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + """ + Performs a token exchange operation such as for single sign-on. + + :param user_id The user id that will be associated with the token. + :param connection_name Name of the auth connection to use. + :param channel_id The channel Id that will be associated with the token. + :param exchange_request The exchange request details, either a token to exchange or a uri to exchange. + :return: A TokenResponse object. + """ + raise NotImplementedError() + + @staticmethod + def create_token_exchange_state( + app_id: str, connection_name: str, activity: Activity + ) -> str: + """ + Helper function to create the Base64 encoded token exchange state used in getSignInResource calls. + + :param app_id The app_id to include in the token exchange state. + :param connection_name The connection_name to include in the token exchange state. + :param activity The [Activity](xref:botframework-schema.Activity) from which to derive the token exchange state. + :return: Base64 encoded token exchange state. + """ + if app_id is None or not isinstance(app_id, str): + raise TypeError("app_id") + if connection_name is None or not isinstance(connection_name, str): + raise TypeError("connection_name") + if activity is None or not isinstance(activity, Activity): + raise TypeError("activity") + + token_exchange_state = TokenExchangeState( + connection_name=connection_name, + conversation=Activity.get_conversation_reference(activity), + relates_to=activity.relates_to, + ms_app_id=app_id, + ) + + tes_string = b64encode( + dumps(token_exchange_state.serialize()).encode( + encoding="UTF-8", errors="strict" + ) + ).decode() + + return tes_string diff --git a/libraries/botframework-connector/botframework/connector/http_client_base.py b/libraries/botframework-connector/botframework/connector/http_client_base.py new file mode 100644 index 000000000..501352819 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/http_client_base.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +from .http_request import HttpRequest +from .http_response_base import HttpResponseBase + + +class HttpClientBase(ABC): + @abstractmethod + async def post(self, *, request: HttpRequest) -> HttpResponseBase: + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/http_client_factory.py b/libraries/botframework-connector/botframework/connector/http_client_factory.py new file mode 100644 index 000000000..a5311b424 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/http_client_factory.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .http_client_base import HttpClientBase + + +class HttpClientFactory: + def create_client(self) -> HttpClientBase: + pass diff --git a/libraries/botframework-connector/botframework/connector/http_request.py b/libraries/botframework-connector/botframework/connector/http_request.py new file mode 100644 index 000000000..de9f2db6b --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/http_request.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any, Dict + + +class HttpRequest: + def __init__( + self, + *, + request_uri: str = None, + content: Any = None, + headers: Dict[str, str] = None + ) -> None: + self.request_uri = request_uri + self.content = content + self.headers = headers diff --git a/libraries/botframework-connector/botframework/connector/http_response_base.py b/libraries/botframework-connector/botframework/connector/http_response_base.py new file mode 100644 index 000000000..27db7e1f6 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/http_response_base.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from http import HTTPStatus +from typing import Union + + +class HttpResponseBase(ABC): + @property + @abstractmethod + def status_code(self) -> Union[HTTPStatus, int]: + raise NotImplementedError() + + @abstractmethod + async def is_succesful(self) -> bool: + raise NotImplementedError() + + @abstractmethod + async def read_content_str(self) -> str: + raise NotImplementedError() diff --git a/libraries/botframework-connector/botframework/connector/skills/__init__.py b/libraries/botframework-connector/botframework/connector/skills/__init__.py new file mode 100644 index 000000000..5afcccb28 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/skills/__init__.py @@ -0,0 +1,3 @@ +from .bot_framework_client import BotFrameworkClient + +__all__ = ["BotFrameworkClient"] diff --git a/libraries/botframework-connector/botframework/connector/skills/bot_framework_client.py b/libraries/botframework-connector/botframework/connector/skills/bot_framework_client.py new file mode 100644 index 000000000..6917f0109 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/skills/bot_framework_client.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +# TODO: add InvokeResponse to botbuilder-schema or rethink dependencies +from botbuilder.schema import Activity + + +class BotFrameworkClient(ABC): + @abstractmethod + async def post_activity( + self, + from_bot_id: str, + to_bot_id: str, + to_url: str, + service_url: str, + conversation_id: str, + activity: Activity, + ) -> "botbuilder.core.InvokeResponse": + """ + Forwards an activity to a another bot. + + :param from_bot_id: The MicrosoftAppId of the bot sending the activity. + :param to_bot_id: The MicrosoftAppId of the bot receiving the activity. + :param to_url: The URL of the bot receiving the activity. + :param service_url: The callback Url for the skill host. + :param conversation_id: A conversation ID to use for the conversation with the skill. + :param activity: Activity to forward. + """ + raise NotImplementedError() diff --git a/libraries/botframework-connector/setup.py b/libraries/botframework-connector/setup.py index 35363bcb0..4e2ec8c7c 100644 --- a/libraries/botframework-connector/setup.py +++ b/libraries/botframework-connector/setup.py @@ -35,6 +35,7 @@ "botframework.connector.models", "botframework.connector.aio", "botframework.connector.aio.operations_async", + "botframework.connector.skills", "botframework.connector.teams", "botframework.connector.teams.operations", "botframework.connector.token_api", diff --git a/libraries/botframework-streaming/requirements.txt b/libraries/botframework-streaming/requirements.txt index 1d6f7ab31..7f973ed8c 100644 --- a/libraries/botframework-streaming/requirements.txt +++ b/libraries/botframework-streaming/requirements.txt @@ -1,4 +1,3 @@ msrest==0.6.10 -botframework-connector>=4.7.1 -botbuilder-schema>=4.7.1 -aiohttp>=3.6.2 \ No newline at end of file +botframework-connector>=4.14.0 +botbuilder-schema>=4.14.0 \ No newline at end of file diff --git a/libraries/botframework-streaming/tests/test_payload_processor.py b/libraries/botframework-streaming/tests/test_payload_processor.py index cb892ff16..456775d9e 100644 --- a/libraries/botframework-streaming/tests/test_payload_processor.py +++ b/libraries/botframework-streaming/tests/test_payload_processor.py @@ -16,9 +16,6 @@ class MockStreamManager(StreamManager): - def __init__(self): - super().__init__() - def get_payload_assembler(self, identifier: UUID) -> PayloadStreamAssembler: return PayloadStreamAssembler(self, identifier) diff --git a/libraries/botframework-streaming/tests/test_payload_receiver.py b/libraries/botframework-streaming/tests/test_payload_receiver.py index c9e2aa58c..00c1253c2 100644 --- a/libraries/botframework-streaming/tests/test_payload_receiver.py +++ b/libraries/botframework-streaming/tests/test_payload_receiver.py @@ -8,6 +8,7 @@ class MockTransportReceiver(TransportReceiverBase): + # pylint: disable=unused-argument def __init__(self, mock_header: bytes, mock_payload: bytes): self._is_connected = True self._mock_gen = self._mock_receive(mock_header, mock_payload) diff --git a/libraries/botframework-streaming/tests/test_send_operations.py b/libraries/botframework-streaming/tests/test_send_operations.py index edc29a1ed..926124304 100644 --- a/libraries/botframework-streaming/tests/test_send_operations.py +++ b/libraries/botframework-streaming/tests/test_send_operations.py @@ -14,6 +14,7 @@ class MockTransportSender(TransportSenderBase): + # pylint: disable=unused-argument def __init__(self): super().__init__() self.is_connected = True