Skip to content
30 changes: 29 additions & 1 deletion libraries/botbuilder-core/botbuilder/core/teams/teams_info.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,6 +30,9 @@
TeamsPagedMembersResult,
TeamsMeetingParticipant,
)
from botframework.connector.teams.operations.teams_operations_extensions import (
TeamsOperationsExtensions,
)


class TeamsInfo:
Expand Down Expand Up @@ -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
)
295 changes: 294 additions & 1 deletion libraries/botbuilder-core/tests/teams/test_teams_info.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,7 +44,6 @@
ChannelAccount,
ConversationAccount,
)
from simple_adapter_with_create_conversation import SimpleAdapterWithCreateConversation

ACTIVITY = Activity(
id="1234",
Expand Down Expand Up @@ -234,13 +266,47 @@ 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):
await super().on_turn(turn_context)

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")
Expand All @@ -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
Loading