diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py index f70f6cccc..92cf64888 100644 --- a/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py +++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_info.py @@ -1,8 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import List, Tuple +from typing import Any, List, Optional, Tuple +from botbuilder.schema.teams.meeting_notification_base import MeetingNotificationBase +from botbuilder.schema.teams.meeting_notification_response import ( + MeetingNotificationResponse, +) from botframework.connector import Channels from botframework.connector.aio import ConnectorClient from botframework.connector.teams import TeamsConnectorClient @@ -26,6 +30,9 @@ TeamsPagedMembersResult, TeamsMeetingParticipant, ) +from botframework.connector.teams.operations.teams_operations_extensions import ( + TeamsOperationsExtensions, +) class TeamsInfo: @@ -401,3 +408,24 @@ async def _get_member( return TeamsChannelAccount().deserialize( dict(member.serialize(), **member.additional_properties) ) + + @staticmethod + async def send_meeting_notification_async( + turn_context: TurnContext, + notification: MeetingNotificationBase, + meeting_id: str = None, + ) -> MeetingNotificationResponse: + if meeting_id is None: + meeting_id = turn_context.activity.id + else: + raise ValueError( + "This method is only valid within the scope of a MS Teams Meeting." + ) + if not notification: + raise Exception(f"{notification} is required.") + + teams_client = await TeamsInfo.get_teams_connector_client(turn_context) + + return await teams_client.teams.send_meeting_notification_message_async( + meeting_id, notification + ) diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index dea57030c..dc4e98c81 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -1,8 +1,41 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from http.server import BaseHTTPRequestHandler, HTTPServer +import socket +from typing import List, Self +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from urllib.parse import urlparse +from aioresponses import aioresponses import aiounittest +from botbuilder.schema.teams.meeting_notification_base import MeetingNotificationBase from botframework.connector import Channels +from botbuilder.schema.teams.content_type import ContentType + +import json +from botbuilder.schema._models_py3 import ErrorResponse +from botbuilder.schema.teams._models_py3 import ( + TaskModuleContinueResponse, + TaskModuleTaskInfo, +) +from botbuilder.schema.teams.meeting_stage_surface import MeetingStageSurface +from botbuilder.schema.teams.targeted_meeting_notification_value import ( + TargetedMeetingNotificationValue, +) +from botframework.connector.aio._connector_client_async import ConnectorClient +from botframework.connector.auth.microsoft_app_credentials import ( + MicrosoftAppCredentials, +) +import pytest +from simple_adapter import SimpleAdapter +from simple_adapter_with_create_conversation import SimpleAdapterWithCreateConversation +from botbuilder.schema.teams.on_behalf_of import OnBehalfOf +from botbuilder.schema.teams.meeting_notification_channel_data import ( + MeetingNotificationChannelData, +) +from botbuilder.schema.teams.targeted_meeting_notification import ( + TargetedMeetingNotification, +) from botbuilder.core import TurnContext, MessageFactory from botbuilder.core.teams import TeamsInfo, TeamsActivityHandler @@ -11,7 +44,6 @@ ChannelAccount, ConversationAccount, ) -from simple_adapter_with_create_conversation import SimpleAdapterWithCreateConversation ACTIVITY = Activity( id="1234", @@ -234,6 +266,38 @@ async def test_get_meeting_info(self): handler = TeamsActivityHandler() await handler.on_turn(turn_context) + @pytest.mark.asyncio + @pytest.mark.parametrize("status_code", ["202", "207", "400", "403"]) + async def test_send_meeting_notification_async( + status_code, + ): # pylint: disable=no-self-argument + base_uri = "https://test.coffee" + + with aioresponses() as m: + # Mock the HTTP response + m.post( + f"{base_uri}/v1/meetings/meeting-id/notification", + status=status_code, + payload={}, + ) + connector_client = ConnectorClient( + credentials=MicrosoftAppCredentials("", ""), base_url=base_uri + ) + + activity = Activity( + type="targetedMeetingNotification", + text="test_send_meeting_notification", + channel_id=Channels.ms_teams, + service_url="https://test.coffee", + from_property=ChannelAccount(id="id-1", name=status_code), + conversation=ConversationAccount(id="conversation-id"), + ) + + turn_context = TurnContext(SimpleAdapterWithCreateConversation(), activity) + turn_context.turn_state[ConnectorClient] = connector_client + handler = TestTeamsActivityHandler() + await handler.on_turn(turn_context) + class TestTeamsActivityHandler(TeamsActivityHandler): async def on_turn(self, turn_context: TurnContext): @@ -241,6 +305,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_async(turn_context) async def call_send_message_to_teams(self, turn_context: TurnContext): msg = MessageFactory.text("call_send_message_to_teams") @@ -251,3 +317,230 @@ async def call_send_message_to_teams(self, turn_context: TurnContext): assert reference[0].activity_id == "new_conversation_id" assert reference[1] == "reference123" + + def get_targeted_meeting_notification( + self, from_user: ChannelAccount + ) -> MeetingNotificationBase: + # Create a list of recipients + recipients: List[str] = [from_user.id] + + if from_user.name == "207": + recipients.append("failingid") + + surface = MeetingStageSurface[TaskModuleContinueResponse]() + surface.content = TaskModuleContinueResponse( + value=TaskModuleTaskInfo(title="title here", height=3, width=2) + ) + surface.content_type = ContentType.TASK + + value = TargetedMeetingNotificationValue( + recipients=recipients, surfaces=[surface] + ) + + obo = OnBehalfOf(display_name=from_user.name, mri=from_user.id) + + channel_data = MeetingNotificationChannelData(on_behalf_of_list=[obo]) + + return TargetedMeetingNotification(value=value, channel_data=channel_data) + + async def call_send_meeting_notification_async(self, turn_context: TurnContext): + from_user = turn_context.activity.from_property + + try: + failed_participants = await TeamsInfo.send_meeting_notification_async( + turn_context, + self.get_targeted_meeting_notification(from_user), + "meeting-id", + ) + + if from_user.name == "207": + assert ( + "failingid" + == failed_participants.recipients_failure_info[0].recipient_mri + ) + elif from_user.name == "202": + assert failed_participants is None + else: + raise ValueError( + f"Expected HttpOperationException with response status code {from_user.name}" + ) + + except ValueError as ve: + # Handle specific ValueError for invalid meeting ID + assert ( + str(ve) + == "This method is only valid within the scope of a MS Teams Meeting." + ) + + +class RosterHttpMessageHandler(BaseHTTPRequestHandler): + async def send_async(self): + # Set response headers + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + # Route handling based on path + path_handlers = { + "team-id": self.handle_team_id, + "v3/conversations": self.handle_v3_conversations, + "team-id/conversations": self.handle_team_id_conversations, + "team-id/members": self.handle_team_id_members, + "conversation-id/members": self.handle_conversation_id_members, + "team-id/members/id-1": self.handle_member_id, + "conversation-id/members/id-1": self.handle_member_id, + "v1/meetings/meetingId-1/participants/participantId-1?tenantId=tenantId-1": self.handle_meeting_participant, + "v1/meetings/meeting-id": self.handle_meeting_id, + "v1/meetings/meeting-id/notification": self.handle_meeting_notification, + } + + path = self.path + handler = next( + (handler for key, handler in path_handlers.items() if path.endswith(key)), + None, + ) + if handler: + response = await handler() + self.wfile.write(response.encode("utf-8")) + else: + self.send_response(404) + self.wfile.write(b"Not Found") + + async def handle_team_id(self): + content = { + "id": "team-id", + "name": "team-name", + "aadGroupId": "team-aadgroupid", + } + return json.dumps(content) + + async def handle_v3_conversations(self): + content = { + "id": "id123", + "serviceUrl": "https://serviceUrl/", + "activityId": "activityId123", + } + return json.dumps(content) + + async def handle_team_id_conversations(self): + content = { + "conversations": [ + {"id": "channel-id-1"}, + {"id": "channel-id-2", "name": "channel-name-2"}, + {"id": "channel-id-3", "name": "channel-name-3"}, + ] + } + return json.dumps(content) + + async def handle_team_id_members(self): + content = [ + { + "id": "id-1", + "objectId": "objectId-1", + "name": "name-1", + "givenName": "givenName-1", + "surname": "surname-1", + "email": "email-1", + "userPrincipalName": "userPrincipalName-1", + "tenantId": "tenantId-1", + }, + { + "id": "id-2", + "objectId": "objectId-2", + "name": "name-2", + "givenName": "givenName-2", + "surname": "surname-2", + "email": "email-2", + "userPrincipalName": "userPrincipalName-2", + "tenantId": "tenantId-2", + }, + ] + return json.dumps(content) + + async def handle_conversation_id_members(self): + content = [ + { + "id": "id-3", + "objectId": "objectId-3", + "name": "name-3", + "givenName": "givenName-3", + "surname": "surname-3", + "email": "email-3", + "userPrincipalName": "userPrincipalName-3", + "tenantId": "tenantId-3", + }, + { + "id": "id-4", + "objectId": "objectId-4", + "name": "name-4", + "givenName": "givenName-4", + "surname": "surname-4", + "email": "email-4", + "userPrincipalName": "userPrincipalName-4", + "tenantId": "tenantId-4", + }, + ] + return json.dumps(content) + + async def handle_member_id(self): + content = { + "id": "id-1", + "objectId": "objectId-1", + "name": "name-1", + "givenName": "givenName-1", + "surname": "surname-1", + "email": "email-1", + "userPrincipalName": "userPrincipalName-1", + "tenantId": "tenantId-1", + } + return json.dumps(content) + + async def handle_meeting_participant(self): + content = { + "user": {"userPrincipalName": "userPrincipalName-1"}, + "meeting": {"role": "Organizer"}, + "conversation": {"Id": "meetigConversationId-1"}, + } + return json.dumps(content) + + async def handle_meeting_id(self): + content = { + "details": {"id": "meeting-id"}, + "organizer": {"id": "organizer-id"}, + "conversation": {"id": "meetingConversationId-1"}, + } + return json.dumps(content) + + async def handle_meeting_notification(self): + content_length = int(self.headers["Content-Length"]) + response_body = self.rfile.read(content_length).decode("utf-8") + notification = json.loads(response_body) + obo = notification["ChannelData"]["OnBehalfOfList"][0] + + # hack displayname as expected status code, for testing + display_name = obo["DisplayName"] + if display_name == "207": + recipient_failure_info = { + "RecipientMri": next( + r + for r in notification["Value"]["Recipients"] + if r.lower() != obo["Mri"].lower() + ) + } + infos = {"RecipientsFailureInfo": [recipient_failure_info]} + response = json.dumps(infos) + status_code = 207 + elif display_name == "403": + response = json.dumps({"error": {"code": "BotNotInConversationRoster"}}) + status_code = 403 + elif display_name == "400": + response = json.dumps({"error": {"code": "BadSyntax"}}) + status_code = 400 + else: + response = "" + status_code = 202 + + self.send_response(status_code) + self.send_header("Content-type", "application/json") + self.end_headers() + return response diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py index 8fb944b16..5b4cec3d9 100644 --- a/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/__init__.py @@ -80,6 +80,20 @@ from ._models_py3 import UserMeetingDetails from ._models_py3 import TeamsMeetingMember from ._models_py3 import MeetingParticipantsEventDetails +from .meeting_notification_base import MeetingNotificationBase +from .meeting_notification_channel_data import MeetingNotificationChannelData +from .meeting_notification_recipient_failure_info import ( + MeetingNotificationRecipientFailureInfo, +) +from .meeting_notification_response import MeetingNotificationResponse +from .meeting_notification import MeetingNotification +from .meeting_stage_surface import MeetingStageSurface +from .meeting_tab_icon_surface import MeetingTabIconSurface +from .on_behalf_of import OnBehalfOf +from .surface_type import SurfaceType +from .surface import Surface +from .targeted_meeting_notification import TargetedMeetingNotification +from .targeted_meeting_notification_value import TargetedMeetingNotificationValue from ._models_py3 import ReadReceiptInfo from ._models_py3 import BotConfigAuth from ._models_py3 import ConfigAuthResponse @@ -166,6 +180,18 @@ "UserMeetingDetails", "TeamsMeetingMember", "MeetingParticipantsEventDetails", + "MeetingNotificationBase", + "MeetingNotificationChannelData", + "MeetingNotificationRecipientFailureInfo", + "MeetingNotificationResponse", + "MeetingNotification", + "MeetingStageSurface", + "MeetingTabIconSurface", + "OnBehalfOf", + "SurfaceType", + "Surface", + "TargetedMeetingNotification", + "TargetedMeetingNotificationValue", "ReadReceiptInfo", "BotConfigAuth", "ConfigAuthResponse", diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/content_type.py b/libraries/botbuilder-schema/botbuilder/schema/teams/content_type.py new file mode 100644 index 000000000..9f5f41e75 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/content_type.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ContentType(str, Enum): + + UNKNOWN = "unknown" + TASK = "task" diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification.py new file mode 100644 index 000000000..dfd000aea --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification.py @@ -0,0 +1,29 @@ +from typing import Optional, TypeVar, Generic +import json +from .meeting_notification_base import MeetingNotificationBase + +T = TypeVar("T") + + +class MeetingNotification(Generic[T], MeetingNotificationBase): + """ + Specifies Bot meeting notification including meeting notification value. + """ + + def __init__(self, value: Optional[T] = None, type: Optional[str] = None): + super().__init__(type=type) + self.value = value + + def to_json(self) -> str: + """ + Converts the MeetingNotification object to JSON. + :return: JSON representation of the MeetingNotification object. + """ + value_dict = ( + self.value.to_dict() + if self.value and hasattr(self.value, "to_dict") + else self.value + ) + return json.dumps( + {"type": self.type, "value": value_dict}, sort_keys=True, indent=4 + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_base.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_base.py new file mode 100644 index 000000000..ea325ca5c --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_base.py @@ -0,0 +1,18 @@ +from typing import Optional +import json + + +class MeetingNotificationBase: + """ + Specifies Bot meeting notification base including channel data and type. + """ + + def __init__(self, type: Optional[str] = None): + self.type = type + + def to_json(self) -> str: + """ + Converts the MeetingNotificationBase object to JSON. + :return: JSON representation of the MeetingNotificationBase object. + """ + return json.dumps({"type": self.type}, sort_keys=True, indent=4) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_channel_data.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_channel_data.py new file mode 100644 index 000000000..1615b9e7e --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_channel_data.py @@ -0,0 +1,26 @@ +import json +from typing import List, Optional +from .on_behalf_of import OnBehalfOf + + +class MeetingNotificationChannelData: + """ + Specify Teams Bot meeting notification channel data. + """ + + def __init__(self, on_behalf_of_list: Optional[List[OnBehalfOf]] = None): + self.on_behalf_of_list = ( + on_behalf_of_list if on_behalf_of_list is not None else [] + ) + + def to_json(self) -> str: + """ + Converts the MeetingNotificationChannelData object to JSON. + + :return: JSON representation of the MeetingNotificationChannelData object. + """ + return json.dumps( + {"OnBehalfOf": [item.to_dict() for item in self.on_behalf_of_list]}, + sort_keys=True, + indent=4, + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_recipient_failure_info.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_recipient_failure_info.py new file mode 100644 index 000000000..1d96a25b0 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_recipient_failure_info.py @@ -0,0 +1,25 @@ +import json + + +class MeetingNotificationRecipientFailureInfo: + recipient_mri: str + error_code: str + failure_reason: str + + def to_json(self): + return json.dumps( + { + "recipientMri": self.recipient_mri, + "errorcode": self.error_code, + "failureReason": self.failure_reason, + } + ) + + @staticmethod + def from_json(json_str): + data = json.loads(json_str) + return MeetingNotificationRecipientFailureInfo( + recipient_mri=data.get("recipientMri"), + error_code=data.get("errorcode"), + failure_reason=data.get("failureReason"), + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_response.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_response.py new file mode 100644 index 000000000..7fced9050 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_notification_response.py @@ -0,0 +1,21 @@ +import json +from typing import List + +from botbuilder.schema.teams.meeting_notification_recipient_failure_info import ( + MeetingNotificationRecipientFailureInfo, +) + + +class MeetingNotificationResponse: + def __init__(self): + self.recipients_failure_info: List[MeetingNotificationRecipientFailureInfo] = [] + + @property + def recipients_failure_info(self) -> List[MeetingNotificationRecipientFailureInfo]: + return self._recipients_failure_info + + @recipients_failure_info.setter + def recipients_failure_info( + self, value: List[MeetingNotificationRecipientFailureInfo] + ): + self._recipients_failure_info = value diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_stage_surface.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_stage_surface.py new file mode 100644 index 000000000..2a62f99b0 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_stage_surface.py @@ -0,0 +1,13 @@ +from typing import Generic, TypeVar, Optional +from botbuilder.schema.teams.content_type import ContentType +from botbuilder.schema.teams.surface import Surface +from botbuilder.schema.teams.surface_type import SurfaceType + +T = TypeVar("T") + + +class MeetingStageSurface(Generic[T], Surface): + def __init__(self): + super().__init__(SurfaceType.MEETING_STAGE) + self.content_type = ContentType.TASK + self.content: Optional[T] = None diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_tab_icon_surface.py b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_tab_icon_surface.py new file mode 100644 index 000000000..ff67f312c --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/meeting_tab_icon_surface.py @@ -0,0 +1,20 @@ +from .surface import Surface +from .surface_type import SurfaceType + + +class MeetingTabIconSurface(Surface): + """ + Specifies meeting tab icon surface. + """ + + tab_entity_id: str = None + + def __init__(self): + super().__init__(SurfaceType.MEETING_TAB_ICON) + + def to_dict(self): + """ + Converts the MeetingTabIconSurface object to a dictionary. + :return: Dictionary representation of the MeetingTabIconSurface object. + """ + return {"type": self.type.value, "tabEntityId": self.tab_entity_id} diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/on_behalf_of.py b/libraries/botbuilder-schema/botbuilder/schema/teams/on_behalf_of.py new file mode 100644 index 000000000..bb42863ba --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/on_behalf_of.py @@ -0,0 +1,37 @@ +import json +from typing import Optional + + +class OnBehalfOf: + """ + Specifies attribution for notifications. + """ + + def __init__( + self, + item_id: int = 0, + mention_type: str = "person", + mri: Optional[str] = None, + display_name: Optional[str] = None, + ): + self.item_id = item_id + self.mention_type = mention_type + self.mri = mri + self.display_name = display_name + + def to_json(self) -> str: + """ + Converts the OnBehalfOf object to JSON. + + :return: JSON representation of the OnBehalfOf object. + """ + return json.dumps( + { + "itemid": self.item_id, + "mentionType": self.mention_type, + "mri": self.mri, + "displayName": self.display_name, + }, + sort_keys=True, + indent=4, + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/surface.py b/libraries/botbuilder-schema/botbuilder/schema/teams/surface.py new file mode 100644 index 000000000..be911d3af --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/surface.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum +from typing import Type +import json +from botbuilder.schema.teams.surface_type import SurfaceType + + +class Surface: + + def __init__(self, type: SurfaceType): + """ + Initializes a new instance of the Surface class. + + :param type: Type of Surface. + """ + self.type = type + + @property + def type(self) -> SurfaceType: + return self._type + + @type.setter + def type(self, value: SurfaceType): + self._type = value + + def to_json(self) -> str: + """ + Converts the Surface object to JSON. + + :return: JSON representation of the Surface object. + """ + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/surface_type.py b/libraries/botbuilder-schema/botbuilder/schema/teams/surface_type.py new file mode 100644 index 000000000..2674285ed --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/surface_type.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class SurfaceType(str, Enum): + """ + Defines Teams Surface type for use with a Surface object. + """ + + UNKNOWN = "Unknown" + MEETING_STAGE = "MeetingStage" + MEETING_TAB_ICON = "MeetingTabIcon" diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/targeted_meeting_notification.py b/libraries/botbuilder-schema/botbuilder/schema/teams/targeted_meeting_notification.py new file mode 100644 index 000000000..280000744 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/targeted_meeting_notification.py @@ -0,0 +1,39 @@ +from typing import Optional +import json +from .meeting_notification import MeetingNotification +from .targeted_meeting_notification_value import TargetedMeetingNotificationValue +from .meeting_notification_channel_data import MeetingNotificationChannelData + + +class TargetedMeetingNotification( + MeetingNotification[TargetedMeetingNotificationValue] +): + """ + Specifies Teams targeted meeting notification. + """ + + def __init__( + self, + value: Optional[TargetedMeetingNotificationValue] = None, + channel_data: Optional[MeetingNotificationChannelData] = None, + type: Optional[str] = None, + ): + super().__init__(value=value, type=type) + self.channel_data = channel_data + + def to_json(self) -> str: + """ + Converts the TargetedMeetingNotification object to JSON. + :return: JSON representation of the TargetedMeetingNotification object. + """ + return json.dumps( + { + "type": self.type, + "value": self.value.to_dict() if self.value else None, + "channelData": ( + self.channel_data.to_dict() if self.channel_data else None + ), + }, + sort_keys=True, + indent=4, + ) diff --git a/libraries/botbuilder-schema/botbuilder/schema/teams/targeted_meeting_notification_value.py b/libraries/botbuilder-schema/botbuilder/schema/teams/targeted_meeting_notification_value.py new file mode 100644 index 000000000..cb0823ab2 --- /dev/null +++ b/libraries/botbuilder-schema/botbuilder/schema/teams/targeted_meeting_notification_value.py @@ -0,0 +1,31 @@ +from typing import List, Optional +import json +from .surface import Surface + + +class TargetedMeetingNotificationValue: + """ + Specifies the targeted meeting notification value, including recipients and surfaces. + """ + + def __init__( + self, + recipients: Optional[List[str]] = None, + surfaces: Optional[List[Surface]] = None, + ): + self.recipients = recipients if recipients is not None else [] + self.surfaces = surfaces if surfaces is not None else [] + + def to_json(self) -> str: + """ + Converts the TargetedMeetingNotificationValue object to JSON. + :return: JSON representation of the TargetedMeetingNotificationValue object. + """ + return json.dumps( + { + "recipients": self.recipients, + "surfaces": [surface.to_dict() for surface in self.surfaces], + }, + sort_keys=True, + indent=4, + ) 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..a2c7a45a6 100644 --- a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations.py @@ -5,9 +5,17 @@ # license information. # -------------------------------------------------------------------------- +import logging from msrest.pipeline import ClientRawResponse from msrest.exceptions import HttpOperationError +from botbuilder.schema.teams.meeting_notification_base import MeetingNotificationBase +from typing import Any, Dict, List, Optional +from botbuilder.schema.teams import MeetingNotificationBase +from botbuilder.schema.teams.meeting_notification_response import ( + MeetingNotificationResponse, +) + from ... import models @@ -148,7 +156,7 @@ def fetch_participant( tenant_id: str, custom_headers=None, raw=False, - **operation_config + **operation_config, ): """Fetches Teams meeting participant details. @@ -266,3 +274,53 @@ def fetch_meeting( return deserialized fetch_participant.metadata = {"url": "/v1/meetings/{meetingId}"} + + @staticmethod + async def send_meeting_notification_message_async( + meeting_id: str, + notification: MeetingNotificationBase, + custom_headers=None, + ) -> MeetingNotificationResponse: + if meeting_id is None: + raise ValueError("meeting_id cannot be null") + + # Using static method for trace_activity + invocation_id = TeamsOperations.trace_activity( + "SendMeetingNotification", {"meeting_id": meeting_id} + ) + + # Construct URL + url = f"v1/meetings/{meeting_id}/notification" + + response = await TeamsOperations.get_response_async( + url, + "POST", + invocation_id, + content=notification, + custom_headers=custom_headers, + ) + return response + + @staticmethod + def trace_activity(operation_name: str, content: Any) -> str: + logging.basicConfig(level=logging.DEBUG) + invocation_id = None + tracing_parameters: Dict[str, Any] = {} + for attr in dir(content): + if not attr.startswith("__") and not callable(getattr(content, attr)): + tracing_parameters[attr] = getattr(content, attr) + logging.debug( + "Operation: %s, Parameters: %s", operation_name, tracing_parameters + ) + return invocation_id + + @staticmethod + async def get_response_async( + api_url: str, + http_method: str, + invocation_id: Optional[str] = None, + content: Optional[Any] = None, + custom_header=None, + ) -> None: + should_trace = invocation_id is not None + return None diff --git a/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations_extensions.py b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations_extensions.py new file mode 100644 index 000000000..655281aa0 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/teams/operations/teams_operations_extensions.py @@ -0,0 +1,71 @@ +import aiohttp +from typing import Optional +from botbuilder.schema.teams._models_py3 import ( + ConversationList, + MeetingInfo, + TeamDetails, + TeamsMeetingParticipant, +) +from botbuilder.schema.teams.meeting_notification_base import MeetingNotificationBase +from botbuilder.schema.teams.meeting_notification_response import ( + MeetingNotificationResponse, +) +from botframework.connector.teams.operations.teams_operations import TeamsOperations + + +class TeamsOperationsExtensions: + @staticmethod + async def fetch_channel_list_async( + operations: TeamsOperations, team_id: str + ) -> ConversationList: + result = await operations.get_teams_channels(team_id) + return result.body + + @staticmethod + async def fetch_team_details_async( + operations: TeamsOperations, team_id: str + ) -> TeamDetails: + result = await operations.get_team_details(team_id) + return result.body + + @staticmethod + async def fetch_meeting_info_async( + operations: TeamsOperations, meeting_id: str + ) -> MeetingInfo: + if not isinstance(operations, TeamsOperations): + raise ValueError( + "TeamsOperations with GetMeetingInfoWithHttpMessages is required for FetchMeetingInfo." + ) + result = await operations.fetch_meeting(meeting_id) + return result.body + + @staticmethod + async def fetch_participant_async( + operations: TeamsOperations, + meeting_id: str, + participant_id: str, + tenant_id: str, + ) -> TeamsMeetingParticipant: + if not isinstance(operations, TeamsOperations): + raise ValueError( + "TeamsOperations with GetParticipantWithHttpMessages is required for FetchParticipant." + ) + result = await operations.fetch_participant( + meeting_id, participant_id, tenant_id + ) + return result.body + + @staticmethod + async def send_meeting_notification_async( + operations: TeamsOperations, + meeting_id: str, + notification: MeetingNotificationBase, + ) -> MeetingNotificationResponse: + if not isinstance(operations, TeamsOperations): + raise ValueError( + "TeamsOperations with SendMeetingNotificationWithHttpMessages is required for SendMeetingNotification." + ) + result = await operations.send_meeting_notification_message_async( + meeting_id, notification + ) + return result.body