diff --git a/README.md b/README.md index 68162493..80d99c4c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ cookiecutter templates/test -o tests - [`@examples/ai-test`](./examples/ai-test/README.md) - [`@examples/stream`](./examples/stream/README.md) - [`@examples/oauth`](./examples/oauth/README.md) +- [`@examples/meetings`](./examples/meetings/README.md) ## Links diff --git a/examples/meetings/README.md b/examples/meetings/README.md new file mode 100644 index 00000000..064bddb5 --- /dev/null +++ b/examples/meetings/README.md @@ -0,0 +1,51 @@ +# Sample: Meetings + +This sample demonstrates how to handle real-time updates for meeting events and meeting participant events. + +## Manifest Requirements + +There are a few requirements in the Teams app manifest (manifest.json) to support these events. + +1) The `scopes` section must include `team`, and `groupChat`: + +```json + "bots": [ + { + "botId": "", + "scopes": [ + "team", + "personal", + "groupChat" + ], + "isNotificationOnly": false + } + ] +``` + +2) In the authorization section, make sure to specify the following resource-specific permissions: + +```json + "authorization":{ + "permissions":{ + "resourceSpecific":[ + { + "name":"OnlineMeetingParticipant.Read.Chat", + "type":"Application" + }, + { + "name":"ChannelMeeting.ReadBasic.Group", + "type":"Application" + }, + { + "name":"OnlineMeeting.ReadBasic.Chat", + "type":"Application" + } + ] + } + } +``` + +### Teams Developer Portal: Bot Configuration + +For your Bot, make sure the [Meeting Event Subscriptions](https://learn.microsoft.com/en-us/microsoftteams/platform/apps-in-teams-meetings/meeting-apps-apis?branch=pr-en-us-8455&tabs=channel-meeting%2Cguest-user%2Cone-on-one-call%2Cdotnet3%2Cdotnet2%2Cdotnet%2Cparticipant-join-event%2Cparticipant-join-event1#receive-meeting-participant-events) are checked. +This enables you to receive the Meeting Participant events. \ No newline at end of file diff --git a/examples/meetings/pyproject.toml b/examples/meetings/pyproject.toml new file mode 100644 index 00000000..3dc36e81 --- /dev/null +++ b/examples/meetings/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "meetings" +version = "0.1.0" +description = "Meetings app" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", + "microsoft-teams-api", + "microsoft-teams-cards", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } +microsoft-teams-api = { workspace = true } +microsoft-teams-cards = { workspace = true } diff --git a/examples/meetings/src/main.py b/examples/meetings/src/main.py new file mode 100644 index 00000000..a6fa97ed --- /dev/null +++ b/examples/meetings/src/main.py @@ -0,0 +1,102 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio + +from microsoft_teams.api.activities.event import ( + MeetingEndEventActivity, + MeetingParticipantJoinEventActivity, + MeetingParticipantLeaveEventActivity, + MeetingStartEventActivity, +) +from microsoft_teams.api.activities.message import MessageActivity +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.apps import ActivityContext, App +from microsoft_teams.cards import AdaptiveCard, OpenUrlAction, TextBlock + +app = App() + + +@app.on_meeting_start +async def handle_meeting_start(ctx: ActivityContext[MeetingStartEventActivity]): + meeting_data = ctx.activity.value + start_time = meeting_data.start_time.strftime("%c") + + card = AdaptiveCard( + body=[ + TextBlock( + text=f"'{meeting_data.title}' has started at {start_time}.", + wrap=True, + weight="Bolder", + ) + ], + actions=[OpenUrlAction(url=meeting_data.join_url, title="Join the meeting")], + ) + + await ctx.send(card) + + +@app.on_meeting_end +async def handle_meeting_end(ctx: ActivityContext[MeetingEndEventActivity]): + meeting_data = ctx.activity.value + end_time = meeting_data.end_time.strftime("%c") + card = AdaptiveCard( + body=[ + TextBlock( + text=f"'{meeting_data.title}' has ended at {end_time}.", + wrap=True, + weight="Bolder", + ) + ] + ) + + await ctx.send(card) + + +@app.on_meeting_participant_join +async def handle_meeting_participant_join(ctx: ActivityContext[MeetingParticipantJoinEventActivity]): + meeting_data = ctx.activity.value + member = meeting_data.members[0].user.name + role = meeting_data.members[0].meeting.role if hasattr(meeting_data.members[0].meeting, "role") else "a participant" + + card = AdaptiveCard( + body=[ + TextBlock( + text=f"{member} has joined the meeting as {role}.", + wrap=True, + weight="Bolder", + ) + ] + ) + + await ctx.send(card) + + +@app.on_meeting_participant_leave +async def handle_meeting_participant_leave(ctx: ActivityContext[MeetingParticipantLeaveEventActivity]): + meeting_data = ctx.activity.value + member = meeting_data.members[0].user.name + + card = AdaptiveCard( + body=[ + TextBlock( + text=f"{member} has left the meeting.", + wrap=True, + weight="Bolder", + ) + ] + ) + + await ctx.send(card) + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + await ctx.reply(TypingActivityInput()) + await ctx.send("Welcome to the meetings sample! This app will notify you for meeting events.") + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/packages/api/src/microsoft_teams/api/activities/event/meeting_end.py b/packages/api/src/microsoft_teams/api/activities/event/meeting_end.py index 31610c03..779b8876 100644 --- a/packages/api/src/microsoft_teams/api/activities/event/meeting_end.py +++ b/packages/api/src/microsoft_teams/api/activities/event/meeting_end.py @@ -6,23 +6,25 @@ from datetime import datetime from typing import Literal +from pydantic import Field + from ...models import ActivityBase, CustomBaseModel class MeetingEndEventValue(CustomBaseModel): - id: str + id: str = Field(alias="Id") """The meeting's Id, encoded as a BASE64 string.""" - meeting_type: str + meeting_type: str = Field(alias="MeetingType") """Type of the meeting""" - join_url: str + join_url: str = Field(alias="JoinUrl") """URL to join the meeting""" - title: str + title: str = Field(alias="Title") """Title of the meeting.""" - end_time: datetime + end_time: datetime = Field(alias="EndTime") """Timestamp for meeting end, in UTC.""" diff --git a/packages/api/src/microsoft_teams/api/activities/event/meeting_participant.py b/packages/api/src/microsoft_teams/api/activities/event/meeting_participant.py index cdc28424..1bb55ba6 100644 --- a/packages/api/src/microsoft_teams/api/activities/event/meeting_participant.py +++ b/packages/api/src/microsoft_teams/api/activities/event/meeting_participant.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import List, Literal +from typing import List, Literal, Optional from ...models import ActivityBase, CustomBaseModel, TeamsChannelAccount @@ -16,8 +16,8 @@ class MeetingParticipantInfo(CustomBaseModel): in_meeting: bool """Indicates whether the participant is currently in the meeting.""" - role: str - """The role of the participant in the meeting""" + role: Optional[str] = None + """The role of the participant in the meeting. Optional field.""" class MeetingParticipant(CustomBaseModel): diff --git a/packages/api/src/microsoft_teams/api/activities/event/meeting_start.py b/packages/api/src/microsoft_teams/api/activities/event/meeting_start.py index 69c1efde..3e898d50 100644 --- a/packages/api/src/microsoft_teams/api/activities/event/meeting_start.py +++ b/packages/api/src/microsoft_teams/api/activities/event/meeting_start.py @@ -6,6 +6,8 @@ from datetime import datetime from typing import Literal +from pydantic import Field + from ...models import ActivityBase, CustomBaseModel @@ -14,27 +16,27 @@ class MeetingStartEventValue(CustomBaseModel): The value associated with a meeting start event in Microsoft Teams. """ - id: str + id: str = Field(alias="Id") """ The meeting's Id, encoded as a BASE64 string. """ - meeting_type: str + meeting_type: str = Field(alias="MeetingType") """ Type of the meeting """ - join_url: str + join_url: str = Field(alias="JoinUrl") """ URL to join the meeting """ - title: str + title: str = Field(alias="Title") """ The title of the meeting """ - start_time: datetime + start_time: datetime = Field(alias="StartTime") """ Timestamp for meeting start, in UTC. """ diff --git a/packages/api/tests/unit/test_meeting_end_event.py b/packages/api/tests/unit/test_meeting_end_event.py new file mode 100644 index 00000000..a6877b8e --- /dev/null +++ b/packages/api/tests/unit/test_meeting_end_event.py @@ -0,0 +1,37 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from datetime import datetime + +import pytest +from microsoft_teams.api.activities.event.meeting_end import ( + MeetingEndEventValue, +) + + +@pytest.mark.unit +class TestMeetingEndEventValue: + """Unit tests for MeetingEndEventValue serialization.""" + + def test_deserialization_from_aliased_fields(self): + """Test that MeetingEndEventValue correctly deserializes from aliased field names""" + data = { + "Id": "meeting-123-base64", + "MeetingType": "Scheduled", + "JoinUrl": "https://teams.microsoft.com/join/meeting-123", + "Title": "Sprint Planning Meeting", + "EndTime": "2024-01-15T15:30:00Z", + } + + event_value = MeetingEndEventValue.model_validate(data) + assert event_value.id == "meeting-123-base64" + assert event_value.meeting_type == "Scheduled" + assert event_value.join_url == "https://teams.microsoft.com/join/meeting-123" + assert event_value.title == "Sprint Planning Meeting" + assert isinstance(event_value.end_time, datetime) + assert event_value.end_time.year == 2024 + assert event_value.end_time.month == 1 + assert event_value.end_time.day == 15 diff --git a/packages/api/tests/unit/test_meeting_start_event.py b/packages/api/tests/unit/test_meeting_start_event.py new file mode 100644 index 00000000..da82ea02 --- /dev/null +++ b/packages/api/tests/unit/test_meeting_start_event.py @@ -0,0 +1,38 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from datetime import datetime + +import pytest +from microsoft_teams.api.activities.event.meeting_start import ( + MeetingStartEventValue, +) + + +@pytest.mark.unit +class TestMeetingStartEventValue: + """Unit tests for MeetingStartEventValue serialization.""" + + def test_deserialization_from_aliased_fields(self): + """Test that MeetingStartEventValue correctly deserializes from aliased field names""" + data = { + "Id": "meeting-123-base64", + "MeetingType": "Scheduled", + "JoinUrl": "https://teams.microsoft.com/join/meeting-123", + "Title": "Sprint Planning Meeting", + "StartTime": "2024-01-15T14:30:00Z", + } + + event_value = MeetingStartEventValue.model_validate(data) + + assert event_value.id == "meeting-123-base64" + assert event_value.meeting_type == "Scheduled" + assert event_value.join_url == "https://teams.microsoft.com/join/meeting-123" + assert event_value.title == "Sprint Planning Meeting" + assert isinstance(event_value.start_time, datetime) + assert event_value.start_time.year == 2024 + assert event_value.start_time.month == 1 + assert event_value.start_time.day == 15 diff --git a/uv.lock b/uv.lock index 9a30754a..aa67582e 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ members = [ "graph", "mcp-client", "mcp-server", + "meetings", "message-extensions", "microsoft-teams-a2a", "microsoft-teams-ai", @@ -1333,6 +1334,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "meetings" +version = "0.1.0" +source = { virtual = "examples/meetings" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, + { name = "microsoft-teams-cards" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, + { name = "microsoft-teams-cards", editable = "packages/cards" }, +] + [[package]] name = "message-extensions" version = "0.1.0"