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())