From 412e6c90056fe822b5a1aff8f36ca5e429655bac Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Dec 2019 18:25:01 -0800 Subject: [PATCH 1/9] Skill layer working, oauth prompt and testing pending --- .../botbuilder/core/__init__.py | 2 + .../botbuilder/core/activity_handler.py | 11 +- .../botbuilder/core/adapters/test_adapter.py | 14 +- .../botbuilder-core/botbuilder/core/bot.py | 21 + .../botbuilder/core/bot_adapter.py | 10 +- .../botbuilder/core/bot_framework_adapter.py | 43 +- .../botbuilder/core/memory_storage.py | 16 +- .../botbuilder/core/skills/__init__.py | 18 + .../core/skills/bot_framework_skill.py | 14 + .../core/skills/conversation_id_factory.py | 22 + .../skills/skill_conversation_id_factory.py | 54 +++ .../botbuilder/core/skills/skill_handler.py | 422 ++++++++++++++++++ libraries/botbuilder-core/setup.py | 1 + .../botbuilder-core/tests/test_bot_adapter.py | 2 +- .../connector/auth/emulator_validation.py | 4 +- .../auth/microsoft_app_credentials.py | 2 +- samples/01.console-echo/main.py | 2 +- samples/13.core-bot/app.py | 2 +- samples/16.proactive-messages/app.py | 2 +- .../simple-child-bot/README.md | 30 ++ .../simple-bot-to-bot/simple-child-bot/app.py | 85 ++++ .../simple-child-bot/bots/__init__.py | 6 + .../simple-child-bot/bots/echo_bot.py | 27 ++ .../simple-child-bot/config.py | 15 + .../simple-child-bot/requirements.txt | 2 + .../simple-bot-to-bot/simple-root-bot/app.py | 112 +++++ .../simple-root-bot/bots/__init__.py | 4 + .../simple-root-bot/bots/root_bot.py | 108 +++++ .../simple-root-bot/config.py | 32 ++ .../simple-root-bot/middleware/__init__.py | 4 + .../middleware/dummy_middleware.py | 32 ++ 31 files changed, 1089 insertions(+), 30 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/bot.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/__init__.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py create mode 100644 libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py create mode 100644 samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index d6977c927..6ced95ae5 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -9,6 +9,7 @@ from .about import __version__ from .activity_handler import ActivityHandler from .auto_save_state_middleware import AutoSaveStateMiddleware +from .bot import Bot from .bot_assert import BotAssert from .bot_adapter import BotAdapter from .bot_framework_adapter import BotFrameworkAdapter, BotFrameworkAdapterSettings @@ -42,6 +43,7 @@ "ActivityHandler", "AnonymousReceiveMiddleware", "AutoSaveStateMiddleware", + "Bot", "BotAdapter", "BotAssert", "BotFrameworkAdapter", diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index adc51ba5c..fed53bb45 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -32,6 +32,8 @@ async def on_turn(self, turn_context: TurnContext): await self.on_message_reaction_activity(turn_context) elif turn_context.activity.type == ActivityTypes.event: await self.on_event_activity(turn_context) + elif turn_context.activity.type == ActivityTypes.end_of_conversation: + await self.on_end_of_conversation(turn_context) else: await self.on_unrecognized_activity_type(turn_context) @@ -58,12 +60,12 @@ async def on_conversation_update_activity(self, turn_context: TurnContext): return async def on_members_added_activity( - self, members_added: ChannelAccount, turn_context: TurnContext + self, members_added: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument return async def on_members_removed_activity( - self, members_removed: ChannelAccount, turn_context: TurnContext + self, members_removed: List[ChannelAccount], turn_context: TurnContext ): # pylint: disable=unused-argument return @@ -104,6 +106,11 @@ async def on_event( # pylint: disable=unused-argument ): return + async def on_end_of_conversation( # pylint: disable=unused-argument + self, turn_context: TurnContext + ): + return + async def on_unrecognized_activity_type( # pylint: disable=unused-argument self, turn_context: TurnContext ): diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 54095b1eb..0ff9f16b6 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -20,6 +20,7 @@ ResourceResponse, TokenResponse, ) +from botframework.connector.auth import ClaimsIdentity from ..bot_adapter import BotAdapter from ..turn_context import TurnContext from ..user_token_provider import UserTokenProvider @@ -157,16 +158,23 @@ async def update_activity(self, context, activity: Activity): self.updated_activities.append(activity) async def continue_conversation( - self, bot_id: str, reference: ConversationReference, callback: Callable + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): """ The `TestAdapter` just calls parent implementation. - :param bot_id :param reference: :param callback: + :param bot_id: + :param claims_identity: :return: """ - await super().continue_conversation(bot_id, reference, callback) + await super().continue_conversation( + reference, callback, bot_id, claims_identity + ) async def receive_activity(self, activity): """ diff --git a/libraries/botbuilder-core/botbuilder/core/bot.py b/libraries/botbuilder-core/botbuilder/core/bot.py new file mode 100644 index 000000000..afbaa3293 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/bot.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + +from .turn_context import TurnContext + + +class Bot(ABC): + """ + Represents a bot that can operate on incoming activities. + """ + + @abstractmethod + async def on_turn(self, context: TurnContext): + """ + When implemented in a bot, handles an incoming activity. + :param context: The context object for this turn. + :return: + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index af893d3ed..f97030879 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from typing import List, Callable, Awaitable from botbuilder.schema import Activity, ConversationReference, ResourceResponse +from botframework.connector.auth import ClaimsIdentity from . import conversation_reference_extension from .bot_assert import BotAssert @@ -62,8 +63,12 @@ def use(self, middleware): return self async def continue_conversation( - self, bot_id: str, reference: ConversationReference, callback: Callable - ): # pylint: disable=unused-argument + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ): """ 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 @@ -73,6 +78,7 @@ async def continue_conversation( which is multi-tenant aware. :param reference: A reference to the conversation to continue. :param callback: The method to call for the resulting bot turn. + :param claims_identity: """ context = TurnContext( self, conversation_reference_extension.get_continuation_activity(reference) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index 8424517e7..22bbb7899 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -37,6 +37,7 @@ from .bot_adapter import BotAdapter from .turn_context import TurnContext from .user_token_provider import UserTokenProvider +from .conversation_reference_extension import get_continuation_activity USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" OAUTH_ENDPOINT = "https://api.botframework.com" @@ -128,8 +129,14 @@ def __init__(self, settings: BotFrameworkAdapterSettings): GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE ) + self._connector_client_cache: Dict[str, ConnectorClient] = {} + async def continue_conversation( - self, bot_id: str, reference: ConversationReference, callback: Callable + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument ): """ Continues a conversation with a user. This is often referred to as the bots "Proactive Messaging" @@ -139,18 +146,26 @@ async def continue_conversation( :param bot_id: :param reference: :param callback: + :param claims_identity: :return: """ # TODO: proactive messages + if not claims_identity: + if not bot_id: + raise TypeError("Expected bot_id: str but got None instead") + + claims_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: bot_id, + AuthenticationConstants.APP_ID_CLAIM: bot_id, + }, + is_authenticated=True, + ) - if not bot_id: - raise TypeError("Expected bot_id: str but got None instead") - - request = TurnContext.apply_conversation_reference( - Activity(), reference, is_incoming=True - ) - context = self.create_context(request) + context = TurnContext(self, get_continuation_activity(reference)) + context.turn_state[BOT_IDENTITY_KEY] = claims_identity + context.turn_state["BotCallbackHandler"] = callback return await self.run_pipeline(context, callback) async def create_conversation( @@ -658,8 +673,16 @@ async def create_connector_client( else: credentials = self._credentials - client = ConnectorClient(credentials, base_url=service_url) - client.config.add_user_agent(USER_AGENT) + client_key = ( + f"{service_url}{credentials.microsoft_app_id if credentials else ''}" + ) + client = self._connector_client_cache.get(client_key) + + if not client: + client = ConnectorClient(credentials, base_url=service_url) + client.config.add_user_agent(USER_AGENT) + self._connector_client_cache[client_key] = client + return client def create_token_api_client(self, service_url: str) -> TokenApiClient: diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index b85b3d368..264c5b798 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -48,9 +48,9 @@ async def write(self, changes: Dict[str, StoreItem]): # If it exists then we want to cache its original value from memory if key in self.memory: old_state = self.memory[key] - if not isinstance(old_state, StoreItem): + if isinstance(old_state, dict): old_state_etag = old_state.get("e_tag", None) - elif old_state.e_tag: + elif hasattr(old_state, "e_tag"): old_state_etag = old_state.e_tag new_state = new_value @@ -59,7 +59,11 @@ async def write(self, changes: Dict[str, StoreItem]): new_value_etag = ( new_value.e_tag if hasattr(new_value, "e_tag") - else new_value.get("e_tag", None) + else ( + new_value.get("e_tag", None) + if isinstance(new_value, dict) + else None + ) ) if new_value_etag == "": raise Exception("blob_storage.write(): etag missing") @@ -73,10 +77,10 @@ async def write(self, changes: Dict[str, StoreItem]): "Etag conflict.\nOriginal: %s\r\nCurrent: %s" % (new_value_etag, old_state_etag) ) - if hasattr(new_state, "e_tag"): - new_state.e_tag = str(self._e_tag) - else: + if isinstance(new_state, dict): new_state["e_tag"] = str(self._e_tag) + else: + new_state.e_tag = str(self._e_tag) self._e_tag += 1 self.memory[key] = deepcopy(new_state) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/__init__.py b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py new file mode 100644 index 000000000..6bd5a66b8 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/__init__.py @@ -0,0 +1,18 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .bot_framework_skill import BotFrameworkSkill +from .conversation_id_factory import ConversationIdFactoryBase +from .skill_conversation_id_factory import SkillConversationIdFactory +from .skill_handler import SkillHandler + +__all__ = [ + "BotFrameworkSkill", + "ConversationIdFactoryBase", + "SkillConversationIdFactory", + "SkillHandler", +] diff --git a/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py b/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py new file mode 100644 index 000000000..8819d6674 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/bot_framework_skill.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class BotFrameworkSkill: + """ + Registration for a BotFrameworkHttpProtocol based Skill endpoint. + """ + + # pylint: disable=invalid-name + def __init__(self, id: str = None, app_id: str = None, skill_endpoint: str = None): + self.id = id + self.app_id = app_id + self.skill_endpoint = skill_endpoint diff --git a/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py new file mode 100644 index 000000000..7c015de08 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/conversation_id_factory.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from abc import ABC, abstractmethod +from botbuilder.schema import ConversationReference + + +class ConversationIdFactoryBase(ABC): + @abstractmethod + async def create_skill_conversation_id( + self, conversation_reference: ConversationReference + ) -> str: + raise NotImplementedError() + + @abstractmethod + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + raise NotImplementedError() + + @abstractmethod + async def delete_conversation_reference(self, skill_conversation_id: str): + raise NotImplementedError() 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 new file mode 100644 index 000000000..6b01865fc --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_conversation_id_factory.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import hashlib +from typing import Dict, Tuple + +from botbuilder.core import Storage +from botbuilder.schema import ConversationReference + +from .conversation_id_factory import ConversationIdFactoryBase + + +class SkillConversationIdFactory(ConversationIdFactoryBase): + def __init__(self, storage: Storage): + if not storage: + raise TypeError("storage can't be None") + + self._storage = storage + self._forward_x_ref: Dict[str, str] = {} + self._backward_x_ref: Dict[str, Tuple[str, str]] = {} + + async def create_skill_conversation_id( + self, conversation_reference: ConversationReference + ) -> str: + if not conversation_reference: + raise TypeError("conversation_reference can't be None") + + if not conversation_reference.conversation.id: + raise TypeError("conversation id in conversation reference can't be None") + + if not conversation_reference.channel_id: + raise TypeError("channel id in conversation reference can't be None") + + storage_key = hashlib.md5( + f"{conversation_reference.conversation.id}{conversation_reference.channel_id}".encode() + ).hexdigest() + + skill_conversation_info = {storage_key: conversation_reference} + + await self._storage.write(skill_conversation_info) + + return storage_key + + async def get_conversation_reference( + self, skill_conversation_id: str + ) -> ConversationReference: + if not skill_conversation_id: + raise TypeError("skill_conversation_id can't be None") + + skill_conversation_info = await self._storage.read([skill_conversation_id]) + + return skill_conversation_info.get(skill_conversation_id) + + async def delete_conversation_reference(self, skill_conversation_id: str): + 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 new file mode 100644 index 000000000..81d7d55be --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -0,0 +1,422 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from uuid import uuid4 +from typing import List + +from botbuilder.core.integration import ChannelServiceHandler +from botbuilder.core import Bot, BotAdapter, TurnContext +from botbuilder.schema import ( + Activity, + ActivityTypes, + AttachmentData, + ChannelAccount, + ConversationAccount, + ConversationParameters, + ConversationResourceResponse, + ConversationsResult, + PagedMembersResult, + ResourceResponse, + RoleTypes, + Transcript, +) +from botframework.connector.auth import ( + AuthenticationConfiguration, + ChannelProvider, + ClaimsIdentity, + CredentialProvider, +) + +from .skill_conversation_id_factory import SkillConversationIdFactory + + +class SkillHandler(ChannelServiceHandler): + def __init__( + self, + adapter: BotAdapter, + bot: Bot, + conversation_id_factory: SkillConversationIdFactory, + credential_provider: CredentialProvider, + auth_configuration: AuthenticationConfiguration, + channel_provider: ChannelProvider = None, + logger: object = None, + ): + super().__init__(credential_provider, auth_configuration, channel_provider) + + 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._adapter = adapter + self._bot = bot + self._conversation_id_factory = conversation_id_factory + self._logger = logger + self.skill_conversation_reference_key = "SkillConversationReference" + + async def on_send_to_conversation( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, + ) -> ResourceResponse: + """ + send_to_conversation() API for Skill + + This method allows you to send an activity to the end of a conversation. + + This is slightly different from ReplyToActivity(). + * SendToConversation(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + :param claims_identity: + :param conversation_id: + :param activity: + :return: + """ + 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(conversationId) - will append the activity to the end + of the conversation according to the timestamp or semantics of the channel. + * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply + to another activity, if the channel supports it. If the channel does not + support nested replies, ReplyToActivity falls back to SendToConversation. + + Use ReplyToActivity when replying to a specific activity in the + conversation. + + Use SendToConversation in all other cases. + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + return await self._process_activity( + claims_identity, conversation_id, activity_id, activity, + ) + + async def on_get_conversations( + self, claims_identity: ClaimsIdentity, continuation_token: str = "", + ) -> ConversationsResult: + """ + get_conversations() API for Skill + + List the Conversations in which this bot has participated. + + GET from this method with a skip token + + The return value is a ConversationsResult, which contains an array of + ConversationMembers and a skip token. If the skip token is not empty, then + there are further values to be returned. Call this method again with the + returned token to get more values. + + Each ConversationMembers object contains the ID of the conversation and an + array of ChannelAccounts that describe the members of the conversation. + :param claims_identity: + :param conversation_id: + :param continuation_token: + :return: + """ + raise NotImplementedError() + + async def on_create_conversation( + self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, + ) -> ConversationResourceResponse: + """ + create_conversation() API for Skill + + Create a new Conversation. + + POST to this method with a + * Bot being the bot creating the conversation + * IsGroup set to true if this is not a direct message (default is false) + * Array containing the members to include in the conversation + + The return value is a ResourceResponse which contains a conversation id + which is suitable for use + in the message payload and REST API uris. + + Most channels only support the semantics of bots initiating a direct + message conversation. An example of how to do that would be: + + var resource = await connector.conversations.CreateConversation(new + ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { new + ChannelAccount("user1") } ); + await connect.Conversations.SendToConversationAsync(resource.Id, new + Activity() ... ) ; + + end. + :param claims_identity: + :param conversation_id: + :param parameters: + :return: + """ + raise NotImplementedError() + + async def on_send_conversation_history( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + transcript: Transcript, + ) -> ResourceResponse: + """ + send_conversation_history() API for Skill. + + This method allows you to upload the historic activities to the + conversation. + + Sender must ensure that the historic activities have unique ids and + appropriate timestamps. The ids are used by the client to deal with + duplicate activities and the timestamps are used by the client to render + the activities in the right order. + :param claims_identity: + :param conversation_id: + :param transcript: + :return: + """ + raise NotImplementedError() + + async def on_update_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + activity_id: str, + activity: Activity, + ) -> ResourceResponse: + """ + update_activity() API for Skill. + + Edit an existing activity. + + Some channels allow you to edit an existing activity to reflect the new + state of a bot conversation. + + For example, you can remove buttons after someone has clicked "Approve" + button. + :param claims_identity: + :param conversation_id: + :param activity_id: + :param activity: + :return: + """ + raise NotImplementedError() + + async def on_delete_activity( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ): + """ + delete_activity() API for Skill. + + Delete an existing activity. + + Some channels allow you to delete an existing activity, and if successful + this method will remove the specified activity. + :param claims_identity: + :param conversation_id: + :param activity_id: + :return: + """ + raise NotImplementedError() + + async def on_get_conversation_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, + ) -> List[ChannelAccount]: + """ + get_conversation_members() API for Skill. + + Enumerate the members of a conversation. + + This REST API takes a ConversationId and returns a list of ChannelAccount + objects representing the members of the conversation. + :param claims_identity: + :param conversation_id: + :return: + """ + raise NotImplementedError() + + async def on_get_conversation_paged_members( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + page_size: int = None, + continuation_token: str = "", + ) -> PagedMembersResult: + """ + get_conversation_paged_members() API for Skill. + + Enumerate the members of a conversation one page at a time. + + This REST API takes a ConversationId. Optionally a pageSize and/or + continuationToken can be provided. It returns a PagedMembersResult, which + contains an array + of ChannelAccounts representing the members of the conversation and a + continuation token that can be used to get more values. + + One page of ChannelAccounts records are returned with each call. The number + of records in a page may vary between channels and calls. The pageSize + parameter can be used as + a suggestion. If there are no additional results the response will not + contain a continuation token. If there are no members in the conversation + the Members will be empty or not present in the response. + + A response to a request that has a continuation token from a prior request + may rarely return members from a previous request. + :param claims_identity: + :param conversation_id: + :param page_size: + :param continuation_token: + :return: + """ + raise NotImplementedError() + + async def on_delete_conversation_member( + self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, + ): + """ + delete_conversation_member() API for Skill. + + Deletes a member from a conversation. + + This REST API takes a ConversationId and a memberId (of type string) and + removes that member from the conversation. If that member was the last + member + of the conversation, the conversation will also be deleted. + :param claims_identity: + :param conversation_id: + :param member_id: + :return: + """ + raise NotImplementedError() + + async def on_get_activity_members( + self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, + ) -> List[ChannelAccount]: + """ + get_activity_members() API for Skill. + + Enumerate the members of an activity. + + This REST API takes a ConversationId and a ActivityId, returning an array + of ChannelAccount objects representing the members of the particular + activity in the conversation. + :param claims_identity: + :param conversation_id: + :param activity_id: + :return: + """ + raise NotImplementedError() + + async def on_upload_attachment( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + attachment_upload: AttachmentData, + ) -> ResourceResponse: + """ + upload_attachment() API for Skill. + + Upload an attachment directly into a channel's blob storage. + + This is useful because it allows you to store data in a compliant store + when dealing with enterprises. + + The response is a ResourceResponse which contains an AttachmentId which is + suitable for using with the attachments API. + :param claims_identity: + :param conversation_id: + :param attachment_upload: + :return: + """ + raise NotImplementedError() + + async def _process_activity( + self, + claims_identity: ClaimsIdentity, + conversation_id: str, + reply_to_activity_id: str, + activity: Activity, + ) -> ResourceResponse: + conversation_reference = await self._conversation_id_factory.get_conversation_reference( + conversation_id + ) + + if not conversation_reference: + raise RuntimeError("ConversationReference not found") + + async def callback(context: TurnContext): + TurnContext.apply_conversation_reference(activity, conversation_reference) + context.activity.id = reply_to_activity_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: + await context.send_activity(activity) + + await self._adapter.continue_conversation( + conversation_reference, callback, claims_identity=claims_identity + ) + return ResourceResponse(id=str(uuid4())) + + @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.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.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/setup.py b/libraries/botbuilder-core/setup.py index 5b667ab06..3ecc37f53 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -36,6 +36,7 @@ "botbuilder.core.adapters", "botbuilder.core.inspection", "botbuilder.core.integration", + "botbuilder.core.skills", ], install_requires=REQUIRES, classifiers=[ diff --git a/libraries/botbuilder-core/tests/test_bot_adapter.py b/libraries/botbuilder-core/tests/test_bot_adapter.py index 30d865956..9edd36c50 100644 --- a/libraries/botbuilder-core/tests/test_bot_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_adapter.py @@ -67,7 +67,7 @@ async def continue_callback(turn_context): # pylint: disable=unused-argument nonlocal callback_invoked callback_invoked = True - await adapter.continue_conversation("MyBot", reference, continue_callback) + await adapter.continue_conversation(reference, continue_callback, "MyBot") self.assertTrue(callback_invoked) async def test_turn_error(self): diff --git a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py index 12738f388..1178db7bc 100644 --- a/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/emulator_validation.py @@ -121,8 +121,8 @@ async def authenticate_emulator_token( AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS, ) - identity = await asyncio.ensure_future( - token_extractor.get_identity_from_auth_header(auth_header, channel_id) + identity = await token_extractor.get_identity_from_auth_header( + auth_header, channel_id ) if not identity: # No valid identity. Not Authorized. diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 180fda6dd..291414507 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -88,7 +88,7 @@ def __init__( self.oauth_scope = ( oauth_scope or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER ) - self.token_cache_key = app_id + "-cache" if app_id else None + self.token_cache_key = app_id + self.oauth_scope + "-cache" if app_id else None self.authentication_context = AuthenticationContext(self.oauth_endpoint) # pylint: disable=arguments-differ diff --git a/samples/01.console-echo/main.py b/samples/01.console-echo/main.py index 73801d1b8..d3e4c0214 100644 --- a/samples/01.console-echo/main.py +++ b/samples/01.console-echo/main.py @@ -17,7 +17,7 @@ # Greet user print("Hi... I'm an echobot. Whatever you say I'll echo back.") - LOOP.run_until_complete(ADAPTER.process_activity(BOT.on_turn)) + LOOP.run_until_complete(ADAPTER._process_activity(BOT.on_turn)) except KeyboardInterrupt: pass finally: diff --git a/samples/13.core-bot/app.py b/samples/13.core-bot/app.py index d09b2d991..3943edc25 100644 --- a/samples/13.core-bot/app.py +++ b/samples/13.core-bot/app.py @@ -64,7 +64,7 @@ def messages(): try: task = LOOP.create_task( - ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + ADAPTER._process_activity(activity, auth_header, BOT.on_turn) ) LOOP.run_until_complete(task) return Response(status=201) diff --git a/samples/16.proactive-messages/app.py b/samples/16.proactive-messages/app.py index b00709eff..62ddb40c9 100644 --- a/samples/16.proactive-messages/app.py +++ b/samples/16.proactive-messages/app.py @@ -110,9 +110,9 @@ def notify(): async def _send_proactive_message(): for conversation_reference in CONVERSATION_REFERENCES.values(): return await ADAPTER.continue_conversation( - APP_ID, conversation_reference, lambda turn_context: turn_context.send_activity("proactive hello"), + APP_ID, ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md new file mode 100644 index 000000000..40e84f525 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/README.md @@ -0,0 +1,30 @@ +# EchoBot + +Bot Framework v4 echo bot sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple bot that accepts input from the user and echoes it back. + +## Running the sample +- Clone the repository +```bash +git clone https://github.com/Microsoft/botbuilder-python.git +``` +- Activate your desired virtual environment +- Bring up a terminal, navigate to `botbuilder-python\samples\02.echo-bot` folder +- In the terminal, type `pip install -r requirements.txt` +- In the terminal, type `python app.py` + +## Testing the bot using Bot Framework Emulator +[Microsoft Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel. + +- Install the Bot Framework emulator from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to bot using Bot Framework Emulator +- Launch Bot Framework Emulator +- Paste this URL in the emulator window - http://localhost:3978/api/messages + +## Further reading + +- [Bot Framework Documentation](https://docs.botframework.com) +- [Bot Basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py new file mode 100644 index 000000000..cfa375aac --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/app.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import EchoBot +from config import DefaultConfig + +CONFIG = DefaultConfig() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = BotFrameworkAdapter(SETTINGS) + + +# Catch-all for errors. +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + await context.send_activity( + "To continue to run this bot, please fix the bot source code." + ) + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == "emulator": + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.utcnow(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await context.send_activity(trace_activity) + + +ADAPTER.on_turn_error = on_error + +# Create the Bot +BOT = EchoBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py new file mode 100644 index 000000000..f95fbbbad --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py new file mode 100644 index 000000000..91c3febb0 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/bots/echo_bot.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes + + +class EchoBot(ActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + if "end" in turn_context.activity.text or "exit" in turn_context.activity.text: + # Send End of conversation at the end. + await turn_context.send_activity( + MessageFactory.text("Ending conversation from the skill...") + ) + + end_of_conversation = Activity(type=ActivityTypes.end_of_conversation) + end_of_conversation.code = EndOfConversationCodes.completed_successfully + await turn_context.send_activity(end_of_conversation) + else: + await turn_context.send_activity( + MessageFactory.text(f"Echo: {turn_context.activity.text}") + ) + await turn_context.send_activity( + MessageFactory.text( + f'Say "end" or "exit" and I\'ll end the conversation and back to the parent.' + ) + ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py new file mode 100644 index 000000000..e007d0fa9 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/config.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3978 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt new file mode 100644 index 000000000..7e54b62ec --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-child-bot/requirements.txt @@ -0,0 +1,2 @@ +botbuilder-core>=4.4.0b1 +flask>=1.0.3 diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py new file mode 100644 index 000000000..4111f6816 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + TurnContext, + BotFrameworkAdapter, +) +from botbuilder.core.integration import ( + BotFrameworkHttpClient, + aiohttp_channel_service_routes, +) +from botbuilder.core.skills import SkillConversationIdFactory, SkillHandler +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ( + AuthenticationConfiguration, + SimpleCredentialProvider, +) + +from bots import RootBot +from config import DefaultConfig, SkillConfiguration + +CONFIG = DefaultConfig() +SKILL_CONFIG = SkillConfiguration() + +CREDENTIAL_PROVIDER = SimpleCredentialProvider(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +CLIENT = BotFrameworkHttpClient(CREDENTIAL_PROVIDER) + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(CONFIG.APP_ID, CONFIG.APP_PASSWORD) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +STORAGE = MemoryStorage() + +CONVERSATION_STATE = ConversationState(STORAGE) +ID_FACTORY = SkillConversationIdFactory(STORAGE) + + +# Catch-all for errors. +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + await context.send_activity( + "To continue to run this bot, please fix the bot source code." + ) + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == "emulator": + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.utcnow(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await context.send_activity(trace_activity) + + +ADAPTER.on_turn_error = on_error + +# Create the Bot +BOT = RootBot(CONVERSATION_STATE, SKILL_CONFIG, ID_FACTORY, CLIENT, CONFIG) + +SKILL_HANDLER = SkillHandler( + ADAPTER, BOT, ID_FACTORY, CREDENTIAL_PROVIDER, AuthenticationConfiguration() +) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + # Main bot message handler. + if "application/json" in req.headers["Content-Type"]: + body = await req.json() + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" + + try: + await ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + return Response(status=201) + except Exception as exception: + raise exception + + +APP = web.Application() +APP.router.add_post("/api/messages", messages) +APP.router.add_routes(aiohttp_channel_service_routes(SKILL_HANDLER, "/api/skills")) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py new file mode 100644 index 000000000..be7e157a7 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/__init__.py @@ -0,0 +1,4 @@ +from .root_bot import RootBot + + +__all__ = ["RootBot"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py new file mode 100644 index 000000000..6ce16672c --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/bots/root_bot.py @@ -0,0 +1,108 @@ +from typing import List + +from botbuilder.core import ( + ActivityHandler, + ConversationState, + MessageFactory, + TurnContext, +) +from botbuilder.core.integration import BotFrameworkHttpClient +from botbuilder.core.skills import SkillConversationIdFactory + +from botbuilder.schema import ActivityTypes, ChannelAccount + +from config import DefaultConfig, SkillConfiguration + + +class RootBot(ActivityHandler): + def __init__( + self, + conversation_state: ConversationState, + skills_config: SkillConfiguration, + conversation_id_factory: SkillConversationIdFactory, + skill_client: BotFrameworkHttpClient, + config: DefaultConfig, + ): + self._conversation_id_factory = conversation_id_factory + self._bot_id = config.APP_ID + self._skill_client = skill_client + self._skills_config = skills_config + self._conversation_state = conversation_state + self._active_skill_property = conversation_state.create_property( + "activeSkillProperty" + ) + + async def on_turn(self, turn_context: TurnContext): + if turn_context.activity.type == ActivityTypes.end_of_conversation: + # Handle end of conversation back from the skill + # forget skill invocation + await self._active_skill_property.delete(turn_context) + await self._conversation_state.save_changes(turn_context, force=True) + + # We are back + await turn_context.send_activity( + MessageFactory.text( + 'Back in the root bot. Say "skill" and I\'ll patch you through' + ) + ) + else: + await super().on_turn(turn_context) + + async def on_message_activity(self, turn_context: TurnContext): + # If there is an active skill + active_skill_id: str = await self._active_skill_property.get(turn_context) + skill_conversation_id = await self._conversation_id_factory.create_skill_conversation_id( + TurnContext.get_conversation_reference(turn_context.activity) + ) + + if active_skill_id: + # NOTE: Always SaveChanges() before calling a skill so that any activity generated by the skill + # will have access to current accurate state. + await self._conversation_state.save_changes(turn_context, force=True) + + # route activity to the skill + await self._skill_client.post_activity( + self._bot_id, + self._skills_config.SKILLS[active_skill_id].app_id, + self._skills_config.SKILLS[active_skill_id].skill_endpoint, + self._skills_config.SKILL_HOST_ENDPOINT, + skill_conversation_id, + turn_context.activity, + ) + else: + if "skill" in turn_context.activity.text: + await turn_context.send_activity( + MessageFactory.text("Got it, connecting you to the skill...") + ) + + # save ConversationReferene for skill + await self._active_skill_property.set(turn_context, "SkillBot") + + # NOTE: Always SaveChanges() before calling a skill so that any activity generated by the + # skill will have access to current accurate state. + await self._conversation_state.save_changes(turn_context, force=True) + + await self._skill_client.post_activity( + self._bot_id, + self._skills_config.SKILLS["SkillBot"].app_id, + self._skills_config.SKILLS["SkillBot"].skill_endpoint, + self._skills_config.SKILL_HOST_ENDPOINT, + skill_conversation_id, + turn_context.activity, + ) + else: + # just respond + await turn_context.send_activity( + MessageFactory.text( + "Me no nothin'. Say \"skill\" and I'll patch you through" + ) + ) + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity( + MessageFactory.text("Hello and welcome!") + ) diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py new file mode 100644 index 000000000..f2a9e1f6e --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/config.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from typing import Dict +from botbuilder.core.skills import BotFrameworkSkill + +""" Bot Configuration """ + + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3428 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + SKILL_HOST_ENDPOINT = "http://localhost:3428/api/skills" + SKILLS = [ + { + "id": "SkillBot", + "app_id": "", + "skill_endpoint": "http://localhost:3978/api/messages", + }, + ] + + +class SkillConfiguration: + SKILL_HOST_ENDPOINT = DefaultConfig.SKILL_HOST_ENDPOINT + SKILLS: Dict[str, BotFrameworkSkill] = { + skill["id"]: BotFrameworkSkill(**skill) for skill in DefaultConfig.SKILLS + } diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py new file mode 100644 index 000000000..c23b52ce2 --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/__init__.py @@ -0,0 +1,4 @@ +from .dummy_middleware import DummyMiddleware + + +__all__ = ["DummyMiddleware"] diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py new file mode 100644 index 000000000..4d38fe79f --- /dev/null +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/middleware/dummy_middleware.py @@ -0,0 +1,32 @@ +from typing import Awaitable, Callable, List + +from botbuilder.core import Middleware, TurnContext +from botbuilder.schema import Activity, ResourceResponse + + +class DummyMiddleware(Middleware): + def __init__(self, label: str): + self._label = label + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + message = f"{self._label} {context.activity.type} {context.activity.text}" + print(message) + + # Register outgoing handler + context.on_send_activities(self._outgoing_handler) + + await logic() + + async def _outgoing_handler( + self, + context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + logic: Callable[[TurnContext], Awaitable[List[ResourceResponse]]], + ): + for activity in activities: + message = f"{self._label} {activity.type} {activity.text}" + print(message) + + return await logic() From 43a19a386bdd5e53d35efd9c6704e7d4ff2782f6 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Dec 2019 18:27:38 -0800 Subject: [PATCH 2/9] pylint: Skill layer working, oauth prompt and testing pending --- .../botbuilder-core/botbuilder/core/skills/skill_handler.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 81d7d55be..7527e9089 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -11,13 +11,11 @@ ActivityTypes, AttachmentData, ChannelAccount, - ConversationAccount, ConversationParameters, ConversationResourceResponse, ConversationsResult, PagedMembersResult, ResourceResponse, - RoleTypes, Transcript, ) from botframework.connector.auth import ( From 12b92800b6c488a9074760fc0a36f67320acb9ac Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Dec 2019 22:06:47 -0800 Subject: [PATCH 3/9] Updating minor skills PRs to match C# --- .../integration/bot_framework_http_client.py | 3 + .../integration/channel_service_handler.py | 24 +- .../botbuilder/core/skills/skill_handler.py | 261 ++---------------- 3 files changed, 33 insertions(+), 255 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py index 52a13230b..583282cfe 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py @@ -66,10 +66,12 @@ async def post_activity( # TODO: DO we need to set the activity ID? (events that are created manually don't have it). original_conversation_id = activity.conversation.id original_service_url = activity.service_url + original_caller_id = activity.caller_id try: activity.conversation.id = conversation_id activity.service_url = service_url + activity.caller_id = from_bot_id headers_dict = { "Content-type": "application/json; charset=utf-8", @@ -94,6 +96,7 @@ async def post_activity( # Restore activity properties. activity.conversation.id = original_conversation_id activity.service_url = original_service_url + activity.caller_id = original_caller_id async def _get_app_credentials( self, app_id: str, oauth_scope: str diff --git a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py index 4b9222de7..27a90f463 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py @@ -159,7 +159,7 @@ async def on_get_conversations( :param continuation_token: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_create_conversation( self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, @@ -193,7 +193,7 @@ async def on_create_conversation( :param parameters: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_send_to_conversation( self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, @@ -220,7 +220,7 @@ async def on_send_to_conversation( :param activity: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_send_conversation_history( self, @@ -244,7 +244,7 @@ async def on_send_conversation_history( :param transcript: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_update_activity( self, @@ -270,7 +270,7 @@ async def on_update_activity( :param activity: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_reply_to_activity( self, @@ -302,7 +302,7 @@ async def on_reply_to_activity( :param activity: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_delete_activity( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, @@ -320,7 +320,7 @@ async def on_delete_activity( :param activity_id: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_get_conversation_members( self, claims_identity: ClaimsIdentity, conversation_id: str, @@ -337,7 +337,7 @@ async def on_get_conversation_members( :param conversation_id: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_get_conversation_paged_members( self, @@ -373,7 +373,7 @@ async def on_get_conversation_paged_members( :param continuation_token: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_delete_conversation_member( self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, @@ -393,7 +393,7 @@ async def on_delete_conversation_member( :param member_id: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_get_activity_members( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, @@ -412,7 +412,7 @@ async def on_get_activity_members( :param activity_id: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def on_upload_attachment( self, @@ -436,7 +436,7 @@ async def on_upload_attachment( :param attachment_upload: :return: """ - raise NotImplementedError() + raise Exception("Not implemented") async def _authenticate(self, auth_header: str) -> ClaimsIdentity: if not auth_header: diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index 7527e9089..a9154db05 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -2,21 +2,14 @@ # Licensed under the MIT License. from uuid import uuid4 -from typing import List from botbuilder.core.integration import ChannelServiceHandler from botbuilder.core import Bot, BotAdapter, TurnContext from botbuilder.schema import ( Activity, ActivityTypes, - AttachmentData, - ChannelAccount, - ConversationParameters, - ConversationResourceResponse, - ConversationsResult, - PagedMembersResult, + ConversationReference, ResourceResponse, - Transcript, ) from botframework.connector.auth import ( AuthenticationConfiguration, @@ -29,6 +22,11 @@ class SkillHandler(ChannelServiceHandler): + + SKILL_CONVERSATION_REFERENCE_KEY = ( + "botbuilder.core.skills.SkillConversationReference" + ) + def __init__( self, adapter: BotAdapter, @@ -52,7 +50,6 @@ def __init__( self._bot = bot self._conversation_id_factory = conversation_id_factory self._logger = logger - self.skill_conversation_reference_key = "SkillConversationReference" async def on_send_to_conversation( self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, @@ -115,240 +112,6 @@ async def on_reply_to_activity( claims_identity, conversation_id, activity_id, activity, ) - async def on_get_conversations( - self, claims_identity: ClaimsIdentity, continuation_token: str = "", - ) -> ConversationsResult: - """ - get_conversations() API for Skill - - List the Conversations in which this bot has participated. - - GET from this method with a skip token - - The return value is a ConversationsResult, which contains an array of - ConversationMembers and a skip token. If the skip token is not empty, then - there are further values to be returned. Call this method again with the - returned token to get more values. - - Each ConversationMembers object contains the ID of the conversation and an - array of ChannelAccounts that describe the members of the conversation. - :param claims_identity: - :param conversation_id: - :param continuation_token: - :return: - """ - raise NotImplementedError() - - async def on_create_conversation( - self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, - ) -> ConversationResourceResponse: - """ - create_conversation() API for Skill - - Create a new Conversation. - - POST to this method with a - * Bot being the bot creating the conversation - * IsGroup set to true if this is not a direct message (default is false) - * Array containing the members to include in the conversation - - The return value is a ResourceResponse which contains a conversation id - which is suitable for use - in the message payload and REST API uris. - - Most channels only support the semantics of bots initiating a direct - message conversation. An example of how to do that would be: - - var resource = await connector.conversations.CreateConversation(new - ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { new - ChannelAccount("user1") } ); - await connect.Conversations.SendToConversationAsync(resource.Id, new - Activity() ... ) ; - - end. - :param claims_identity: - :param conversation_id: - :param parameters: - :return: - """ - raise NotImplementedError() - - async def on_send_conversation_history( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - transcript: Transcript, - ) -> ResourceResponse: - """ - send_conversation_history() API for Skill. - - This method allows you to upload the historic activities to the - conversation. - - Sender must ensure that the historic activities have unique ids and - appropriate timestamps. The ids are used by the client to deal with - duplicate activities and the timestamps are used by the client to render - the activities in the right order. - :param claims_identity: - :param conversation_id: - :param transcript: - :return: - """ - raise NotImplementedError() - - async def on_update_activity( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - activity_id: str, - activity: Activity, - ) -> ResourceResponse: - """ - update_activity() API for Skill. - - Edit an existing activity. - - Some channels allow you to edit an existing activity to reflect the new - state of a bot conversation. - - For example, you can remove buttons after someone has clicked "Approve" - button. - :param claims_identity: - :param conversation_id: - :param activity_id: - :param activity: - :return: - """ - raise NotImplementedError() - - async def on_delete_activity( - self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, - ): - """ - delete_activity() API for Skill. - - Delete an existing activity. - - Some channels allow you to delete an existing activity, and if successful - this method will remove the specified activity. - :param claims_identity: - :param conversation_id: - :param activity_id: - :return: - """ - raise NotImplementedError() - - async def on_get_conversation_members( - self, claims_identity: ClaimsIdentity, conversation_id: str, - ) -> List[ChannelAccount]: - """ - get_conversation_members() API for Skill. - - Enumerate the members of a conversation. - - This REST API takes a ConversationId and returns a list of ChannelAccount - objects representing the members of the conversation. - :param claims_identity: - :param conversation_id: - :return: - """ - raise NotImplementedError() - - async def on_get_conversation_paged_members( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - page_size: int = None, - continuation_token: str = "", - ) -> PagedMembersResult: - """ - get_conversation_paged_members() API for Skill. - - Enumerate the members of a conversation one page at a time. - - This REST API takes a ConversationId. Optionally a pageSize and/or - continuationToken can be provided. It returns a PagedMembersResult, which - contains an array - of ChannelAccounts representing the members of the conversation and a - continuation token that can be used to get more values. - - One page of ChannelAccounts records are returned with each call. The number - of records in a page may vary between channels and calls. The pageSize - parameter can be used as - a suggestion. If there are no additional results the response will not - contain a continuation token. If there are no members in the conversation - the Members will be empty or not present in the response. - - A response to a request that has a continuation token from a prior request - may rarely return members from a previous request. - :param claims_identity: - :param conversation_id: - :param page_size: - :param continuation_token: - :return: - """ - raise NotImplementedError() - - async def on_delete_conversation_member( - self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, - ): - """ - delete_conversation_member() API for Skill. - - Deletes a member from a conversation. - - This REST API takes a ConversationId and a memberId (of type string) and - removes that member from the conversation. If that member was the last - member - of the conversation, the conversation will also be deleted. - :param claims_identity: - :param conversation_id: - :param member_id: - :return: - """ - raise NotImplementedError() - - async def on_get_activity_members( - self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, - ) -> List[ChannelAccount]: - """ - get_activity_members() API for Skill. - - Enumerate the members of an activity. - - This REST API takes a ConversationId and a ActivityId, returning an array - of ChannelAccount objects representing the members of the particular - activity in the conversation. - :param claims_identity: - :param conversation_id: - :param activity_id: - :return: - """ - raise NotImplementedError() - - async def on_upload_attachment( - self, - claims_identity: ClaimsIdentity, - conversation_id: str, - attachment_upload: AttachmentData, - ) -> ResourceResponse: - """ - upload_attachment() API for Skill. - - Upload an attachment directly into a channel's blob storage. - - This is useful because it allows you to store data in a compliant store - when dealing with enterprises. - - The response is a ResourceResponse which contains an AttachmentId which is - suitable for using with the attachments API. - :param claims_identity: - :param conversation_id: - :param attachment_upload: - :return: - """ - raise NotImplementedError() - async def _process_activity( self, claims_identity: ClaimsIdentity, @@ -363,7 +126,19 @@ async def _process_activity( if not conversation_reference: raise RuntimeError("ConversationReference not found") + skill_conversation_reference = ConversationReference( + activity_id=activity.id, + user=activity.from_property, + bot=activity.recipient, + conversation=activity.conversation, + channel_id=activity.channel_id, + service_url=activity.service_url, + ) + async def callback(context: TurnContext): + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = skill_conversation_reference TurnContext.apply_conversation_reference(activity, conversation_reference) context.activity.id = reply_to_activity_id From 45b3e593b303c8130068350e52f6fdee5d51fb7a Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Dec 2019 22:14:15 -0800 Subject: [PATCH 4/9] Removing accidental changes in samples 1. and 13. --- samples/01.console-echo/main.py | 2 +- samples/13.core-bot/app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/01.console-echo/main.py b/samples/01.console-echo/main.py index d3e4c0214..73801d1b8 100644 --- a/samples/01.console-echo/main.py +++ b/samples/01.console-echo/main.py @@ -17,7 +17,7 @@ # Greet user print("Hi... I'm an echobot. Whatever you say I'll echo back.") - LOOP.run_until_complete(ADAPTER._process_activity(BOT.on_turn)) + LOOP.run_until_complete(ADAPTER.process_activity(BOT.on_turn)) except KeyboardInterrupt: pass finally: diff --git a/samples/13.core-bot/app.py b/samples/13.core-bot/app.py index 3943edc25..d09b2d991 100644 --- a/samples/13.core-bot/app.py +++ b/samples/13.core-bot/app.py @@ -64,7 +64,7 @@ def messages(): try: task = LOOP.create_task( - ADAPTER._process_activity(activity, auth_header, BOT.on_turn) + ADAPTER.process_activity(activity, auth_header, BOT.on_turn) ) LOOP.run_until_complete(task) return Response(status=201) From 7bf33c4202f1ac08697df4aa7397215020650d8e Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 11 Dec 2019 11:04:12 -0800 Subject: [PATCH 5/9] Adding custom exception for channel service handler --- .../botbuilder/core/integration/__init__.py | 3 +- .../integration/channel_service_handler.py | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py index 3a579402b..09c4847f8 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py @@ -7,10 +7,11 @@ from .aiohttp_channel_service import aiohttp_channel_service_routes from .bot_framework_http_client import BotFrameworkHttpClient -from .channel_service_handler import ChannelServiceHandler +from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler __all__ = [ "aiohttp_channel_service_routes", "BotFrameworkHttpClient", + "BotActionNotImplementedError", "ChannelServiceHandler", ] diff --git a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py index 27a90f463..9d9fce6df 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/channel_service_handler.py @@ -24,6 +24,10 @@ ) +class BotActionNotImplementedError(Exception): + """Raised when an action is not implemented""" + + class ChannelServiceHandler: """ Initializes a new instance of the class, @@ -159,7 +163,7 @@ async def on_get_conversations( :param continuation_token: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_create_conversation( self, claims_identity: ClaimsIdentity, parameters: ConversationParameters, @@ -193,7 +197,7 @@ async def on_create_conversation( :param parameters: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_send_to_conversation( self, claims_identity: ClaimsIdentity, conversation_id: str, activity: Activity, @@ -220,7 +224,7 @@ async def on_send_to_conversation( :param activity: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_send_conversation_history( self, @@ -244,7 +248,7 @@ async def on_send_conversation_history( :param transcript: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_update_activity( self, @@ -270,7 +274,7 @@ async def on_update_activity( :param activity: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_reply_to_activity( self, @@ -302,7 +306,7 @@ async def on_reply_to_activity( :param activity: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_delete_activity( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, @@ -320,7 +324,7 @@ async def on_delete_activity( :param activity_id: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_get_conversation_members( self, claims_identity: ClaimsIdentity, conversation_id: str, @@ -337,7 +341,7 @@ async def on_get_conversation_members( :param conversation_id: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_get_conversation_paged_members( self, @@ -373,7 +377,7 @@ async def on_get_conversation_paged_members( :param continuation_token: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_delete_conversation_member( self, claims_identity: ClaimsIdentity, conversation_id: str, member_id: str, @@ -393,7 +397,7 @@ async def on_delete_conversation_member( :param member_id: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_get_activity_members( self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str, @@ -412,7 +416,7 @@ async def on_get_activity_members( :param activity_id: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def on_upload_attachment( self, @@ -436,7 +440,7 @@ async def on_upload_attachment( :param attachment_upload: :return: """ - raise Exception("Not implemented") + raise BotActionNotImplementedError() async def _authenticate(self, auth_header: str) -> ClaimsIdentity: if not auth_header: From 8477fca45bf8c3da3b0819ab26b746dbbdec534e Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 11 Dec 2019 13:21:17 -0800 Subject: [PATCH 6/9] Skills error handler --- .../botbuilder/core/integration/__init__.py | 2 ++ .../integration/aiohttp_channel_service.py | 3 ++- ...tp_channel_service_exception_middleware.py | 24 +++++++++++++++++++ .../integration/bot_framework_http_client.py | 2 +- .../botbuilder/core/skills/skill_handler.py | 2 +- .../simple-bot-to-bot/simple-root-bot/app.py | 3 ++- 6 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py diff --git a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py index 09c4847f8..a971ce6f6 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/__init__.py @@ -8,10 +8,12 @@ from .aiohttp_channel_service import aiohttp_channel_service_routes from .bot_framework_http_client import BotFrameworkHttpClient from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler +from .aiohttp_channel_service_exception_middleware import aiohttp_error_middleware __all__ = [ "aiohttp_channel_service_routes", "BotFrameworkHttpClient", "BotActionNotImplementedError", "ChannelServiceHandler", + "aiohttp_error_middleware", ] diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py index d61c0f0eb..6ca3bbde1 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -4,7 +4,6 @@ from typing import List, Union, Type from aiohttp.web import RouteTableDef, Request, Response -from msrest.serialization import Model from botbuilder.schema import ( Activity, AttachmentData, @@ -12,6 +11,8 @@ Transcript, ) +from msrest.serialization import Model + from .channel_service_handler import ChannelServiceHandler diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py new file mode 100644 index 000000000..47c93ab9b --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py @@ -0,0 +1,24 @@ +from aiohttp.web import ( + middleware, + HTTPNotImplemented, + HTTPUnauthorized, + HTTPNotFound, + HTTPInternalServerError, +) + +from .channel_service_handler import BotActionNotImplementedError + + +@middleware +async def error_middleware(request, handler): + try: + response = await handler(request) + return response + except BotActionNotImplementedError: + raise HTTPNotImplemented() + except PermissionError: + raise HTTPUnauthorized() + except KeyError: + raise HTTPNotFound() + except Exception: + raise HTTPInternalServerError() diff --git a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py index 583282cfe..81bd20139 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/bot_framework_http_client.py @@ -53,7 +53,7 @@ async def post_activity( app_credentials = await self._get_app_credentials(from_bot_id, to_bot_id) if not app_credentials: - raise RuntimeError("Unable to get appCredentials to connect to the skill") + raise KeyError("Unable to get appCredentials to connect to the skill") # Get token for the skill call token = ( diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py index a9154db05..05ec99bb0 100644 --- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py @@ -124,7 +124,7 @@ async def _process_activity( ) if not conversation_reference: - raise RuntimeError("ConversationReference not found") + raise KeyError("ConversationReference not found") skill_conversation_reference = ConversationReference( activity_id=activity.id, diff --git a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py index 4111f6816..baba86ac1 100644 --- a/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py +++ b/samples/experimental/skills-prototypes/simple-bot-to-bot/simple-root-bot/app.py @@ -17,6 +17,7 @@ from botbuilder.core.integration import ( BotFrameworkHttpClient, aiohttp_channel_service_routes, + aiohttp_error_middleware, ) from botbuilder.core.skills import SkillConversationIdFactory, SkillHandler from botbuilder.schema import Activity, ActivityTypes @@ -101,7 +102,7 @@ async def messages(req: Request) -> Response: raise exception -APP = web.Application() +APP = web.Application(middlewares=[aiohttp_error_middleware]) APP.router.add_post("/api/messages", messages) APP.router.add_routes(aiohttp_channel_service_routes(SKILL_HANDLER, "/api/skills")) From e438f84738e467a8c47b4595fd34d2aa949ee24f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 11 Dec 2019 13:27:06 -0800 Subject: [PATCH 7/9] Skills error handler --- .../integration/aiohttp_channel_service_exception_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py index 47c93ab9b..7b2949894 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py @@ -10,7 +10,7 @@ @middleware -async def error_middleware(request, handler): +async def aiohttp_error_middleware(request, handler): try: response = await handler(request) return response From e0f6303cdc6de0f7c6fc8c96980ae999512a5572 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 11 Dec 2019 13:54:30 -0800 Subject: [PATCH 8/9] pylint: Solved conflicts w/master --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 4f7803931..a134068ff 100644 --- a/.pylintrc +++ b/.pylintrc @@ -7,7 +7,7 @@ extension-pkg-whitelist= # Add files or directories to the blacklist. They should be base names, not # paths. -ignore=CVS,build,botbuilder-schema,samples,django_tests,Generator,operations,operations_async +ignore=CVS,build,botbuilder-schema,samples,django_tests,Generator,operations,operations_async,schema # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. From e40bcb5356a4432d0f0140eec489dc6f863775ed Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 11 Dec 2019 14:02:46 -0800 Subject: [PATCH 9/9] pylint: Solved conflicts w/master --- .../botbuilder/core/integration/aiohttp_channel_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py index 6ca3bbde1..9c7284ad3 100644 --- a/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py +++ b/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service.py @@ -4,6 +4,8 @@ from typing import List, Union, Type from aiohttp.web import RouteTableDef, Request, Response +from msrest.serialization import Model + from botbuilder.schema import ( Activity, AttachmentData, @@ -11,8 +13,6 @@ Transcript, ) -from msrest.serialization import Model - from .channel_service_handler import ChannelServiceHandler