diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
index 5a38be990..a7727956d 100644
--- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
+++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
@@ -12,8 +12,8 @@
ConversationAccount,
ConversationParameters,
ConversationReference,
- ResourceResponse,
TokenResponse,
+ ResourceResponse,
)
from botframework.connector import Channels, EmulatorApiClient
from botframework.connector.aio import ConnectorClient
diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py
new file mode 100644
index 000000000..6683b49a0
--- /dev/null
+++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py
@@ -0,0 +1,10 @@
+# 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 .teams_activity_handler import TeamsActivityHandler
+
+__all__ = ["TeamsActivityHandler"]
diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py
new file mode 100644
index 000000000..04d7389aa
--- /dev/null
+++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_activity_handler.py
@@ -0,0 +1,396 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from http import HTTPStatus
+from botbuilder.schema import Activity, ActivityTypes, ChannelAccount
+from botbuilder.core.turn_context import TurnContext
+from botbuilder.core import ActivityHandler, InvokeResponse, BotFrameworkAdapter
+from botbuilder.schema.teams import (
+ TeamInfo,
+ ChannelInfo,
+ TeamsChannelData,
+ TeamsChannelAccount,
+)
+from botframework.connector import Channels
+
+
+class TeamsActivityHandler(ActivityHandler):
+ async def on_turn(self, turn_context: TurnContext):
+ if turn_context is None:
+ raise TypeError("ActivityHandler.on_turn(): turn_context cannot be None.")
+
+ if not getattr(turn_context, "activity", None):
+ raise TypeError(
+ "ActivityHandler.on_turn(): turn_context must have a non-None activity."
+ )
+
+ if not getattr(turn_context.activity, "type", None):
+ raise TypeError(
+ "ActivityHandler.on_turn(): turn_context activity must have a non-None type."
+ )
+
+ if turn_context.activity.type == ActivityTypes.invoke:
+ invoke_response = await self.on_invoke_activity(turn_context)
+ if invoke_response and not turn_context.turn_state.get(
+ BotFrameworkAdapter._INVOKE_RESPONSE_KEY # pylint: disable=protected-access
+ ):
+ await turn_context.send_activity(
+ Activity(value=invoke_response, type=ActivityTypes.invoke_response)
+ )
+ return
+
+ await super().on_turn(turn_context)
+
+ async def on_invoke_activity(self, turn_context: TurnContext):
+ try:
+ if (
+ not turn_context.activity.name
+ and turn_context.activity.channel_id == Channels.ms_teams
+ ):
+ return await self.on_teams_card_action_invoke_activity(turn_context)
+
+ if turn_context.activity.name == "signin/verifyState":
+ await self.on_teams_signin_verify_state_activity(turn_context)
+ return self._create_invoke_response()
+
+ if turn_context.activity.name == "fileConsent/invoke":
+ return await self.on_teams_file_consent_activity(
+ turn_context, turn_context.activity.value
+ )
+
+ if turn_context.activity.name == "actionableMessage/executeAction":
+ await self.on_teams_o365_connector_card_action_activity(
+ turn_context, turn_context.activity.value
+ )
+ return self._create_invoke_response()
+
+ if turn_context.activity.name == "composeExtension/queryLink":
+ return self._create_invoke_response(
+ await self.on_teams_app_based_link_query_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "composeExtension/query":
+ return self._create_invoke_response(
+ await self.on_teams_messaging_extension_query_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "composeExtension/selectItem":
+ return self._create_invoke_response(
+ await self.on_teams_messaging_extension_select_item_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "composeExtension/submitAction":
+ return self._create_invoke_response(
+ await self.on_teams_messaging_extension_submit_action_dispatch_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "composeExtension/fetchTask":
+ return self._create_invoke_response(
+ await self.on_teams_messaging_extension_fetch_task_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "composeExtension/querySettingUrl":
+ return self._create_invoke_response(
+ await self.on_teams_messaging_extension_configuration_query_settings_url_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "composeExtension/setting":
+ await self.on_teams_messaging_extension_configuration_setting_activity(
+ turn_context, turn_context.activity.value
+ )
+ return self._create_invoke_response()
+
+ if turn_context.activity.name == "composeExtension/onCardButtonClicked":
+ await self.on_teams_messaging_extension_card_button_clicked_activity(
+ turn_context, turn_context.activity.value
+ )
+ return self._create_invoke_response()
+
+ if turn_context.activity.name == "task/fetch":
+ return self._create_invoke_response(
+ await self.on_teams_task_module_fetch_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ if turn_context.activity.name == "task/submit":
+ return self._create_invoke_response(
+ await self.on_teams_task_module_submit_activity(
+ turn_context, turn_context.activity.value
+ )
+ )
+
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+ except _InvokeResponseException as err:
+ return err.create_invoke_response()
+
+ async def on_teams_card_action_invoke_activity(self, turn_context: TurnContext):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_signin_verify_state_activity(self, turn_context: TurnContext):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_file_consent_activity(
+ self, turn_context: TurnContext, file_consent_card_response
+ ):
+ if file_consent_card_response.action == "accept":
+ await self.on_teams_file_consent_accept_activity(
+ turn_context, file_consent_card_response
+ )
+ return self._create_invoke_response()
+
+ if file_consent_card_response.action == "decline":
+ await self.on_teams_file_consent_decline_activity(
+ turn_context, file_consent_card_response
+ )
+ return self._create_invoke_response()
+
+ raise _InvokeResponseException(
+ HTTPStatus.BAD_REQUEST,
+ f"{file_consent_card_response.action} is not a supported Action.",
+ )
+
+ async def on_teams_file_consent_accept_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, file_consent_card_response
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_file_consent_decline_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, file_consent_card_response
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_o365_connector_card_action_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, query
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_app_based_link_query_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, query
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_query_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, query
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_select_item_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, query
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_submit_action_dispatch_activity(
+ self, turn_context: TurnContext, action
+ ):
+ if not action:
+ return await self.on_teams_messaging_extension_submit_action_activity(
+ turn_context, action
+ )
+
+ if action.bot_message_preview_action == "edit":
+ return await self.on_teams_messaging_extension_bot_message_preview_edit_activity(
+ turn_context, action
+ )
+
+ if action.bot_message_preview_action == "send":
+ return await self.on_teams_messaging_extension_bot_message_send_activity(
+ turn_context, action
+ )
+
+ raise _InvokeResponseException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ body=f"{action.bot_message_preview_action} is not a supported BotMessagePreviewAction",
+ )
+
+ async def on_teams_messaging_extension_bot_message_preview_edit_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, action
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_bot_message_send_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, action
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_submit_action_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, action
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_fetch_task_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, task_module_request
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_configuration_query_settings_url_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, query
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_configuration_setting_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, settings
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_messaging_extension_card_button_clicked_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, card_data
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_task_module_fetch_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, task_module_request
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_teams_task_module_submit_activity( # pylint: disable=unused-argument
+ self, turn_context: TurnContext, task_module_request
+ ):
+ raise _InvokeResponseException(status_code=HTTPStatus.NOT_IMPLEMENTED)
+
+ async def on_conversation_update_activity(self, turn_context: TurnContext):
+ if turn_context.activity.channel_id == Channels.ms_teams:
+ channel_data = TeamsChannelData(**turn_context.activity.channel_data)
+
+ if turn_context.activity.members_added:
+ return await self.on_teams_members_added_dispatch_activity(
+ turn_context.activity.members_added, channel_data.team, turn_context
+ )
+
+ if turn_context.activity.members_removed:
+ return await self.on_teams_members_removed_dispatch_activity(
+ turn_context.activity.members_removed,
+ channel_data.team,
+ turn_context,
+ )
+
+ if channel_data:
+ if channel_data.event_type == "channelCreated":
+ return await self.on_teams_channel_created_activity(
+ channel_data.channel, channel_data.team, turn_context
+ )
+ if channel_data.event_type == "channelDeleted":
+ return await self.on_teams_channel_deleted_activity(
+ channel_data.channel, channel_data.team, turn_context
+ )
+ if channel_data.event_type == "channelRenamed":
+ return await self.on_teams_channel_renamed_activity(
+ channel_data.channel, channel_data.team, turn_context
+ )
+ if channel_data.event_type == "teamRenamed":
+ return await self.on_teams_team_renamed_activity(
+ channel_data.team, turn_context
+ )
+ return await super().on_conversation_update_activity(turn_context)
+
+ return await super().on_conversation_update_activity(turn_context)
+
+ async def on_teams_channel_created_activity( # pylint: disable=unused-argument
+ self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return
+
+ async def on_teams_team_renamed_activity( # pylint: disable=unused-argument
+ self, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return
+
+ async def on_teams_members_added_dispatch_activity( # pylint: disable=unused-argument
+ self,
+ members_added: [ChannelAccount],
+ team_info: TeamInfo,
+ turn_context: TurnContext,
+ ):
+ """
+ team_members = {}
+ team_members_added = []
+ for member in members_added:
+ if member.additional_properties != {}:
+ team_members_added.append(TeamsChannelAccount(member))
+ else:
+ if team_members == {}:
+ result = await TeamsInfo.get_members_async(turn_context)
+ team_members = { i.id : i for i in result }
+
+ if member.id in team_members:
+ team_members_added.append(member)
+ else:
+ newTeamsChannelAccount = TeamsChannelAccount(
+ id=member.id,
+ name = member.name,
+ aad_object_id = member.aad_object_id,
+ role = member.role
+ )
+ team_members_added.append(newTeamsChannelAccount)
+
+ return await self.on_teams_members_added_activity(teams_members_added, team_info, turn_context)
+ """
+ for member in members_added:
+ new_account_json = member.seralize()
+ del new_account_json["additional_properties"]
+ member = TeamsChannelAccount(**new_account_json)
+ return await self.on_teams_members_added_activity(members_added, turn_context)
+
+ async def on_teams_members_added_activity(
+ self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext
+ ):
+ teams_members_added = [ChannelAccount(member) for member in teams_members_added]
+ return super().on_members_added_activity(teams_members_added, turn_context)
+
+ async def on_teams_members_removed_dispatch_activity( # pylint: disable=unused-argument
+ self,
+ members_removed: [ChannelAccount],
+ team_info: TeamInfo,
+ turn_context: TurnContext,
+ ):
+ teams_members_removed = []
+ for member in members_removed:
+ new_account_json = member.seralize()
+ del new_account_json["additional_properties"]
+ teams_members_removed.append(TeamsChannelAccount(**new_account_json))
+
+ return await self.on_teams_members_removed_activity(
+ teams_members_removed, turn_context
+ )
+
+ async def on_teams_members_removed_activity(
+ self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext
+ ):
+ members_removed = [ChannelAccount(member) for member in teams_members_removed]
+ return super().on_members_removed_activity(members_removed, turn_context)
+
+ async def on_teams_channel_deleted_activity( # pylint: disable=unused-argument
+ self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return # Task.CompleteTask
+
+ async def on_teams_channel_renamed_activity( # pylint: disable=unused-argument
+ self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return # Task.CompleteTask
+
+ @staticmethod
+ def _create_invoke_response(body: object = None) -> InvokeResponse:
+ return InvokeResponse(status=int(HTTPStatus.OK), body=body)
+
+
+class _InvokeResponseException(Exception):
+ def __init__(self, status_code: HTTPStatus, body: object = None):
+ super(_InvokeResponseException, self).__init__()
+ self._status_code = status_code
+ self._body = body
+
+ def create_invoke_response(self) -> InvokeResponse:
+ return InvokeResponse(status=int(self._status_code), body=self._body)
diff --git a/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py
new file mode 100644
index 000000000..87b092e09
--- /dev/null
+++ b/libraries/botbuilder-core/tests/teams/test_teams_activity_handler.py
@@ -0,0 +1,100 @@
+from typing import List
+
+import aiounittest
+from botbuilder.core import BotAdapter, TurnContext
+from botbuilder.core.teams import TeamsActivityHandler
+from botbuilder.schema import (
+ Activity,
+ ActivityTypes,
+ ChannelAccount,
+ ConversationReference,
+ MessageReaction,
+ ResourceResponse,
+)
+
+
+class TestingTeamsActivityHandler(TeamsActivityHandler):
+ def __init__(self):
+ self.record: List[str] = []
+
+ async def on_message_activity(self, turn_context: TurnContext):
+ self.record.append("on_message_activity")
+ return await super().on_message_activity(turn_context)
+
+ async def on_members_added_activity(
+ self, members_added: ChannelAccount, turn_context: TurnContext
+ ):
+ self.record.append("on_members_added_activity")
+ return await super().on_members_added_activity(members_added, turn_context)
+
+ async def on_members_removed_activity(
+ self, members_removed: ChannelAccount, turn_context: TurnContext
+ ):
+ self.record.append("on_members_removed_activity")
+ return await super().on_members_removed_activity(members_removed, turn_context)
+
+ async def on_message_reaction_activity(self, turn_context: TurnContext):
+ self.record.append("on_message_reaction_activity")
+ return await super().on_message_reaction_activity(turn_context)
+
+ async def on_reactions_added(
+ self, message_reactions: List[MessageReaction], turn_context: TurnContext
+ ):
+ self.record.append("on_reactions_added")
+ return await super().on_reactions_added(message_reactions, turn_context)
+
+ async def on_reactions_removed(
+ self, message_reactions: List[MessageReaction], turn_context: TurnContext
+ ):
+ self.record.append("on_reactions_removed")
+ return await super().on_reactions_removed(message_reactions, turn_context)
+
+ async def on_token_response_event(self, turn_context: TurnContext):
+ self.record.append("on_token_response_event")
+ return await super().on_token_response_event(turn_context)
+
+ async def on_event(self, turn_context: TurnContext):
+ self.record.append("on_event")
+ return await super().on_event(turn_context)
+
+ async def on_unrecognized_activity_type(self, turn_context: TurnContext):
+ self.record.append("on_unrecognized_activity_type")
+ return await super().on_unrecognized_activity_type(turn_context)
+
+
+class NotImplementedAdapter(BotAdapter):
+ async def delete_activity(
+ self, context: TurnContext, reference: ConversationReference
+ ):
+ raise NotImplementedError()
+
+ async def send_activities(
+ self, context: TurnContext, activities: List[Activity]
+ ) -> List[ResourceResponse]:
+ raise NotImplementedError()
+
+ async def update_activity(self, context: TurnContext, activity: Activity):
+ raise NotImplementedError()
+
+
+class TestTeamsActivityHandler(aiounittest.AsyncTestCase):
+ async def test_message_reaction(self):
+ # Note the code supports multiple adds and removes in the same activity though
+ # a channel may decide to send separate activities for each. For example, Teams
+ # sends separate activities each with a single add and a single remove.
+
+ # Arrange
+ activity = Activity(
+ type=ActivityTypes.message_reaction,
+ reactions_added=[MessageReaction(type="sad")],
+ )
+ turn_context = TurnContext(NotImplementedAdapter(), activity)
+
+ # Act
+ bot = TestingTeamsActivityHandler()
+ await bot.on_turn(turn_context)
+
+ # Assert
+ assert len(bot.record) == 2
+ assert bot.record[0] == "on_message_reaction_activity"
+ assert bot.record[1] == "on_reactions_added"
diff --git a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py
index 605600aa9..300ccddd8 100644
--- a/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py
+++ b/libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py
@@ -27,6 +27,7 @@ class ActivityTypes(str, Enum):
end_of_conversation = "endOfConversation"
event = "event"
invoke = "invoke"
+ invoke_response = "invokeResponse"
delete_user_data = "deleteUserData"
message_update = "messageUpdate"
message_delete = "messageDelete"
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py
new file mode 100644
index 000000000..d299ec66c
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py
@@ -0,0 +1,15 @@
+from .team_info import TeamInfo
+from .notification_info import NotificationInfo
+from .tenant_info import TenantInfo
+from .channel_info import ChannelInfo
+from .teams_channel_data import TeamsChannelData
+from .teams_channel_account import TeamsChannelAccount
+
+__all__ = [
+ "TeamInfo",
+ "ChannelInfo",
+ "TeamsChannelData",
+ "TeamsChannelAccount",
+ "TenantInfo",
+ "NotificationInfo",
+]
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py
new file mode 100644
index 000000000..6125698c3
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/channel_info.py
@@ -0,0 +1,13 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+#
+# Code generated by Microsoft (R) AutoRest Code Generator.
+# Changes may cause incorrect behavior and will be lost if the code is
+# regenerated.
+
+
+class ChannelInfo(object):
+ def __init__(self, id="", name=""):
+ self.id = id
+ self.name = name
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py
new file mode 100644
index 000000000..dd55a69c7
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/notification_info.py
@@ -0,0 +1,12 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+#
+# Code generated by Microsoft (R) AutoRest Code Generator.
+# Changes may cause incorrect behavior and will be lost if the code is
+# regenerated.
+
+
+class NotificationInfo:
+ def __init__(self, alert: bool = False):
+ self.alert = alert
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py
new file mode 100644
index 000000000..316ae89c2
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/team_info.py
@@ -0,0 +1,14 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+#
+# Code generated by Microsoft (R) AutoRest Code Generator.
+# Changes may cause incorrect behavior and will be lost if the code is
+# regenerated.
+
+
+class TeamInfo:
+ def __init__(self, id="", name="", aadGroupId=""):
+ self.id = id
+ self.name = name
+ self.aad_group_id = aadGroupId
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py
new file mode 100644
index 000000000..a2354effd
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_account.py
@@ -0,0 +1,31 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+#
+# Code generated by Microsoft (R) AutoRest Code Generator.
+# Changes may cause incorrect behavior and will be lost if the code is
+# regenerated.
+
+from botbuilder.schema import ChannelAccount
+
+
+class TeamsChannelAccount(ChannelAccount):
+ def __init__(
+ self,
+ id="",
+ name="",
+ aad_object_id="",
+ role="",
+ given_name="",
+ surname="",
+ email="",
+ userPrincipalName="",
+ ):
+ super().__init__(
+ **{"id": id, "name": name, "aad_object_id": aad_object_id, "role": role}
+ )
+ self.given_name = given_name
+ self.surname = surname
+ self.email = email
+ # This isn't camel_cased because the JSON that makes this object isn't camel_case
+ self.user_principal_name = userPrincipalName
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py
new file mode 100644
index 000000000..24001d00c
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/teams_channel_data.py
@@ -0,0 +1,30 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+#
+# Code generated by Microsoft (R) AutoRest Code Generator.
+# Changes may cause incorrect behavior and will be lost if the code is
+# regenerated.
+
+from botbuilder.schema.teams import ChannelInfo, TeamInfo, NotificationInfo, TenantInfo
+
+
+class TeamsChannelData:
+ def __init__(
+ self,
+ channel: ChannelInfo = None,
+ eventType="",
+ team: TeamInfo = None,
+ notification: NotificationInfo = None,
+ tenant: TenantInfo = None,
+ ):
+ self.channel = ChannelInfo(**channel) if channel is not None else ChannelInfo()
+ # This is not camel case because the JSON that makes this object isn't
+ self.event_type = eventType
+ self.team = TeamInfo(**team) if team is not None else TeamInfo()
+ self.notification = (
+ NotificationInfo(**notification)
+ if notification is not None
+ else NotificationInfo()
+ )
+ self.tenant = TenantInfo(**tenant) if tenant is not None else TenantInfo()
diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py
new file mode 100644
index 000000000..2b47e81a0
--- /dev/null
+++ b/libraries/botbuilder-schema/botbuilder/schema/teams/tenant_info.py
@@ -0,0 +1,12 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+#
+# Code generated by Microsoft (R) AutoRest Code Generator.
+# Changes may cause incorrect behavior and will be lost if the code is
+# regenerated.
+
+
+class TenantInfo:
+ def __init__(self, id=""):
+ self._id = id
diff --git a/scenarios/activity-update-and-delete/README.md b/scenarios/activity-update-and-delete/README.md
new file mode 100644
index 000000000..40e84f525
--- /dev/null
+++ b/scenarios/activity-update-and-delete/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/scenarios/activity-update-and-delete/app.py b/scenarios/activity-update-and-delete/app.py
new file mode 100644
index 000000000..166cee39d
--- /dev/null
+++ b/scenarios/activity-update-and-delete/app.py
@@ -0,0 +1,92 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import asyncio
+import sys
+from datetime import datetime
+from types import MethodType
+
+from flask import Flask, request, Response
+from botbuilder.core import (
+ BotFrameworkAdapterSettings,
+ TurnContext,
+ BotFrameworkAdapter,
+)
+from botbuilder.schema import Activity, ActivityTypes
+
+from bots import ActivitiyUpdateAndDeleteBot
+
+# Create the loop and Flask app
+LOOP = asyncio.get_event_loop()
+APP = Flask(__name__, instance_relative_config=True)
+APP.config.from_object("config.DefaultConfig")
+
+# Create adapter.
+# See https://aka.ms/about-bot-adapter to learn more about how bots work.
+SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"])
+ADAPTER = BotFrameworkAdapter(SETTINGS)
+
+
+# Catch-all for errors.
+async def on_error( # pylint: disable=unused-argument
+ self, 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 = MethodType(on_error, ADAPTER)
+ACTIVITY_IDS = []
+# Create the Bot
+BOT = ActivitiyUpdateAndDeleteBot(ACTIVITY_IDS)
+
+# Listen for incoming requests on /api/messages.s
+@APP.route("/api/messages", methods=["POST"])
+def messages():
+ # Main bot message handler.
+ if "application/json" in request.headers["Content-Type"]:
+ body = request.json
+ else:
+ return Response(status=415)
+
+ activity = Activity().deserialize(body)
+ auth_header = (
+ request.headers["Authorization"] if "Authorization" in request.headers else ""
+ )
+
+ try:
+ task = LOOP.create_task(
+ ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
+ )
+ LOOP.run_until_complete(task)
+ return Response(status=201)
+ except Exception as exception:
+ raise exception
+
+
+if __name__ == "__main__":
+ try:
+ APP.run(debug=False, port=APP.config["PORT"]) # nosec debug
+ except Exception as exception:
+ raise exception
diff --git a/scenarios/activity-update-and-delete/bots/__init__.py b/scenarios/activity-update-and-delete/bots/__init__.py
new file mode 100644
index 000000000..e6c728a12
--- /dev/null
+++ b/scenarios/activity-update-and-delete/bots/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from .activity_update_and_delete_bot import ActivitiyUpdateAndDeleteBot
+
+__all__ = ["ActivitiyUpdateAndDeleteBot"]
diff --git a/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py b/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py
new file mode 100644
index 000000000..350cec8c2
--- /dev/null
+++ b/scenarios/activity-update-and-delete/bots/activity_update_and_delete_bot.py
@@ -0,0 +1,33 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from botbuilder.core import MessageFactory, TurnContext, ActivityHandler
+
+
+class ActivitiyUpdateAndDeleteBot(ActivityHandler):
+ def __init__(self, activity_ids):
+ self.activity_ids = activity_ids
+
+ async def on_message_activity(self, turn_context: TurnContext):
+ TurnContext.remove_recipient_mention(turn_context.activity)
+ if turn_context.activity.text == "delete":
+ for activity in self.activity_ids:
+ await turn_context.delete_activity(activity)
+
+ self.activity_ids = []
+ else:
+ await self._send_message_and_log_activity_id(
+ turn_context, turn_context.activity.text
+ )
+
+ for activity_id in self.activity_ids:
+ new_activity = MessageFactory.text(turn_context.activity.text)
+ new_activity.id = activity_id
+ await turn_context.update_activity(new_activity)
+
+ async def _send_message_and_log_activity_id(
+ self, turn_context: TurnContext, text: str
+ ):
+ reply_activity = MessageFactory.text(text)
+ resource_response = await turn_context.send_activity(reply_activity)
+ self.activity_ids.append(resource_response.id)
diff --git a/scenarios/activity-update-and-delete/config.py b/scenarios/activity-update-and-delete/config.py
new file mode 100644
index 000000000..6b5116fba
--- /dev/null
+++ b/scenarios/activity-update-and-delete/config.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import os
+
+
+class DefaultConfig:
+ """ Bot Configuration """
+
+ PORT = 3978
+ APP_ID = os.environ.get("MicrosoftAppId", "")
+ APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")
diff --git a/scenarios/activity-update-and-delete/requirements.txt b/scenarios/activity-update-and-delete/requirements.txt
new file mode 100644
index 000000000..7e54b62ec
--- /dev/null
+++ b/scenarios/activity-update-and-delete/requirements.txt
@@ -0,0 +1,2 @@
+botbuilder-core>=4.4.0b1
+flask>=1.0.3
diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/color.png b/scenarios/activity-update-and-delete/teams_app_manifest/color.png
new file mode 100644
index 000000000..48a2de133
Binary files /dev/null and b/scenarios/activity-update-and-delete/teams_app_manifest/color.png differ
diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json b/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json
new file mode 100644
index 000000000..844969c04
--- /dev/null
+++ b/scenarios/activity-update-and-delete/teams_app_manifest/manifest.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.5",
+ "version": "1.0.0",
+ "id": "",
+ "packageName": "com.teams.sample.conversationUpdate",
+ "developer": {
+ "name": "ConversationUpdatesBot",
+ "websiteUrl": "https://www.microsoft.com",
+ "privacyUrl": "https://www.teams.com/privacy",
+ "termsOfUseUrl": "https://www.teams.com/termsofuser"
+ },
+ "icons": {
+ "color": "color.png",
+ "outline": "outline.png"
+ },
+ "name": {
+ "short": "ConversationUpdatesBot",
+ "full": "ConversationUpdatesBot"
+ },
+ "description": {
+ "short": "ConversationUpdatesBot",
+ "full": "ConversationUpdatesBot"
+ },
+ "accentColor": "#FFFFFF",
+ "bots": [
+ {
+ "botId": "",
+ "scopes": [
+ "groupchat",
+ "team",
+ "personal"
+ ],
+ "supportsFiles": false,
+ "isNotificationOnly": false
+ }
+ ],
+ "permissions": [
+ "identity",
+ "messageTeamMembers"
+ ],
+ "validDomains": []
+}
\ No newline at end of file
diff --git a/scenarios/activity-update-and-delete/teams_app_manifest/outline.png b/scenarios/activity-update-and-delete/teams_app_manifest/outline.png
new file mode 100644
index 000000000..dbfa92772
Binary files /dev/null and b/scenarios/activity-update-and-delete/teams_app_manifest/outline.png differ
diff --git a/scenarios/conversation-update/README.md b/scenarios/conversation-update/README.md
new file mode 100644
index 000000000..40e84f525
--- /dev/null
+++ b/scenarios/conversation-update/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/scenarios/conversation-update/app.py b/scenarios/conversation-update/app.py
new file mode 100644
index 000000000..8d1bc4ac0
--- /dev/null
+++ b/scenarios/conversation-update/app.py
@@ -0,0 +1,92 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import asyncio
+import sys
+from datetime import datetime
+from types import MethodType
+
+from flask import Flask, request, Response
+from botbuilder.core import (
+ BotFrameworkAdapterSettings,
+ TurnContext,
+ BotFrameworkAdapter,
+)
+from botbuilder.schema import Activity, ActivityTypes
+
+from bots import ConversationUpdateBot
+
+# Create the loop and Flask app
+LOOP = asyncio.get_event_loop()
+APP = Flask(__name__, instance_relative_config=True)
+APP.config.from_object("config.DefaultConfig")
+
+# Create adapter.
+# See https://aka.ms/about-bot-adapter to learn more about how bots work.
+SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"])
+ADAPTER = BotFrameworkAdapter(SETTINGS)
+
+
+# Catch-all for errors.
+async def on_error( # pylint: disable=unused-argument
+ self, 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 = MethodType(on_error, ADAPTER)
+
+# Create the Bot
+BOT = ConversationUpdateBot()
+
+# Listen for incoming requests on /api/messages.s
+@APP.route("/api/messages", methods=["POST"])
+def messages():
+ # Main bot message handler.
+ if "application/json" in request.headers["Content-Type"]:
+ body = request.json
+ else:
+ return Response(status=415)
+
+ activity = Activity().deserialize(body)
+ auth_header = (
+ request.headers["Authorization"] if "Authorization" in request.headers else ""
+ )
+
+ try:
+ task = LOOP.create_task(
+ ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
+ )
+ LOOP.run_until_complete(task)
+ return Response(status=201)
+ except Exception as exception:
+ raise exception
+
+
+if __name__ == "__main__":
+ try:
+ APP.run(debug=False, port=APP.config["PORT"]) # nosec debug
+ except Exception as exception:
+ raise exception
diff --git a/scenarios/conversation-update/bots/__init__.py b/scenarios/conversation-update/bots/__init__.py
new file mode 100644
index 000000000..f9e91a398
--- /dev/null
+++ b/scenarios/conversation-update/bots/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from .conversation_update_bot import ConversationUpdateBot
+
+__all__ = ["ConversationUpdateBot"]
diff --git a/scenarios/conversation-update/bots/conversation_update_bot.py b/scenarios/conversation-update/bots/conversation_update_bot.py
new file mode 100644
index 000000000..ec34da0f0
--- /dev/null
+++ b/scenarios/conversation-update/bots/conversation_update_bot.py
@@ -0,0 +1,56 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from botbuilder.core import MessageFactory, TurnContext
+from botbuilder.core.teams import TeamsActivityHandler
+from botbuilder.schema.teams import ChannelInfo, TeamInfo, TeamsChannelAccount
+
+
+class ConversationUpdateBot(TeamsActivityHandler):
+ async def on_teams_channel_created_activity(
+ self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return await turn_context.send_activity(
+ MessageFactory.text(
+ f"The new channel is {channel_info.name}. The channel id is {channel_info.id}"
+ )
+ )
+
+ async def on_teams_channel_deleted_activity(
+ self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return await turn_context.send_activity(
+ MessageFactory.text(f"The deleted channel is {channel_info.name}")
+ )
+
+ async def on_teams_channel_renamed_activity(
+ self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return await turn_context.send_activity(
+ MessageFactory.text(f"The new channel name is {channel_info.name}")
+ )
+
+ async def on_teams_team_renamed_activity(
+ self, team_info: TeamInfo, turn_context: TurnContext
+ ):
+ return await turn_context.send_activity(
+ MessageFactory.text(f"The new team name is {team_info.name}")
+ )
+
+ async def on_teams_members_added_activity(
+ self, teams_members_added: [TeamsChannelAccount], turn_context: TurnContext
+ ):
+ for member in teams_members_added:
+ await turn_context.send_activity(
+ MessageFactory.text(f"Welcome your new team member {member.id}")
+ )
+ return
+
+ async def on_teams_members_removed_activity(
+ self, teams_members_removed: [TeamsChannelAccount], turn_context: TurnContext
+ ):
+ for member in teams_members_removed:
+ await turn_context.send_activity(
+ MessageFactory.text(f"Say goodbye to your team member {member.id}")
+ )
+ return
diff --git a/scenarios/conversation-update/config.py b/scenarios/conversation-update/config.py
new file mode 100644
index 000000000..6b5116fba
--- /dev/null
+++ b/scenarios/conversation-update/config.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import os
+
+
+class DefaultConfig:
+ """ Bot Configuration """
+
+ PORT = 3978
+ APP_ID = os.environ.get("MicrosoftAppId", "")
+ APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")
diff --git a/scenarios/conversation-update/requirements.txt b/scenarios/conversation-update/requirements.txt
new file mode 100644
index 000000000..7e54b62ec
--- /dev/null
+++ b/scenarios/conversation-update/requirements.txt
@@ -0,0 +1,2 @@
+botbuilder-core>=4.4.0b1
+flask>=1.0.3
diff --git a/scenarios/conversation-update/teams_app_manifest/color.png b/scenarios/conversation-update/teams_app_manifest/color.png
new file mode 100644
index 000000000..48a2de133
Binary files /dev/null and b/scenarios/conversation-update/teams_app_manifest/color.png differ
diff --git a/scenarios/conversation-update/teams_app_manifest/manifest.json b/scenarios/conversation-update/teams_app_manifest/manifest.json
new file mode 100644
index 000000000..844969c04
--- /dev/null
+++ b/scenarios/conversation-update/teams_app_manifest/manifest.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.5",
+ "version": "1.0.0",
+ "id": "",
+ "packageName": "com.teams.sample.conversationUpdate",
+ "developer": {
+ "name": "ConversationUpdatesBot",
+ "websiteUrl": "https://www.microsoft.com",
+ "privacyUrl": "https://www.teams.com/privacy",
+ "termsOfUseUrl": "https://www.teams.com/termsofuser"
+ },
+ "icons": {
+ "color": "color.png",
+ "outline": "outline.png"
+ },
+ "name": {
+ "short": "ConversationUpdatesBot",
+ "full": "ConversationUpdatesBot"
+ },
+ "description": {
+ "short": "ConversationUpdatesBot",
+ "full": "ConversationUpdatesBot"
+ },
+ "accentColor": "#FFFFFF",
+ "bots": [
+ {
+ "botId": "",
+ "scopes": [
+ "groupchat",
+ "team",
+ "personal"
+ ],
+ "supportsFiles": false,
+ "isNotificationOnly": false
+ }
+ ],
+ "permissions": [
+ "identity",
+ "messageTeamMembers"
+ ],
+ "validDomains": []
+}
\ No newline at end of file
diff --git a/scenarios/conversation-update/teams_app_manifest/outline.png b/scenarios/conversation-update/teams_app_manifest/outline.png
new file mode 100644
index 000000000..dbfa92772
Binary files /dev/null and b/scenarios/conversation-update/teams_app_manifest/outline.png differ
diff --git a/scenarios/mentions/README.md b/scenarios/mentions/README.md
new file mode 100644
index 000000000..40e84f525
--- /dev/null
+++ b/scenarios/mentions/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/scenarios/mentions/app.py b/scenarios/mentions/app.py
new file mode 100644
index 000000000..bf7aa3de9
--- /dev/null
+++ b/scenarios/mentions/app.py
@@ -0,0 +1,92 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import asyncio
+import sys
+from datetime import datetime
+from types import MethodType
+
+from flask import Flask, request, Response
+from botbuilder.core import (
+ BotFrameworkAdapterSettings,
+ TurnContext,
+ BotFrameworkAdapter,
+)
+from botbuilder.schema import Activity, ActivityTypes
+
+from bots import MentionBot
+
+# Create the loop and Flask app
+LOOP = asyncio.get_event_loop()
+APP = Flask(__name__, instance_relative_config=True)
+APP.config.from_object("config.DefaultConfig")
+
+# Create adapter.
+# See https://aka.ms/about-bot-adapter to learn more about how bots work.
+SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"])
+ADAPTER = BotFrameworkAdapter(SETTINGS)
+
+
+# Catch-all for errors.
+async def on_error( # pylint: disable=unused-argument
+ 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 = MethodType(on_error, ADAPTER)
+
+# Create the Bot
+BOT = MentionBot()
+
+# Listen for incoming requests on /api/messages.s
+@APP.route("/api/messages", methods=["POST"])
+def messages():
+ # Main bot message handler.
+ if "application/json" in request.headers["Content-Type"]:
+ body = request.json
+ else:
+ return Response(status=415)
+
+ activity = Activity().deserialize(body)
+ auth_header = (
+ request.headers["Authorization"] if "Authorization" in request.headers else ""
+ )
+
+ try:
+ task = LOOP.create_task(
+ ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
+ )
+ LOOP.run_until_complete(task)
+ return Response(status=201)
+ except Exception as exception:
+ raise exception
+
+
+if __name__ == "__main__":
+ try:
+ APP.run(debug=False, port=APP.config["PORT"]) # nosec debug
+ except Exception as exception:
+ raise exception
diff --git a/scenarios/mentions/bots/__init__.py b/scenarios/mentions/bots/__init__.py
new file mode 100644
index 000000000..82e97adab
--- /dev/null
+++ b/scenarios/mentions/bots/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from .mention_bot import MentionBot
+
+__all__ = ["MentionBot"]
diff --git a/scenarios/mentions/bots/mention_bot.py b/scenarios/mentions/bots/mention_bot.py
new file mode 100644
index 000000000..f343c4584
--- /dev/null
+++ b/scenarios/mentions/bots/mention_bot.py
@@ -0,0 +1,21 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from botbuilder.core import MessageFactory, TurnContext
+from botbuilder.core.teams import TeamsActivityHandler
+from botbuilder.schema import Mention
+
+
+class MentionBot(TeamsActivityHandler):
+ async def on_message_activity(self, turn_context: TurnContext):
+ mention_data = {
+ "mentioned": turn_context.activity.from_property,
+ "text": f"{turn_context.activity.from_property.name}",
+ "type": "mention",
+ }
+
+ mention_object = Mention(**mention_data)
+
+ reply_activity = MessageFactory.text(f"Hello {mention_object.text}")
+ reply_activity.entities = [mention_object]
+ await turn_context.send_activity(reply_activity)
diff --git a/scenarios/mentions/config.py b/scenarios/mentions/config.py
new file mode 100644
index 000000000..6b5116fba
--- /dev/null
+++ b/scenarios/mentions/config.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import os
+
+
+class DefaultConfig:
+ """ Bot Configuration """
+
+ PORT = 3978
+ APP_ID = os.environ.get("MicrosoftAppId", "")
+ APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "")
diff --git a/scenarios/mentions/requirements.txt b/scenarios/mentions/requirements.txt
new file mode 100644
index 000000000..7e54b62ec
--- /dev/null
+++ b/scenarios/mentions/requirements.txt
@@ -0,0 +1,2 @@
+botbuilder-core>=4.4.0b1
+flask>=1.0.3
diff --git a/scenarios/mentions/teams_app_manifest/color.png b/scenarios/mentions/teams_app_manifest/color.png
new file mode 100644
index 000000000..48a2de133
Binary files /dev/null and b/scenarios/mentions/teams_app_manifest/color.png differ
diff --git a/scenarios/mentions/teams_app_manifest/manifest.json b/scenarios/mentions/teams_app_manifest/manifest.json
new file mode 100644
index 000000000..b9d5b596f
--- /dev/null
+++ b/scenarios/mentions/teams_app_manifest/manifest.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.5",
+ "version": "1.0.0",
+ "id": "<>",
+ "packageName": "com.teams.sample.conversationUpdate",
+ "developer": {
+ "name": "MentionBot",
+ "websiteUrl": "https://www.microsoft.com",
+ "privacyUrl": "https://www.teams.com/privacy",
+ "termsOfUseUrl": "https://www.teams.com/termsofuser"
+ },
+ "icons": {
+ "color": "color.png",
+ "outline": "outline.png"
+ },
+ "name": {
+ "short": "MentionBot",
+ "full": "MentionBot"
+ },
+ "description": {
+ "short": "MentionBot",
+ "full": "MentionBot"
+ },
+ "accentColor": "#FFFFFF",
+ "bots": [
+ {
+ "botId": "<>",
+ "scopes": [
+ "groupchat",
+ "team",
+ "personal"
+ ],
+ "supportsFiles": false,
+ "isNotificationOnly": false
+ }
+ ],
+ "permissions": [
+ "identity",
+ "messageTeamMembers"
+ ],
+ "validDomains": []
+}
\ No newline at end of file
diff --git a/scenarios/mentions/teams_app_manifest/outline.png b/scenarios/mentions/teams_app_manifest/outline.png
new file mode 100644
index 000000000..dbfa92772
Binary files /dev/null and b/scenarios/mentions/teams_app_manifest/outline.png differ
diff --git a/scenarios/message-reactions/README.md b/scenarios/message-reactions/README.md
new file mode 100644
index 000000000..40e84f525
--- /dev/null
+++ b/scenarios/message-reactions/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/scenarios/message-reactions/activity_log.py b/scenarios/message-reactions/activity_log.py
new file mode 100644
index 000000000..e4dbe477a
--- /dev/null
+++ b/scenarios/message-reactions/activity_log.py
@@ -0,0 +1,27 @@
+from botbuilder.core import MemoryStorage
+from botbuilder.schema import Activity
+
+
+class ActivityLog:
+ def __init__(self, storage: MemoryStorage):
+ self._storage = storage
+
+ async def append(self, activity_id: str, activity: Activity):
+ if not activity_id:
+ raise TypeError("activity_id is required for ActivityLog.append")
+
+ if not activity:
+ raise TypeError("activity is required for ActivityLog.append")
+
+ obj = {}
+ obj[activity_id] = activity
+
+ await self._storage.write(obj)
+ return
+
+ async def find(self, activity_id: str) -> Activity:
+ if not activity_id:
+ raise TypeError("activity_id is required for ActivityLog.find")
+
+ items = await self._storage.read([activity_id])
+ return items[activity_id] if len(items) >= 1 else None
diff --git a/scenarios/message-reactions/app.py b/scenarios/message-reactions/app.py
new file mode 100644
index 000000000..f92c64c3c
--- /dev/null
+++ b/scenarios/message-reactions/app.py
@@ -0,0 +1,94 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import sys
+from datetime import datetime
+from types import MethodType
+
+from flask import Flask, request, Response
+from botbuilder.core import (
+ BotFrameworkAdapterSettings,
+ TurnContext,
+ BotFrameworkAdapter,
+ MemoryStorage,
+)
+from botbuilder.schema import Activity, ActivityTypes
+from activity_log import ActivityLog
+from bots import MessageReactionBot
+from threading_helper import run_coroutine
+
+# Create the Flask app
+APP = Flask(__name__, instance_relative_config=True)
+APP.config.from_object("config.DefaultConfig")
+
+# Create adapter.
+# See https://aka.ms/about-bot-adapter to learn more about how bots work.
+SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"])
+ADAPTER = BotFrameworkAdapter(SETTINGS)
+
+
+# Catch-all for errors.
+async def on_error( # pylint: disable=unused-argument
+ self, 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 = MethodType(on_error, ADAPTER)
+
+MEMORY = MemoryStorage()
+ACTIVITY_LOG = ActivityLog(MEMORY)
+# Create the Bot
+BOT = MessageReactionBot(ACTIVITY_LOG)
+
+# Listen for incoming requests on /api/messages.s
+@APP.route("/api/messages", methods=["POST"])
+def messages():
+ # Main bot message handler.
+ if "application/json" in request.headers["Content-Type"]:
+ body = request.json
+ else:
+ return Response(status=415)
+
+ activity = Activity().deserialize(body)
+ auth_header = (
+ request.headers["Authorization"] if "Authorization" in request.headers else ""
+ )
+
+ try:
+ print("about to create task")
+ print("about to run until complete")
+ run_coroutine(ADAPTER.process_activity(activity, auth_header, BOT.on_turn))
+ print("is now complete")
+ return Response(status=201)
+ except Exception as exception:
+ raise exception
+
+
+if __name__ == "__main__":
+ try:
+ APP.run(debug=False, port=APP.config["PORT"]) # nosec debug
+ except Exception as exception:
+ raise exception
diff --git a/scenarios/message-reactions/bots/__init__.py b/scenarios/message-reactions/bots/__init__.py
new file mode 100644
index 000000000..4c417f70c
--- /dev/null
+++ b/scenarios/message-reactions/bots/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from .message_reaction_bot import MessageReactionBot
+
+__all__ = ["MessageReactionBot"]
diff --git a/scenarios/message-reactions/bots/message_reaction_bot.py b/scenarios/message-reactions/bots/message_reaction_bot.py
new file mode 100644
index 000000000..ce8c34cea
--- /dev/null
+++ b/scenarios/message-reactions/bots/message_reaction_bot.py
@@ -0,0 +1,60 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+from typing import List
+from botbuilder.core import MessageFactory, TurnContext, ActivityHandler
+from botbuilder.schema import MessageReaction
+from activity_log import ActivityLog
+
+
+class MessageReactionBot(ActivityHandler):
+ def __init__(self, activity_log: ActivityLog):
+ self._log = activity_log
+
+ async def on_reactions_added(
+ self, message_reactions: List[MessageReaction], turn_context: TurnContext
+ ):
+ for reaction in message_reactions:
+ activity = await self._log.find(turn_context.activity.reply_to_id)
+ if not activity:
+ await self._send_message_and_log_activity_id(
+ turn_context,
+ f"Activity {turn_context.activity.reply_to_id} not found in log",
+ )
+ else:
+ await self._send_message_and_log_activity_id(
+ turn_context,
+ f"You added '{reaction.type}' regarding '{activity.text}'",
+ )
+ return
+
+ async def on_reactions_removed(
+ self, message_reactions: List[MessageReaction], turn_context: TurnContext
+ ):
+ for reaction in message_reactions:
+ activity = await self._log.find(turn_context.activity.reply_to_id)
+ if not activity:
+ await self._send_message_and_log_activity_id(
+ turn_context,
+ f"Activity {turn_context.activity.reply_to_id} not found in log",
+ )
+ else:
+ await self._send_message_and_log_activity_id(
+ turn_context,
+ f"You removed '{reaction.type}' regarding '{activity.text}'",
+ )
+ return
+
+ async def on_message_activity(self, turn_context: TurnContext):
+ await self._send_message_and_log_activity_id(
+ turn_context, f"echo: {turn_context.activity.text}"
+ )
+
+ async def _send_message_and_log_activity_id(
+ self, turn_context: TurnContext, text: str
+ ):
+ reply_activity = MessageFactory.text(text)
+ resource_response = await turn_context.send_activity(reply_activity)
+
+ await self._log.append(resource_response.id, reply_activity)
+ return
diff --git a/scenarios/message-reactions/config.py b/scenarios/message-reactions/config.py
new file mode 100644
index 000000000..480b0647b
--- /dev/null
+++ b/scenarios/message-reactions/config.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import os
+
+
+class DefaultConfig:
+ """ Bot Configuration """
+
+ PORT = 3978
+ APP_ID = os.environ.get("MicrosoftAppId", "e4c570ca-189d-4fee-a81b-5466be24a557")
+ APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "bghqYKJV3709;creKFP8$@@")
diff --git a/scenarios/message-reactions/requirements.txt b/scenarios/message-reactions/requirements.txt
new file mode 100644
index 000000000..7e54b62ec
--- /dev/null
+++ b/scenarios/message-reactions/requirements.txt
@@ -0,0 +1,2 @@
+botbuilder-core>=4.4.0b1
+flask>=1.0.3
diff --git a/scenarios/message-reactions/teams_app_manifest/color.png b/scenarios/message-reactions/teams_app_manifest/color.png
new file mode 100644
index 000000000..48a2de133
Binary files /dev/null and b/scenarios/message-reactions/teams_app_manifest/color.png differ
diff --git a/scenarios/message-reactions/teams_app_manifest/manifest.json b/scenarios/message-reactions/teams_app_manifest/manifest.json
new file mode 100644
index 000000000..a3ec0ae45
--- /dev/null
+++ b/scenarios/message-reactions/teams_app_manifest/manifest.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.5/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.5",
+ "version": "1.0.0",
+ "id": "<>",
+ "packageName": "com.teams.sample.conversationUpdate",
+ "developer": {
+ "name": "MessageReactions",
+ "websiteUrl": "https://www.microsoft.com",
+ "privacyUrl": "https://www.teams.com/privacy",
+ "termsOfUseUrl": "https://www.teams.com/termsofuser"
+ },
+ "icons": {
+ "color": "color.png",
+ "outline": "outline.png"
+ },
+ "name": {
+ "short": "MessageReactions",
+ "full": "MessageReactions"
+ },
+ "description": {
+ "short": "MessageReactions",
+ "full": "MessageReactions"
+ },
+ "accentColor": "#FFFFFF",
+ "bots": [
+ {
+ "botId": "<>",
+ "scopes": [
+ "groupchat",
+ "team",
+ "personal"
+ ],
+ "supportsFiles": false,
+ "isNotificationOnly": false
+ }
+ ],
+ "permissions": [
+ "identity",
+ "messageTeamMembers"
+ ],
+ "validDomains": []
+}
\ No newline at end of file
diff --git a/scenarios/message-reactions/teams_app_manifest/outline.png b/scenarios/message-reactions/teams_app_manifest/outline.png
new file mode 100644
index 000000000..dbfa92772
Binary files /dev/null and b/scenarios/message-reactions/teams_app_manifest/outline.png differ
diff --git a/scenarios/message-reactions/threading_helper.py b/scenarios/message-reactions/threading_helper.py
new file mode 100644
index 000000000..ab3316e1f
--- /dev/null
+++ b/scenarios/message-reactions/threading_helper.py
@@ -0,0 +1,169 @@
+import asyncio
+import itertools
+import logging
+import threading
+
+# pylint: disable=invalid-name
+# pylint: disable=global-statement
+try:
+ # Python 3.8 or newer has a suitable process watcher
+ asyncio.ThreadedChildWatcher
+except AttributeError:
+ # backport the Python 3.8 threaded child watcher
+ import os
+ import warnings
+
+ # Python 3.7 preferred API
+ _get_running_loop = getattr(asyncio, "get_running_loop", asyncio.get_event_loop)
+
+ class _Py38ThreadedChildWatcher(asyncio.AbstractChildWatcher):
+ def __init__(self):
+ self._pid_counter = itertools.count(0)
+ self._threads = {}
+
+ def is_active(self):
+ return True
+
+ def close(self):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ pass
+
+ def __del__(self, _warn=warnings.warn):
+ threads = [t for t in list(self._threads.values()) if t.is_alive()]
+ if threads:
+ _warn(
+ f"{self.__class__} has registered but not finished child processes",
+ ResourceWarning,
+ source=self,
+ )
+
+ def add_child_handler(self, pid, callback, *args):
+ loop = _get_running_loop()
+ thread = threading.Thread(
+ target=self._do_waitpid,
+ name=f"waitpid-{next(self._pid_counter)}",
+ args=(loop, pid, callback, args),
+ daemon=True,
+ )
+ self._threads[pid] = thread
+ thread.start()
+
+ def remove_child_handler(self, pid):
+ # asyncio never calls remove_child_handler() !!!
+ # The method is no-op but is implemented because
+ # abstract base class requires it
+ return True
+
+ def attach_loop(self, loop):
+ pass
+
+ def _do_waitpid(self, loop, expected_pid, callback, args):
+ assert expected_pid > 0
+
+ try:
+ pid, status = os.waitpid(expected_pid, 0)
+ except ChildProcessError:
+ # The child process is already reaped
+ # (may happen if waitpid() is called elsewhere).
+ pid = expected_pid
+ returncode = 255
+ logger.warning(
+ "Unknown child process pid %d, will report returncode 255", pid
+ )
+ else:
+ if os.WIFSIGNALED(status):
+ returncode = -os.WTERMSIG(status)
+ elif os.WIFEXITED(status):
+ returncode = os.WEXITSTATUS(status)
+ else:
+ returncode = status
+
+ if loop.get_debug():
+ logger.debug(
+ "process %s exited with returncode %s", expected_pid, returncode
+ )
+
+ if loop.is_closed():
+ logger.warning("Loop %r that handles pid %r is closed", loop, pid)
+ else:
+ loop.call_soon_threadsafe(callback, pid, returncode, *args)
+
+ self._threads.pop(expected_pid)
+
+ # add the watcher to the loop policy
+ asyncio.get_event_loop_policy().set_child_watcher(_Py38ThreadedChildWatcher())
+
+__all__ = ["EventLoopThread", "get_event_loop", "stop_event_loop", "run_coroutine"]
+
+logger = logging.getLogger(__name__)
+
+
+class EventLoopThread(threading.Thread):
+ loop = None
+ _count = itertools.count(0)
+
+ def __init__(self):
+ name = f"{type(self).__name__}-{next(self._count)}"
+ super().__init__(name=name, daemon=True)
+
+ def __repr__(self):
+ loop, r, c, d = self.loop, False, True, False
+ if loop is not None:
+ r, c, d = loop.is_running(), loop.is_closed(), loop.get_debug()
+ return (
+ f"<{type(self).__name__} {self.name} id={self.ident} "
+ f"running={r} closed={c} debug={d}>"
+ )
+
+ def run(self):
+ self.loop = loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ try:
+ loop.run_forever()
+ finally:
+ try:
+ shutdown_asyncgens = loop.shutdown_asyncgens()
+ except AttributeError:
+ pass
+ else:
+ loop.run_until_complete(shutdown_asyncgens)
+ loop.close()
+ asyncio.set_event_loop(None)
+
+ def stop(self):
+ loop, self.loop = self.loop, None
+ if loop is None:
+ return
+ loop.call_soon_threadsafe(loop.stop)
+ self.join()
+
+
+_lock = threading.Lock()
+_loop_thread = None
+
+
+def get_event_loop():
+ global _loop_thread
+ with _lock:
+ if _loop_thread is None:
+ _loop_thread = EventLoopThread()
+ _loop_thread.start()
+ return _loop_thread.loop
+
+
+def stop_event_loop():
+ global _loop_thread
+ with _lock:
+ if _loop_thread is not None:
+ _loop_thread.stop()
+ _loop_thread = None
+
+
+def run_coroutine(coro):
+ return asyncio.run_coroutine_threadsafe(coro, get_event_loop())