From f3b7c090a7391dccbd1261753b915278be1bd2a8 Mon Sep 17 00:00:00 2001 From: Ganapathi Diddi Date: Mon, 23 Sep 2024 23:39:21 +0530 Subject: [PATCH 1/2] Send targeted meeting notification in Teams meeting --- .../botbuilder/core/teams/teams_info.py | 27 ++ .../botbuilder/schema/teams/__init__.py | 4 + .../botbuilder/schema/teams/_models_py3.py | 293 ++++++++++++++++++ .../teams/operations/teams_operations.py | 73 +++++ 4 files changed, 397 insertions(+) diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index f70f6cccc..4afa50c05 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -25,6 +25,8 @@ TeamsChannelAccount, TeamsPagedMembersResult, TeamsMeetingParticipant, + MeetingNotificationBase, + MeetingNotificationResponse, ) @@ -100,6 +102,31 @@ async def _legacy_send_message_to_teams_channel( ) return (result[0], result[1]) + @staticmethod + async def send_meeting_notification( + turn_context: TurnContext, + notification: MeetingNotificationBase, + meeting_id: str = None, + ) -> MeetingNotificationResponse: + meeting_id = ( + meeting_id + if meeting_id + else teams_get_meeting_info(turn_context.activity).id + ) + if meeting_id is None: + raise TypeError( + "TeamsInfo._send_meeting_notification: method requires a meeting_id or " + "TurnContext that contains a meeting id" + ) + + if notification is None: + raise TypeError("notification is required.") + + connector_client = await TeamsInfo.get_teams_connector_client(turn_context) + return await connector_client.teams.send_meeting_notification( + meeting_id, notification + ) + @staticmethod async def _create_conversation_callback( new_turn_context, diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index 8fb944b16..6a689072d 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -85,6 +85,8 @@ from ._models_py3 import ConfigAuthResponse from ._models_py3 import ConfigResponse from ._models_py3 import ConfigTaskResponse +from ._models_py3 import MeetingNotificationBase +from ._models_py3 import MeetingNotificationResponse __all__ = [ "AppBasedLinkQuery", @@ -171,4 +173,6 @@ "ConfigAuthResponse", "ConfigResponse", "ConfigTaskResponse", + "MeetingNotificationBase", + "MeetingNotificationResponse", ] diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index a1ab30d6c..6714c9dd4 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from enum import Enum from typing import List from msrest.serialization import Model from botbuilder.schema import ( @@ -2699,3 +2700,295 @@ def __init__(self, *, config=None, **kwargs) -> None: super(ConfigAuthResponse, self).__init__( config=config or BotConfigAuth(), **kwargs ) + + +class OnBehalfOf(Model): + """Specifies attribution for notifications. + + :param item_id: The identification of the item. Default is 0. + :type item_id: int + :param mention_type: The mention type. Default is "person". + :type mention_type: str + :param mri: Message resource identifier (MRI) of the person on whose behalf the message is sent. + :type mri: str + :param display_name: Name of the person. Used as fallback in case name resolution is unavailable. + :type display_name: str + """ + + _attribute_map = { + "item_id": {"key": "itemid", "type": "int"}, + "mention_type": {"key": "mentionType", "type": "str"}, + "mri": {"key": "mri", "type": "str"}, + "display_name": {"key": "displayName", "type": "str"}, + } + + def __init__( + self, + *, + item_id: int = 0, + mention_type: str = "person", + mri: str = None, + display_name: str = None, + **kwargs + ) -> None: + super(OnBehalfOf, self).__init__(**kwargs) + self.item_id = item_id + self.mention_type = mention_type + self.mri = mri + self.display_name = display_name + + +class SurfaceType(Enum): + """ + Defines Teams Surface type for use with a Surface object. + + :var Unknown: TeamsSurfaceType is Unknown. + :vartype Unknown: int + :var MeetingStage: TeamsSurfaceType is MeetingStage.. + :vartype MeetingStage: int + :var MeetingTabIcon: TeamsSurfaceType is MeetingTabIcon. + :vartype MeetingTabIcon: int + """ + + Unknown = 0 + + MeetingStage = 1 + + MeetingTabIcon = 2 + + +class ContentType(Enum): + """ + Defines content type. Depending on contentType, content field will have a different structure. + + :var Unknown: Content type is Unknown. + :vartype Unknown: int + :var Task: TContent type is Task. + :vartype Task: int + """ + + Unknown = 0 + + Task = 1 + + +class MeetingNotificationBase(Model): + """Specifies Bot meeting notification base including channel data and type. + + :param type: Type of Bot meeting notification. + :type type: str + """ + + _attribute_map = { + "type": {"key": "type", "type": "str"}, + } + + def __init__(self, *, type: str = None, **kwargs) -> None: + super(MeetingNotificationBase, self).__init__(**kwargs) + self.type = type + + +class MeetingNotification(MeetingNotificationBase): + """Specifies Bot meeting notification including meeting notification value. + + :param value: Teams Bot meeting notification value. + :type value: TargetedMeetingNotificationValue + """ + + _attribute_map = { + "value": {"key": "value", "type": "TargetedMeetingNotificationValue"}, + } + + def __init__(self, *, value: "TargetedMeetingNotificationValue" =None, **kwargs) -> None: + super(MeetingNotification, self).__init__(**kwargs) + self.value = value + + +class MeetingNotificationChannelData(Model): + """Specify Teams Bot meeting notification channel data. + + :param on_behalf_of_list: The Teams Bot meeting notification's OnBehalfOf list. + :type on_behalf_of_list: list[~botframework.connector.teams.models.OnBehalfOf] + """ + + _attribute_map = { + "on_behalf_of_list": {"key": "OnBehalfOf", "type": "[OnBehalfOf]"} + } + + def __init__(self, *, on_behalf_of_list: List["OnBehalfOf"] = None, **kwargs): + super(MeetingNotificationChannelData, self).__init__(**kwargs) + self.on_behalf_of_list = on_behalf_of_list + + +class MeetingNotificationRecipientFailureInfo(Model): + """Information regarding failure to notify a recipient of a meeting notification. + + :param recipient_mri: The MRI for a recipient meeting notification failure. + :type recipient_mri: str + :param error_code: The error code for a meeting notification. + :type error_code: str + :param failure_reason: The reason why a participant meeting notification failed. + :type failure_reason: str + """ + + _attribute_map = { + "recipient_mri": {"key": "recipientMri", "type": "str"}, + "error_code": {"key": "errorcode", "type": "str"}, + "failure_reason": {"key": "failureReason", "type": "str"}, + } + + def __init__( + self, + *, + recipient_mri: str = None, + error_code: str = None, + failure_reason: str = None, + **kwargs + ): + super(MeetingNotificationRecipientFailureInfo, self).__init__(**kwargs) + self.recipient_mri = recipient_mri + self.error_code = error_code + self.failure_reason = failure_reason + + +class MeetingNotificationResponse(Model): + """Specifies Bot meeting notification response. + + Contains list of MeetingNotificationRecipientFailureInfo. + + :param recipients_failure_info: The list of MeetingNotificationRecipientFailureInfo. + :type recipients_failure_info: list[~botframework.connector.teams.models.MeetingNotificationRecipientFailureInfo] + """ + + _attribute_map = { + "recipients_failure_info": { + "key": "recipientsFailureInfo", + "type": "[MeetingNotificationRecipientFailureInfo]", + } + } + + def __init__( + self, + *, + recipients_failure_info: List["MeetingNotificationRecipientFailureInfo"] = None, + **kwargs + ): + super(MeetingNotificationResponse, self).__init__(**kwargs) + self.recipients_failure_info = recipients_failure_info + + +class Surface(Model): + """Specifies where the notification will be rendered in the meeting UX. + + :param type: The value indicating where the notification will be rendered in the meeting UX. + :type type: ~botframework.connector.teams.models.SurfaceType + """ + + _attribute_map = { + "type": {"key": "surface", "type": "SurfaceType"}, + } + + def __init__(self, *, type: SurfaceType = None, **kwargs): + super(Surface, self).__init__(**kwargs) + self.type = type + + +class MeetingStageSurface(Surface): + """Specifies meeting stage surface. + + :param content_type: The content type of this MeetingStageSurface. + :type content_type: ~botframework.connector.teams.models.ContentType + :param content: The content of this MeetingStageSurface. + :type content: object + """ + + _attribute_map = { + "content_type": {"key": "contentType", "type": "ContentType"}, + "content": {"key": "content", "type": "object"}, + } + + def __init__( + self, + *, + content_type: ContentType = ContentType.Task, + content: object = None, + **kwargs + ): + super(MeetingStageSurface, self).__init__(SurfaceType.MeetingStage, **kwargs) + self.content_type = content_type + self.content = content + + +class MeetingTabIconSurface(Surface): + """ + Specifies meeting tab icon surface. + + :param tab_entity_id: The tab entity Id of this MeetingTabIconSurface. + :type tab_entity_id: str + """ + + _attribute_map = { + "tab_entity_id": {"key": "tabEntityId", "type": "str"}, + } + + def __init__(self, *, tab_entity_id: str = None, **kwargs): + super(MeetingTabIconSurface, self).__init__( + SurfaceType.MeetingTabIcon, **kwargs + ) + self.tab_entity_id = tab_entity_id + + +class TargetedMeetingNotificationValue(Model): + """Specifies the targeted meeting notification value, including recipients and surfaces. + + :param recipients: The collection of recipients of the targeted meeting notification. + :type recipients: list[str] + :param surfaces: The collection of surfaces on which to show the notification. + :type surfaces: list[~botframework.connector.teams.models.Surface] + """ + + _attribute_map = { + "recipients": {"key": "recipients", "type": "[str]"}, + "surfaces": {"key": "surfaces", "type": "[Surface]"}, + } + + def __init__( + self, + *, + recipients: List[str] = None, + surfaces: List[Surface] = None, + **kwargs + ): + super(TargetedMeetingNotificationValue, self).__init__(**kwargs) + self.recipients = recipients + self.surfaces = surfaces + + +class TargetedMeetingNotification( + MeetingNotification +): + """Specifies Teams targeted meeting notification. + + :param value: The value of the TargetedMeetingNotification. + :type value: ~botframework.connector.teams.models.TargetedMeetingNotificationValue + :param channel_data: Teams Bot meeting notification channel data. + :type channel_data: ~botframework.connector.teams.models.MeetingNotificationChannelData + """ + + _attribute_map = { + "value": {"key": "value", "type": "TargetedMeetingNotificationValue"}, + "channel_data": { + "key": "channelData", + "type": "MeetingNotificationChannelData", + }, + } + + def __init__( + self, + *, + value: "TargetedMeetingNotificationValue" = None, + channel_data: "MeetingNotificationChannelData" = None, + **kwargs + ): + super(TargetedMeetingNotification, self).__init__(value=value, **kwargs) + self.channel_data = channel_data diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py index ff1bdb18c..6e453ae23 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -266,3 +266,76 @@ def fetch_meeting( return deserialized fetch_participant.metadata = {"url": "/v1/meetings/{meetingId}"} + + def send_meeting_notification( + self, + meeting_id: str, + notification: models.MeetingNotificationBase, + custom_headers=None, + raw=False, + **operation_config + ): + """Send a teams meeting notification. + + :param meeting_id: Meeting Id, encoded as a BASE64 string. + :type meeting_id: str + :param notification: The notification to send to Teams + :type notification: ~botframework.connector.teams.models.MeetingNotificationBase + :param dict custom_headers: headers that will be added to the request + :param bool raw: returns the direct response alongside the + deserialized response + :param operation_config: :ref:`Operation configuration + overrides`. + :return: MeetingNotificationResponse or ClientRawResponse if raw=true + :rtype: ~botframework.connector.teams.models.MeetingNotificationResponse or + ~msrest.pipeline.ClientRawResponse + :raises: + :class:`HttpOperationError` + """ + + # Construct URL + url = self.send_meeting_notification.metadata["url"] + path_format_arguments = { + "meetingId": self._serialize.url("meeting_id", meeting_id, "str"), + } + url = self._client.format_url(url, **path_format_arguments) + + # Construct parameters + query_parameters = {} + + # Construct headers + header_parameters = {} + header_parameters["Accept"] = "application/json" + header_parameters["Content-Type"] = "application/json; charset=utf-8" + if custom_headers: + header_parameters.update(custom_headers) + + # Construct body + body_content = self._serialize.body(notification, "notification") + + # Construct and send request + request = self._client.post( + url, query_parameters, header_parameters, body_content + ) + response = self._client.send(request, stream=False, **operation_config) + + if response.status_code not in [200, 201, 202]: + raise models.ErrorResponseException(self._deserialize, response) + + deserialized = None + if response.status_code == 200: + deserialized = self._deserialize("MeetingNotificationResponse", response) + if response.status_code == 201: + deserialized = self._deserialize("MeetingNotificationResponse", response) + if response.status_code == 202: + deserialized = self._deserialize("MeetingNotificationResponse", response) + + if raw: + client_raw_response = ClientRawResponse(deserialized, response) + return client_raw_response + + return deserialized + + send_meeting_notification.metadata = { + "url": "/v1/meetings/{meetingId}/notification" + } From 550e84c54eed98cc90ebe1599a49bae0862b4133 Mon Sep 17 00:00:00 2001 From: Ganapathi Diddi Date: Tue, 24 Sep 2024 01:46:31 +0530 Subject: [PATCH 2/2] test cases --- .../tests/teams/test_teams_info.py | 129 ++++++++++++++++++ .../botbuilder/schema/teams/_models_py3.py | 14 +- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index dea57030c..00f4ad8a4 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -1,7 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import aiounittest +from botbuilder.schema.teams._models_py3 import ( + ContentType, + MeetingNotificationChannelData, + MeetingStageSurface, + MeetingTabIconSurface, + OnBehalfOf, + TargetedMeetingNotification, + TargetedMeetingNotificationValue, + TaskModuleContinueResponse, + TaskModuleTaskInfo, +) from botframework.connector import Channels from botbuilder.core import TurnContext, MessageFactory @@ -234,6 +246,53 @@ async def test_get_meeting_info(self): handler = TeamsActivityHandler() await handler.on_turn(turn_context) + async def test_send_meeting_notificationt(self): + test_cases = [ + ("202", "accepted"), + ( + "207", + "if the notifications are sent only to parital number of recipients\ + because the validation on some recipients' ids failed or some\ + recipients were not found in the roster. In this case, \ + SMBA will return the user MRIs of those failed recipients\ + in a format that was given to a bot (ex: if a bot sent \ + encrypted user MRIs, return encrypted one).", + ), + ( + "400", + "when Meeting Notification request payload validation fails. For instance,\ + Recipients: # of recipients is greater than what the API allows ||\ + all of recipients' user ids were invalid, Surface: Surface list\ + is empty or null, Surface type is invalid, Duplicative \ + surface type exists in one payload", + ), + ( + "403", + "if the bot is not allowed to send the notification. In this case,\ + the payload should contain more detail error message. \ + There can be many reasons: bot disabled by tenant admin,\ + blocked during live site mitigation, the bot does not\ + have a correct RSC permission for a specific surface type, etc", + ), + ] + for status_code, expected_message in test_cases: + adapter = SimpleAdapterWithCreateConversation() + + activity = Activity( + type="targetedMeetingNotification", + text="Test-send_meeting_notificationt", + channel_id=Channels.ms_teams, + from_property=ChannelAccount( + aad_object_id="participantId-1", name=status_code + ), + service_url="https://test.coffee", + conversation=ConversationAccount(id="conversation-id"), + ) + + turn_context = TurnContext(adapter, activity) + handler = TeamsActivityHandler() + await handler.on_turn(turn_context) + class TestTeamsActivityHandler(TeamsActivityHandler): async def on_turn(self, turn_context: TurnContext): @@ -241,6 +300,8 @@ async def on_turn(self, turn_context: TurnContext): if turn_context.activity.text == "test_send_message_to_teams_channel": await self.call_send_message_to_teams(turn_context) + elif turn_context.activity.text == "test_send_meeting_notification": + await self.call_send_meeting_notification(turn_context) async def call_send_message_to_teams(self, turn_context: TurnContext): msg = MessageFactory.text("call_send_message_to_teams") @@ -251,3 +312,71 @@ async def call_send_message_to_teams(self, turn_context: TurnContext): assert reference[0].activity_id == "new_conversation_id" assert reference[1] == "reference123" + + async def call_send_meeting_notification(self, turn_context: TurnContext): + from_property = turn_context.activity.from_property + try: + # Send the meeting notification asynchronously + failed_participants = await TeamsInfo.send_meeting_notification( + turn_context, + self.get_targeted_meeting_notification(from_property), + "meeting-id", + ) + + # Handle based on the 'from_property.name' + if from_property.name == "207": + self.assertEqual( + "failingid", + failed_participants.recipients_failure_info[0].recipient_mri, + ) + elif from_property.name == "202": + assert failed_participants is None + else: + raise TypeError( + f"Expected HttpOperationException with response status code {from_property.name}." + ) + + except ValueError as ex: + # Assert that the response status code matches the from_property.name + assert from_property.name == str(int(ex.response.status_code)) + + # Deserialize the error response content to an ErrorResponse object + error_response = json.loads(ex.response.content) + + # Handle based on error codes + if from_property.name == "400": + assert error_response["error"]["code"] == "BadSyntax" + elif from_property.name == "403": + assert error_response["error"]["code"] == "BotNotInConversationRoster" + else: + raise TypeError( + f"Expected HttpOperationException with response status code {from_property.name}." + ) + + def get_targeted_meeting_notification(self, from_account: ChannelAccount): + recipients = [from_account.id] + + if from_account.name == "207": + recipients.append("failingid") + + meeting_stage_surface = MeetingStageSurface( + content=TaskModuleContinueResponse( + value=TaskModuleTaskInfo(title="title here", height=3, width=2) + ), + content_type=ContentType.Task, + ) + + meeting_tab_icon_surface = MeetingTabIconSurface( + tab_entity_id="test tab entity id" + ) + + value = TargetedMeetingNotificationValue( + recipients=recipients, + surfaces=[meeting_stage_surface, meeting_tab_icon_surface], + ) + + obo = OnBehalfOf(display_name=from_account.name, mri=from_account.id) + + channel_data = MeetingNotificationChannelData(on_behalf_of_list=[obo]) + + return TargetedMeetingNotification(value=value, channel_data=channel_data) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py index 6714c9dd4..11908fc44 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/_models_py3.py @@ -2799,7 +2799,9 @@ class MeetingNotification(MeetingNotificationBase): "value": {"key": "value", "type": "TargetedMeetingNotificationValue"}, } - def __init__(self, *, value: "TargetedMeetingNotificationValue" =None, **kwargs) -> None: + def __init__( + self, *, value: "TargetedMeetingNotificationValue" = None, **kwargs + ) -> None: super(MeetingNotification, self).__init__(**kwargs) self.value = value @@ -2953,20 +2955,14 @@ class TargetedMeetingNotificationValue(Model): } def __init__( - self, - *, - recipients: List[str] = None, - surfaces: List[Surface] = None, - **kwargs + self, *, recipients: List[str] = None, surfaces: List[Surface] = None, **kwargs ): super(TargetedMeetingNotificationValue, self).__init__(**kwargs) self.recipients = recipients self.surfaces = surfaces -class TargetedMeetingNotification( - MeetingNotification -): +class TargetedMeetingNotification(MeetingNotification): """Specifies Teams targeted meeting notification. :param value: The value of the TargetedMeetingNotification.