From 87ef822bd018846f6ab756300225712ac0d686f1 Mon Sep 17 00:00:00 2001 From: lilydu Date: Wed, 25 Mar 2026 10:56:05 -0700 Subject: [PATCH 1/5] claude client gaps --- .../api/clients/conversation/__init__.py | 4 +- .../api/clients/conversation/activity.py | 8 ++-- .../api/clients/conversation/client.py | 30 +------------ .../api/clients/conversation/member.py | 10 ----- .../api/clients/conversation/params.py | 29 +----------- .../api/clients/team/client.py | 2 +- .../src/microsoft_teams/api/models/account.py | 12 +++-- .../api/models/team_details.py | 3 ++ packages/api/tests/conftest.py | 37 ++++++--------- .../tests/unit/test_conversation_client.py | 45 +------------------ .../apps/contexts/function_context.py | 1 - .../apps/routing/activity_context.py | 1 - 12 files changed, 36 insertions(+), 146 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/__init__.py b/packages/api/src/microsoft_teams/api/clients/conversation/__init__.py index dc421bc9..8dba1468 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/__init__.py @@ -6,13 +6,11 @@ from .activity import ConversationActivityClient from .client import ConversationClient from .member import ConversationMemberClient -from .params import CreateConversationParams, GetConversationsParams, GetConversationsResponse +from .params import CreateConversationParams __all__ = [ "ConversationActivityClient", "ConversationClient", "ConversationMemberClient", "CreateConversationParams", - "GetConversationsParams", - "GetConversationsResponse", ] diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py index e72d1104..a1ee86ef 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -9,7 +9,7 @@ from microsoft_teams.common.http import Client from ...activities import ActivityParams, SentActivity -from ...models import Account +from ...models import TeamsChannelAccount from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -111,7 +111,7 @@ async def delete(self, conversation_id: str, activity_id: str) -> None: """ await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}") - async def get_members(self, conversation_id: str, activity_id: str) -> List[Account]: + async def get_members(self, conversation_id: str, activity_id: str) -> List[TeamsChannelAccount]: """ Get the members associated with an activity. @@ -120,12 +120,12 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Acco activity_id: The ID of the activity Returns: - List of Account objects representing the activity members + List of TeamsChannelAccount objects representing the activity members """ response = await self.http.get( f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/members" ) - return [Account.model_validate(member) for member in response.json()] + return [TeamsChannelAccount.model_validate(member) for member in response.json()] @experimental("ExperimentalTeamsTargeted") async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity: diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/client.py b/packages/api/src/microsoft_teams/api/clients/conversation/client.py index 7ad5aa01..84e4a9c2 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import Dict, Optional, Union +from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions @@ -12,11 +12,7 @@ from ..base_client import BaseClient from .activity import ActivityParams, ConversationActivityClient from .member import ConversationMemberClient -from .params import ( - CreateConversationParams, - GetConversationsParams, - GetConversationsResponse, -) +from .params import CreateConversationParams class ConversationOperations: @@ -67,9 +63,6 @@ async def get_all(self): async def get(self, member_id: str): return await self._client.members_client.get_by_id(self._conversation_id, member_id) - async def delete(self, member_id: str) -> None: - await self._client.members_client.delete(self._conversation_id, member_id) - class ConversationClient(BaseClient): """Client for managing Teams conversations.""" @@ -137,25 +130,6 @@ def members(self, conversation_id: str) -> MemberOperations: """ return MemberOperations(self, conversation_id) - async def get(self, params: Optional[GetConversationsParams] = None) -> GetConversationsResponse: - """Get a list of conversations. - - Args: - params: Optional parameters for getting conversations. - - Returns: - A response containing the list of conversations and a continuation token. - """ - query_params: Dict[str, str] = {} - if params and params.continuation_token: - query_params["continuationToken"] = params.continuation_token - - response = await self.http.get( - f"{self.service_url}/v3/conversations", - params=query_params, - ) - return GetConversationsResponse.model_validate(response.json()) - async def create(self, params: CreateConversationParams) -> ConversationResource: """Create a new conversation. diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/member.py b/packages/api/src/microsoft_teams/api/clients/conversation/member.py index f46692d3..9f621068 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/member.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/member.py @@ -60,13 +60,3 @@ async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelA """ response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members/{member_id}") return TeamsChannelAccount.model_validate(response.json()) - - async def delete(self, conversation_id: str, member_id: str) -> None: - """ - Remove a member from a conversation. - - Args: - conversation_id: The ID of the conversation - member_id: The ID of the member to remove - """ - await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/members/{member_id}") diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/params.py b/packages/api/src/microsoft_teams/api/clients/conversation/params.py index c1f3c380..f750e0b6 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/params.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/params.py @@ -5,16 +5,10 @@ from typing import Any, Dict, List, Optional -from ...models import Account, Conversation, CustomBaseModel +from ...models import Account, CustomBaseModel from .activity import ActivityParams -class GetConversationsParams(CustomBaseModel): - """Parameters for getting conversations.""" - - continuation_token: Optional[str] = None - - class CreateConversationParams(CustomBaseModel): """Parameters for creating a conversation.""" @@ -22,18 +16,10 @@ class CreateConversationParams(CustomBaseModel): """ Whether this is a group conversation. """ - bot: Optional[Account] = None - """ - The bot account to add to the conversation. - """ members: Optional[List[Account]] = None """ The members to add to the conversation. """ - topic_name: Optional[str] = None - """ - The topic name for the conversation. - """ tenant_id: Optional[str] = None """ The tenant ID for the conversation. @@ -46,16 +32,3 @@ class CreateConversationParams(CustomBaseModel): """ The channel-specific data for the conversation. """ - - -class GetConversationsResponse(CustomBaseModel): - """Response from getting conversations.""" - - continuation_token: Optional[str] = None - """ - Token for getting the next page of conversations. - """ - conversations: List[Conversation] = [] - """ - List of conversations. - """ diff --git a/packages/api/src/microsoft_teams/api/clients/team/client.py b/packages/api/src/microsoft_teams/api/clients/team/client.py index ddda3b10..7ff53140 100644 --- a/packages/api/src/microsoft_teams/api/clients/team/client.py +++ b/packages/api/src/microsoft_teams/api/clients/team/client.py @@ -56,4 +56,4 @@ async def get_conversations(self, id: str) -> List[ChannelInfo]: List of channel information. """ response = await self.http.get(f"{self.service_url}/v3/teams/{id}/conversations") - return [ChannelInfo.model_validate(channel) for channel in response.json()] + return [ChannelInfo.model_validate(channel) for channel in response.json()["conversations"]] diff --git a/packages/api/src/microsoft_teams/api/models/account.py b/packages/api/src/microsoft_teams/api/models/account.py index 5f3100c5..604a509e 100644 --- a/packages/api/src/microsoft_teams/api/models/account.py +++ b/packages/api/src/microsoft_teams/api/models/account.py @@ -5,6 +5,8 @@ from typing import Any, Dict, Literal, Optional +from pydantic import AliasChoices, Field + from .custom_base_model import CustomBaseModel AccountRole = Literal["user", "bot"] @@ -59,13 +61,17 @@ class TeamsChannelAccount(CustomBaseModel): """ Display-friendly name of the user or bot. """ - aad_object_id: Optional[str] = None + aad_object_id: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("aadObjectId", "objectId"), + serialization_alias="aadObjectId", + ) """ The user's Object ID in Azure Active Directory (AAD). """ - role: Optional[AccountRole] = None + role: Optional[str] = Field(default=None, alias="userRole") """ - Role of the user (e.g., 'user' or 'bot'). + Role of the user in the conversation. """ given_name: Optional[str] = None """ diff --git a/packages/api/src/microsoft_teams/api/models/team_details.py b/packages/api/src/microsoft_teams/api/models/team_details.py index 70d30ba0..527cbaa4 100644 --- a/packages/api/src/microsoft_teams/api/models/team_details.py +++ b/packages/api/src/microsoft_teams/api/models/team_details.py @@ -30,3 +30,6 @@ class TeamDetails(CustomBaseModel): member_count: Optional[int] = None "Count of members in the team." + + tenant_id: Optional[str] = None + "Azure Active Directory (AAD) tenant ID for the team." diff --git a/packages/api/tests/conftest.py b/packages/api/tests/conftest.py index 70196944..b63b7fa1 100644 --- a/packages/api/tests/conftest.py +++ b/packages/api/tests/conftest.py @@ -86,18 +86,20 @@ def handler(request: httpx.Request) -> httpx.Response: "tokenExchangeResource": {"id": "mock_resource_id"}, } elif "/v3/teams/" in str(request.url) and "/conversations" in str(request.url): - response_data = [ - { - "id": "mock_channel_id_1", - "name": "General", - "type": "standard", - }, - { - "id": "mock_channel_id_2", - "name": "Random", - "type": "standard", - }, - ] + response_data = { + "conversations": [ + { + "id": "mock_channel_id_1", + "name": "General", + "type": "standard", + }, + { + "id": "mock_channel_id_2", + "name": "Random", + "type": "standard", + }, + ] + } elif "/conversations/" in str(request.url) and str(request.url).endswith("/members"): response_data = [ { @@ -112,17 +114,6 @@ def handler(request: httpx.Request) -> httpx.Response: "name": "Mock Member", "aadObjectId": "mock_aad_object_id", } - elif "/conversations" in str(request.url) and request.method == "GET": - response_data = { - "conversations": [ - { - "id": "mock_conversation_id", - "conversationType": "personal", - "isGroup": True, - } - ], - "continuationToken": "mock_continuation_token", - } elif "/conversations" in str(request.url) and request.method == "POST": # Parse request body to check if activity is present try: diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index 86053b3d..15058bd7 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -6,10 +6,7 @@ import pytest from microsoft_teams.api.clients.conversation import ConversationClient -from microsoft_teams.api.clients.conversation.params import ( - CreateConversationParams, - GetConversationsParams, -) +from microsoft_teams.api.clients.conversation.params import CreateConversationParams from microsoft_teams.api.models import ConversationResource, TeamsChannelAccount from microsoft_teams.common.http import Client, ClientOptions @@ -47,30 +44,6 @@ def test_conversation_client_initialization_with_options(self): assert client.http is not None assert client.service_url == service_url - @pytest.mark.asyncio - async def test_get_conversations(self, mock_http_client): - """Test getting conversations.""" - service_url = "https://test.service.url" - client = ConversationClient(service_url, mock_http_client) - - params = GetConversationsParams(continuation_token="test_token") - response = await client.get(params) - - assert response.conversations is not None - assert isinstance(response.conversations, list) - assert response.continuation_token is not None - - @pytest.mark.asyncio - async def test_get_conversations_without_params(self, mock_http_client): - """Test getting conversations without parameters.""" - service_url = "https://test.service.url" - client = ConversationClient(service_url, mock_http_client) - - response = await client.get() - - assert response.conversations is not None - assert isinstance(response.conversations, list) - @pytest.mark.asyncio async def test_create_conversation(self, mock_http_client, mock_account, mock_activity): """Test creating a conversation with an activity.""" @@ -79,9 +52,7 @@ async def test_create_conversation(self, mock_http_client, mock_account, mock_ac params = CreateConversationParams( is_group=True, - bot=mock_account, members=[mock_account], - topic_name="Test Conversation", tenant_id="test_tenant_id", activity=mock_activity, channel_data={"custom": "data"}, @@ -101,9 +72,7 @@ async def test_create_conversation_without_activity(self, mock_http_client, mock params = CreateConversationParams( is_group=True, - bot=mock_account, members=[mock_account], - topic_name="Test Conversation", tenant_id="test_tenant_id", ) @@ -292,18 +261,6 @@ async def test_member_get(self, mock_http_client): assert result.name == "Mock Member" assert result.aad_object_id == "mock_aad_object_id" - async def test_member_delete(self, mock_http_client): - """Test deleting a member.""" - service_url = "https://test.service.url" - client = ConversationClient(service_url, mock_http_client) - - conversation_id = "test_conversation_id" - member_id = "test_member_id" - members = client.members(conversation_id) - - # Should not raise an exception - await members.delete(member_id) - @pytest.mark.unit class TestConversationClientHttpClientSharing: diff --git a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py index eb209ec5..8d369478 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -120,7 +120,6 @@ async def _resolve_conversation_id(self, activity: str | ActivityParams | Adapti or return a pre-existing one.""" try: conversation_params = CreateConversationParams( - bot=Account(id=self.id, name=self.name, role="bot"), # type: ignore members=[Account(id=self.user_id, role="user", name=self.user_name)], tenant_id=self.tenant_id, is_group=False, diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 243463b3..4c50f26f 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -274,7 +274,6 @@ async def sign_in(self, options: Optional[SignInOptions] = None) -> Optional[str CreateConversationParams( tenant_id=self.activity.conversation.tenant_id, is_group=False, - bot=self.activity.recipient, members=[self.activity.from_], ) ) From 1e4b4d066ee69c3d0bab9f41d86914761d1056ca Mon Sep 17 00:00:00 2001 From: lilydu Date: Wed, 25 Mar 2026 13:43:51 -0700 Subject: [PATCH 2/5] add missing endpoints --- .../microsoft_teams/api/clients/__init__.py | 4 +- .../microsoft_teams/api/clients/api_client.py | 3 + .../api/clients/batch/__init__.py | 15 +++ .../api/clients/batch/client.py | 90 +++++++++++++++ .../api/clients/batch/params.py | 59 ++++++++++ .../api/clients/conversation/client.py | 3 + .../api/clients/conversation/member.py | 33 +++++- .../api/clients/meeting/client.py | 27 ++++- .../microsoft_teams/api/models/__init__.py | 3 + .../api/models/batch/__init__.py | 8 ++ .../models/batch/batch_operation_result.py | 16 +++ .../api/models/conversation/__init__.py | 3 +- .../conversation/paged_members_result.py | 21 ++++ .../api/models/meetings/__init__.py | 12 ++ .../models/meetings/meeting_notification.py | 75 ++++++++++++ packages/api/tests/conftest.py | 23 ++++ packages/api/tests/unit/test_batch_client.py | 109 ++++++++++++++++++ .../tests/unit/test_conversation_client.py | 41 ++++++- .../api/tests/unit/test_meeting_client.py | 52 ++++++++- 19 files changed, 591 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/clients/batch/__init__.py create mode 100644 packages/api/src/microsoft_teams/api/clients/batch/client.py create mode 100644 packages/api/src/microsoft_teams/api/clients/batch/params.py create mode 100644 packages/api/src/microsoft_teams/api/models/batch/__init__.py create mode 100644 packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py create mode 100644 packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py create mode 100644 packages/api/src/microsoft_teams/api/models/meetings/meeting_notification.py create mode 100644 packages/api/tests/unit/test_batch_client.py diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py index b744c1e8..52a26ed5 100644 --- a/packages/api/src/microsoft_teams/api/clients/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/__init__.py @@ -3,9 +3,10 @@ Licensed under the MIT License. """ -from . import bot, conversation, meeting, reaction, team, user +from . import batch, bot, conversation, meeting, reaction, team, user from .api_client import ApiClient from .api_client_settings import DEFAULT_API_CLIENT_SETTINGS, ApiClientSettings, merge_api_client_settings +from .batch import * # noqa: F403 from .bot import * # noqa: F403 from .conversation import * # noqa: F403 from .meeting import * # noqa: F403 @@ -20,6 +21,7 @@ "DEFAULT_API_CLIENT_SETTINGS", "merge_api_client_settings", ] +__all__.extend(batch.__all__) __all__.extend(bot.__all__) __all__.extend(conversation.__all__) __all__.extend(meeting.__all__) diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py index 3576b147..733fdda5 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -10,6 +10,7 @@ from .api_client_settings import ApiClientSettings from .base_client import BaseClient +from .batch import BatchClient from .bot import BotClient from .conversation import ConversationClient from .meeting import MeetingClient @@ -43,6 +44,7 @@ def __init__( self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings) self.teams = TeamClient(self.service_url, self._http, self._api_client_settings) self.meetings = MeetingClient(self.service_url, self._http, self._api_client_settings) + self.batch = BatchClient(self.service_url, self._http, self._api_client_settings) self._reactions: Optional[ReactionClient] = None @property @@ -65,6 +67,7 @@ def http(self, value: HttpClient) -> None: self.users.http = value self.teams.http = value self.meetings.http = value + self.batch.http = value if self._reactions is not None: self._reactions.http = value self._http = value diff --git a/packages/api/src/microsoft_teams/api/clients/batch/__init__.py b/packages/api/src/microsoft_teams/api/clients/batch/__init__.py new file mode 100644 index 00000000..915af0d7 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/clients/batch/__init__.py @@ -0,0 +1,15 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .client import BatchClient +from .params import BatchChannelsParams, BatchTeamParams, BatchTenantParams, BatchUsersParams + +__all__ = [ + "BatchClient", + "BatchChannelsParams", + "BatchTeamParams", + "BatchTenantParams", + "BatchUsersParams", +] diff --git a/packages/api/src/microsoft_teams/api/clients/batch/client.py b/packages/api/src/microsoft_teams/api/clients/batch/client.py new file mode 100644 index 00000000..1530a975 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/clients/batch/client.py @@ -0,0 +1,90 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Optional, Union + +from microsoft_teams.common.http import Client, ClientOptions + +from ...models.batch import BatchOperationResult +from ..api_client_settings import ApiClientSettings +from ..base_client import BaseClient +from .params import BatchChannelsParams, BatchTeamParams, BatchTenantParams, BatchUsersParams + + +class BatchClient(BaseClient): + """Client for sending messages to large audiences in batch.""" + + def __init__( + self, + service_url: str, + options: Optional[Union[Client, ClientOptions]] = None, + api_client_settings: Optional[ApiClientSettings] = None, + ) -> None: + super().__init__(options, api_client_settings) + self.service_url = service_url.rstrip("/") + + async def send_to_users(self, params: BatchUsersParams) -> BatchOperationResult: + """ + Send a message to a specific list of users. + + Args: + params: The batch users parameters including tenant_id, members, and activity. + + Returns: + BatchOperationResult containing the operation_id to track the operation. + """ + response = await self.http.post( + f"{self.service_url}/v3/batch/conversation/users", + json=params.model_dump(by_alias=True, exclude_none=True), + ) + return BatchOperationResult.model_validate(response.json()) + + async def send_to_tenant(self, params: BatchTenantParams) -> BatchOperationResult: + """ + Send a message to all users in a tenant. + + Args: + params: The batch tenant parameters including tenant_id and activity. + + Returns: + BatchOperationResult containing the operation_id to track the operation. + """ + response = await self.http.post( + f"{self.service_url}/v3/batch/conversation/tenant", + json=params.model_dump(by_alias=True, exclude_none=True), + ) + return BatchOperationResult.model_validate(response.json()) + + async def send_to_team(self, params: BatchTeamParams) -> BatchOperationResult: + """ + Send a message to all members of a team. + + Args: + params: The batch team parameters including tenant_id, team_id, and activity. + + Returns: + BatchOperationResult containing the operation_id to track the operation. + """ + response = await self.http.post( + f"{self.service_url}/v3/batch/conversation/team", + json=params.model_dump(by_alias=True, exclude_none=True), + ) + return BatchOperationResult.model_validate(response.json()) + + async def send_to_channels(self, params: BatchChannelsParams) -> BatchOperationResult: + """ + Send a message to a list of channels. + + Args: + params: The batch channels parameters including tenant_id, members, and activity. + + Returns: + BatchOperationResult containing the operation_id to track the operation. + """ + response = await self.http.post( + f"{self.service_url}/v3/batch/conversation/channels", + json=params.model_dump(by_alias=True, exclude_none=True), + ) + return BatchOperationResult.model_validate(response.json()) diff --git a/packages/api/src/microsoft_teams/api/clients/batch/params.py b/packages/api/src/microsoft_teams/api/clients/batch/params.py new file mode 100644 index 00000000..e6a78313 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/clients/batch/params.py @@ -0,0 +1,59 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import List + +from ...activities.message.message import MessageActivityInput +from ...models import Account +from ...models.custom_base_model import CustomBaseModel + + +class BatchUsersParams(CustomBaseModel): + """Parameters for sending a message to a list of users.""" + + tenant_id: str + "The tenant ID." + + members: List[Account] + "The users to send the message to. Must contain between 5 and 1000 members." + + activity: MessageActivityInput + "The message activity to send." + + +class BatchTenantParams(CustomBaseModel): + """Parameters for sending a message to all users in a tenant.""" + + tenant_id: str + "The tenant ID." + + activity: MessageActivityInput + "The message activity to send." + + +class BatchTeamParams(CustomBaseModel): + """Parameters for sending a message to all members of a team.""" + + tenant_id: str + "The tenant ID." + + team_id: str + "The team ID (e.g. '19:...@thread.tacv2')." + + activity: MessageActivityInput + "The message activity to send." + + +class BatchChannelsParams(CustomBaseModel): + """Parameters for sending a message to a list of channels.""" + + tenant_id: str + "The tenant ID." + + members: List[Account] + "The channels to send the message to." + + activity: MessageActivityInput + "The message activity to send." diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/client.py b/packages/api/src/microsoft_teams/api/clients/conversation/client.py index 84e4a9c2..1c42a103 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -60,6 +60,9 @@ class MemberOperations(ConversationOperations): async def get_all(self): return await self._client.members_client.get(self._conversation_id) + async def get_paged(self, page_size: Optional[int] = None, continuation_token: Optional[str] = None): + return await self._client.members_client.get_paged(self._conversation_id, page_size, continuation_token) + async def get(self, member_id: str): return await self._client.members_client.get_by_id(self._conversation_id, member_id) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/member.py b/packages/api/src/microsoft_teams/api/clients/conversation/member.py index 9f621068..1187e64e 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/member.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/member.py @@ -3,11 +3,12 @@ Licensed under the MIT License. """ -from typing import List, Optional +from typing import Any, List, Optional from microsoft_teams.common.http import Client from ...models import TeamsChannelAccount +from ...models.conversation import PagedMembersResult from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -47,6 +48,36 @@ async def get(self, conversation_id: str) -> List[TeamsChannelAccount]: response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members") return [TeamsChannelAccount.model_validate(member) for member in response.json()] + async def get_paged( + self, + conversation_id: str, + page_size: Optional[int] = None, + continuation_token: Optional[str] = None, + ) -> PagedMembersResult: + """ + Get a page of members in a conversation. + + Args: + conversation_id: The ID of the conversation. + page_size: Optional maximum number of members to return per page. + continuation_token: Optional token from a previous call to fetch the next page. + + Returns: + PagedMembersResult containing the members and an optional continuation token + for fetching subsequent pages. + """ + url = f"{self.service_url}/v3/conversations/{conversation_id}/pagedMembers" + params: dict[str, Any] = {} + if page_size is not None: + params["pageSize"] = page_size + if continuation_token is not None: + params["continuationToken"] = continuation_token + if params: + query = "&".join(f"{k}={v}" for k, v in params.items()) + url = f"{url}?{query}" + response = await self.http.get(url) + return PagedMembersResult.model_validate(response.json()) + async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelAccount: """ Get a specific member in a conversation. diff --git a/packages/api/src/microsoft_teams/api/clients/meeting/client.py b/packages/api/src/microsoft_teams/api/clients/meeting/client.py index 7b467fbd..cea7277b 100644 --- a/packages/api/src/microsoft_teams/api/clients/meeting/client.py +++ b/packages/api/src/microsoft_teams/api/clients/meeting/client.py @@ -8,6 +8,7 @@ from microsoft_teams.common.http import Client, ClientOptions from ...models import MeetingInfo, MeetingParticipant +from ...models.meetings.meeting_notification import MeetingNotificationParams, MeetingNotificationResponse from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -57,7 +58,31 @@ async def get_participant(self, meeting_id: str, id: str, tenant_id: str) -> Mee Returns: MeetingParticipant: The meeting participant information. """ - url = f"{self.service_url}/v1/meetings/{meeting_id}/participants/{id}?tenantId={tenant_id}" response = await self.http.get(url) return MeetingParticipant.model_validate(response.json()) + + async def send_notification( + self, meeting_id: str, params: MeetingNotificationParams + ) -> Optional[MeetingNotificationResponse]: + """ + Send a targeted meeting notification to participants. + + Returns None on full success (HTTP 202). Returns a MeetingNotificationResponse + with failure details on partial success (HTTP 207). + + Args: + meeting_id: The BASE64-encoded meeting ID. + params: The notification parameters including recipients and surfaces. + + Returns: + None if all notifications were sent successfully, or a MeetingNotificationResponse + with per-recipient failure details on partial success. + """ + response = await self.http.post( + f"{self.service_url}/v1/meetings/{meeting_id}/notification", + json=params.model_dump(by_alias=True, exclude_none=True), + ) + if not response.text: + return None + return MeetingNotificationResponse.model_validate(response.json()) diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index 1b9062d5..c08a4e0e 100644 --- a/packages/api/src/microsoft_teams/api/models/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/__init__.py @@ -6,6 +6,7 @@ from . import ( adaptive_card, attachment, + batch, card, channel_data, config, @@ -29,6 +30,7 @@ from .adaptive_card import * # noqa: F403 from .app_based_link_query import AppBasedLinkQuery from .attachment import * # noqa: F403 +from .batch import * # noqa: F403 from .cache_info import CacheInfo from .card import * # noqa: F403 from .channel_data import * # noqa: F403 @@ -104,6 +106,7 @@ "is_invoke_response", ] __all__.extend(adaptive_card.__all__) +__all__.extend(batch.__all__) __all__.extend(attachment.__all__) __all__.extend(card.__all__) __all__.extend(channel_data.__all__) diff --git a/packages/api/src/microsoft_teams/api/models/batch/__init__.py b/packages/api/src/microsoft_teams/api/models/batch/__init__.py new file mode 100644 index 00000000..caf6801a --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/batch/__init__.py @@ -0,0 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .batch_operation_result import BatchOperationResult + +__all__ = ["BatchOperationResult"] diff --git a/packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py b/packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py new file mode 100644 index 00000000..9630ebeb --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py @@ -0,0 +1,16 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from ..custom_base_model import CustomBaseModel + + +class BatchOperationResult(CustomBaseModel): + """ + Result of a batch conversation operation, containing the operation ID + that can be used to poll for status or cancel the operation. + """ + + operation_id: str + "The ID of the created batch operation." diff --git a/packages/api/src/microsoft_teams/api/models/conversation/__init__.py b/packages/api/src/microsoft_teams/api/models/conversation/__init__.py index ccd35b43..fc336f5c 100644 --- a/packages/api/src/microsoft_teams/api/models/conversation/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/conversation/__init__.py @@ -6,5 +6,6 @@ from .conversation import Conversation, ConversationType from .conversation_reference import ConversationReference from .conversation_resource import ConversationResource +from .paged_members_result import PagedMembersResult -__all__ = ["Conversation", "ConversationReference", "ConversationResource", "ConversationType"] +__all__ = ["Conversation", "ConversationReference", "ConversationResource", "ConversationType", "PagedMembersResult"] diff --git a/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py b/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py new file mode 100644 index 00000000..63c39802 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py @@ -0,0 +1,21 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import List, Optional + +from ..account import TeamsChannelAccount +from ..custom_base_model import CustomBaseModel + + +class PagedMembersResult(CustomBaseModel): + """ + Result of a paged members request. + """ + + members: List[TeamsChannelAccount] = [] + "The members in this page." + + continuation_token: Optional[str] = None + "Token to fetch the next page of members. None if this is the last page." diff --git a/packages/api/src/microsoft_teams/api/models/meetings/__init__.py b/packages/api/src/microsoft_teams/api/models/meetings/__init__.py index 8e718367..1a07032e 100644 --- a/packages/api/src/microsoft_teams/api/models/meetings/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/meetings/__init__.py @@ -6,11 +6,23 @@ from .meeting import Meeting from .meeting_details import MeetingDetails from .meeting_info import MeetingInfo +from .meeting_notification import ( + MeetingNotificationParams, + MeetingNotificationRecipientFailure, + MeetingNotificationResponse, + MeetingNotificationSurface, + MeetingNotificationValue, +) from .meeting_participant import MeetingParticipant __all__ = [ "Meeting", "MeetingDetails", "MeetingInfo", + "MeetingNotificationParams", + "MeetingNotificationRecipientFailure", + "MeetingNotificationResponse", + "MeetingNotificationSurface", + "MeetingNotificationValue", "MeetingParticipant", ] diff --git a/packages/api/src/microsoft_teams/api/models/meetings/meeting_notification.py b/packages/api/src/microsoft_teams/api/models/meetings/meeting_notification.py new file mode 100644 index 00000000..71d2effd --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/meetings/meeting_notification.py @@ -0,0 +1,75 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Dict, List, Optional + +from ..custom_base_model import CustomBaseModel + + +class MeetingNotificationSurface(CustomBaseModel): + """ + A surface target for a meeting notification. + """ + + surface: str + "The surface type. E.g. 'meetingStage', 'meetingTabIcon', 'meetingCopilotPane'." + + content_type: Optional[str] = None + "The content type for surfaces that carry content. E.g. 'task'." + + content: Optional[Dict[str, Any]] = None + "The content payload for the surface." + + tab_entity_id: Optional[str] = None + "The tab entity ID, required for 'meetingTabIcon' surfaces." + + +class MeetingNotificationValue(CustomBaseModel): + """ + The value of a targeted meeting notification. + """ + + recipients: List[str] + "AAD object IDs of the meeting participants to notify." + + surfaces: List[MeetingNotificationSurface] + "The surfaces to send the notification to." + + +class MeetingNotificationParams(CustomBaseModel): + """ + Parameters for sending a meeting notification. + """ + + type: str = "targetedMeetingNotification" + "The notification type." + + value: MeetingNotificationValue + "The notification value containing recipients and surfaces." + + +class MeetingNotificationRecipientFailure(CustomBaseModel): + """ + Information about a recipient that failed to receive a meeting notification. + """ + + recipient_mri: Optional[str] = None + "The MRI of the recipient." + + error_code: Optional[str] = None + "The error code." + + failure_reason: Optional[str] = None + "The reason for the failure." + + +class MeetingNotificationResponse(CustomBaseModel): + """ + Response from a meeting notification request when some or all recipients failed (HTTP 207). + None is returned when all notifications were sent successfully (HTTP 202). + """ + + recipients_failure_info: Optional[List[MeetingNotificationRecipientFailure]] = None + "Information about recipients that failed to receive the notification." diff --git a/packages/api/tests/conftest.py b/packages/api/tests/conftest.py index b63b7fa1..fa5e8485 100644 --- a/packages/api/tests/conftest.py +++ b/packages/api/tests/conftest.py @@ -100,6 +100,29 @@ def handler(request: httpx.Request) -> httpx.Response: }, ] } + elif "/conversations/" in str(request.url) and "/pagedMembers" in str(request.url): + response_data = { + "members": [ + { + "id": "mock_member_id", + "name": "Mock Member", + "aadObjectId": "mock_aad_object_id", + } + ], + "continuationToken": "mock_continuation_token", + } + elif "/v3/batch/conversation/" in str(request.url) and request.method == "POST": + response_data = {"operationId": "mock_operation_id"} + elif "/notification" in str(request.url) and request.method == "POST": + response_data = { + "recipientsFailureInfo": [ + { + "recipientMri": "8:orgid:mock_recipient", + "errorCode": "BadArgument", + "failureReason": "Invalid recipient", + } + ] + } elif "/conversations/" in str(request.url) and str(request.url).endswith("/members"): response_data = [ { diff --git a/packages/api/tests/unit/test_batch_client.py b/packages/api/tests/unit/test_batch_client.py new file mode 100644 index 00000000..359a3d00 --- /dev/null +++ b/packages/api/tests/unit/test_batch_client.py @@ -0,0 +1,109 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +import pytest +from microsoft_teams.api.activities.message.message import MessageActivityInput +from microsoft_teams.api.clients.batch import ( + BatchChannelsParams, + BatchClient, + BatchTeamParams, + BatchTenantParams, + BatchUsersParams, +) +from microsoft_teams.api.models import Account +from microsoft_teams.api.models.batch import BatchOperationResult +from microsoft_teams.common.http import Client, ClientOptions + + +@pytest.mark.unit +class TestBatchClient: + """Unit tests for BatchClient.""" + + def test_batch_client_strips_trailing_slash(self, mock_http_client): + """Test BatchClient strips trailing slash from service_url.""" + service_url = "https://test.service.url/" + client = BatchClient(service_url, mock_http_client) + + assert client.service_url == "https://test.service.url" + + def test_http_client_property(self, mock_http_client): + """Test HTTP client property getter and setter.""" + service_url = "https://test.service.url" + client = BatchClient(service_url, mock_http_client) + + assert client.http == mock_http_client + + new_http_client = Client(ClientOptions(base_url="https://new.api.com")) + client.http = new_http_client + + assert client.http == new_http_client + + @pytest.mark.asyncio + async def test_send_to_tenant(self, mock_http_client): + """Test sending a message to all users in a tenant.""" + service_url = "https://test.service.url" + client = BatchClient(service_url, mock_http_client) + + result = await client.send_to_tenant( + BatchTenantParams( + tenant_id="mock_tenant_id", + activity=MessageActivityInput(text="hello"), + ) + ) + + assert isinstance(result, BatchOperationResult) + assert result.operation_id == "mock_operation_id" + + @pytest.mark.asyncio + async def test_send_to_users(self, mock_http_client): + """Test sending a message to a list of users.""" + service_url = "https://test.service.url" + client = BatchClient(service_url, mock_http_client) + + result = await client.send_to_users( + BatchUsersParams( + tenant_id="mock_tenant_id", + members=[Account(id=f"29:user-{i}") for i in range(5)], + activity=MessageActivityInput(text="hello"), + ) + ) + + assert isinstance(result, BatchOperationResult) + assert result.operation_id == "mock_operation_id" + + @pytest.mark.asyncio + async def test_send_to_team(self, mock_http_client): + """Test sending a message to all members of a team.""" + service_url = "https://test.service.url" + client = BatchClient(service_url, mock_http_client) + + result = await client.send_to_team( + BatchTeamParams( + tenant_id="mock_tenant_id", + team_id="19:mock@thread.tacv2", + activity=MessageActivityInput(text="hello"), + ) + ) + + assert isinstance(result, BatchOperationResult) + assert result.operation_id == "mock_operation_id" + + @pytest.mark.asyncio + async def test_send_to_channels(self, mock_http_client): + """Test sending a message to a list of channels.""" + service_url = "https://test.service.url" + client = BatchClient(service_url, mock_http_client) + + result = await client.send_to_channels( + BatchChannelsParams( + tenant_id="mock_tenant_id", + members=[Account(id="19:mock-channel@thread.tacv2")], + activity=MessageActivityInput(text="hello"), + ) + ) + + assert isinstance(result, BatchOperationResult) + assert result.operation_id == "mock_operation_id" diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index 15058bd7..c0593458 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -4,10 +4,13 @@ """ # pyright: basic +from unittest.mock import AsyncMock, patch + +import httpx import pytest from microsoft_teams.api.clients.conversation import ConversationClient from microsoft_teams.api.clients.conversation.params import CreateConversationParams -from microsoft_teams.api.models import ConversationResource, TeamsChannelAccount +from microsoft_teams.api.models import ConversationResource, PagedMembersResult, TeamsChannelAccount from microsoft_teams.common.http import Client, ClientOptions @@ -261,6 +264,42 @@ async def test_member_get(self, mock_http_client): assert result.name == "Mock Member" assert result.aad_object_id == "mock_aad_object_id" + async def test_member_get_paged(self, mock_http_client): + """Test getting a page of members returns PagedMembersResult.""" + + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + + conversation_id = "test_conversation_id" + members = client.members(conversation_id) + + result = await members.get_paged() + + assert isinstance(result, PagedMembersResult) + assert len(result.members) == 1 + assert isinstance(result.members[0], TeamsChannelAccount) + assert result.members[0].id == "mock_member_id" + assert result.continuation_token == "mock_continuation_token" + + async def test_member_get_paged_with_token(self, mock_http_client): + """Test get_paged passes continuation_token and page_size.""" + + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + members = client.members("test_conversation_id") + + mock_response = httpx.Response( + 200, + json={"members": [], "continuationToken": None}, + headers={"content-type": "application/json"}, + ) + with patch.object(mock_http_client, "get", new_callable=AsyncMock, return_value=mock_response) as mock_get: + await members.get_paged(page_size=50, continuation_token="some_token") + + called_url = mock_get.call_args[0][0] + assert "pageSize=50" in called_url + assert "continuationToken=some_token" in called_url + @pytest.mark.unit class TestConversationClientHttpClientSharing: diff --git a/packages/api/tests/unit/test_meeting_client.py b/packages/api/tests/unit/test_meeting_client.py index 7a2701e8..fd72d116 100644 --- a/packages/api/tests/unit/test_meeting_client.py +++ b/packages/api/tests/unit/test_meeting_client.py @@ -4,9 +4,18 @@ """ # pyright: basic +from unittest.mock import AsyncMock, patch + import pytest from microsoft_teams.api.clients.meeting import MeetingClient -from microsoft_teams.api.models import MeetingInfo, MeetingParticipant +from microsoft_teams.api.models import ( + MeetingInfo, + MeetingNotificationParams, + MeetingNotificationResponse, + MeetingNotificationSurface, + MeetingNotificationValue, + MeetingParticipant, +) from microsoft_teams.common.http import Client, ClientOptions @@ -57,3 +66,44 @@ def test_meeting_client_strips_trailing_slash(self, mock_http_client): client = MeetingClient(service_url, mock_http_client) assert client.service_url == "https://test.service.url" + + @pytest.mark.asyncio + async def test_send_notification_partial_failure(self, mock_http_client): + """Test send_notification returns MeetingNotificationResponse on partial failure (HTTP 207).""" + service_url = "https://test.service.url" + client = MeetingClient(service_url, mock_http_client) + + params = MeetingNotificationParams( + value=MeetingNotificationValue( + recipients=["mock_aad_oid"], + surfaces=[MeetingNotificationSurface(surface="meetingTabIcon", tab_entity_id="test")], + ) + ) + + result = await client.send_notification("mock_meeting_id", params) + + assert isinstance(result, MeetingNotificationResponse) + assert result.recipients_failure_info is not None + assert len(result.recipients_failure_info) == 1 + assert result.recipients_failure_info[0].error_code == "BadArgument" + + @pytest.mark.asyncio + async def test_send_notification_full_success(self, mock_http_client): + """Test send_notification returns None on full success (HTTP 202, empty body).""" + import httpx + + service_url = "https://test.service.url" + client = MeetingClient(service_url, mock_http_client) + + params = MeetingNotificationParams( + value=MeetingNotificationValue( + recipients=["mock_aad_oid"], + surfaces=[MeetingNotificationSurface(surface="meetingTabIcon", tab_entity_id="test")], + ) + ) + + empty_response = httpx.Response(202, content=b"", headers={"content-type": "application/json"}) + with patch.object(mock_http_client, "post", new_callable=AsyncMock, return_value=empty_response): + result = await client.send_notification("mock_meeting_id", params) + + assert result is None From 0491c3f267092d2cf89b58bf1b2f524735c5a1d5 Mon Sep 17 00:00:00 2001 From: lilydu Date: Wed, 25 Mar 2026 14:32:47 -0700 Subject: [PATCH 3/5] fixes --- .../api/clients/conversation/member.py | 12 ++---------- .../conversation/paged_members_result.py | 4 +++- packages/api/tests/unit/test_meeting_client.py | 18 +++++++++++++++++- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/member.py b/packages/api/src/microsoft_teams/api/clients/conversation/member.py index 1187e64e..78a7a958 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/member.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/member.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import Any, List, Optional +from typing import List, Optional from microsoft_teams.common.http import Client @@ -67,15 +67,7 @@ async def get_paged( for fetching subsequent pages. """ url = f"{self.service_url}/v3/conversations/{conversation_id}/pagedMembers" - params: dict[str, Any] = {} - if page_size is not None: - params["pageSize"] = page_size - if continuation_token is not None: - params["continuationToken"] = continuation_token - if params: - query = "&".join(f"{k}={v}" for k, v in params.items()) - url = f"{url}?{query}" - response = await self.http.get(url) + response = await self.http.get(url, params={"pageSize": page_size, "continuationToken": continuation_token}) return PagedMembersResult.model_validate(response.json()) async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelAccount: diff --git a/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py b/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py index 63c39802..89aa7afc 100644 --- a/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py +++ b/packages/api/src/microsoft_teams/api/models/conversation/paged_members_result.py @@ -5,6 +5,8 @@ from typing import List, Optional +from pydantic import Field + from ..account import TeamsChannelAccount from ..custom_base_model import CustomBaseModel @@ -14,7 +16,7 @@ class PagedMembersResult(CustomBaseModel): Result of a paged members request. """ - members: List[TeamsChannelAccount] = [] + members: List[TeamsChannelAccount] = Field(default_factory=list[TeamsChannelAccount]) "The members in this page." continuation_token: Optional[str] = None diff --git a/packages/api/tests/unit/test_meeting_client.py b/packages/api/tests/unit/test_meeting_client.py index fd72d116..4f4e7d18 100644 --- a/packages/api/tests/unit/test_meeting_client.py +++ b/packages/api/tests/unit/test_meeting_client.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch +import httpx import pytest from microsoft_teams.api.clients.meeting import MeetingClient from microsoft_teams.api.models import ( @@ -70,6 +71,7 @@ def test_meeting_client_strips_trailing_slash(self, mock_http_client): @pytest.mark.asyncio async def test_send_notification_partial_failure(self, mock_http_client): """Test send_notification returns MeetingNotificationResponse on partial failure (HTTP 207).""" + service_url = "https://test.service.url" client = MeetingClient(service_url, mock_http_client) @@ -80,7 +82,21 @@ async def test_send_notification_partial_failure(self, mock_http_client): ) ) - result = await client.send_notification("mock_meeting_id", params) + partial_failure_response = httpx.Response( + 207, + json={ + "recipientsFailureInfo": [ + { + "recipientMri": "8:orgid:mock_recipient", + "errorCode": "BadArgument", + "failureReason": "Invalid recipient", + } + ] + }, + headers={"content-type": "application/json"}, + ) + with patch.object(mock_http_client, "post", new_callable=AsyncMock, return_value=partial_failure_response): + result = await client.send_notification("mock_meeting_id", params) assert isinstance(result, MeetingNotificationResponse) assert result.recipients_failure_info is not None From 31297642d74752b0ec8c575c9ffb6172b3d0409b Mon Sep 17 00:00:00 2001 From: lilydu Date: Wed, 25 Mar 2026 14:39:56 -0700 Subject: [PATCH 4/5] fix test --- packages/api/tests/unit/test_conversation_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index c0593458..bc468e20 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -296,9 +296,9 @@ async def test_member_get_paged_with_token(self, mock_http_client): with patch.object(mock_http_client, "get", new_callable=AsyncMock, return_value=mock_response) as mock_get: await members.get_paged(page_size=50, continuation_token="some_token") - called_url = mock_get.call_args[0][0] - assert "pageSize=50" in called_url - assert "continuationToken=some_token" in called_url + called_params = mock_get.call_args.kwargs.get("params", {}) + assert called_params.get("pageSize") == 50 + assert called_params.get("continuationToken") == "some_token" @pytest.mark.unit From 5227e65dbb4eb529430f5f0e7c7f17cd025facd1 Mon Sep 17 00:00:00 2001 From: lilydu Date: Thu, 26 Mar 2026 09:41:11 -0700 Subject: [PATCH 5/5] remove batch API --- .../microsoft_teams/api/clients/__init__.py | 4 +- .../microsoft_teams/api/clients/api_client.py | 3 - .../api/clients/batch/__init__.py | 15 --- .../api/clients/batch/client.py | 90 --------------- .../api/clients/batch/params.py | 59 ---------- .../microsoft_teams/api/models/__init__.py | 3 - .../api/models/batch/__init__.py | 8 -- .../models/batch/batch_operation_result.py | 16 --- packages/api/tests/conftest.py | 2 - packages/api/tests/unit/test_batch_client.py | 109 ------------------ 10 files changed, 1 insertion(+), 308 deletions(-) delete mode 100644 packages/api/src/microsoft_teams/api/clients/batch/__init__.py delete mode 100644 packages/api/src/microsoft_teams/api/clients/batch/client.py delete mode 100644 packages/api/src/microsoft_teams/api/clients/batch/params.py delete mode 100644 packages/api/src/microsoft_teams/api/models/batch/__init__.py delete mode 100644 packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py delete mode 100644 packages/api/tests/unit/test_batch_client.py diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py index 52a26ed5..b744c1e8 100644 --- a/packages/api/src/microsoft_teams/api/clients/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/__init__.py @@ -3,10 +3,9 @@ Licensed under the MIT License. """ -from . import batch, bot, conversation, meeting, reaction, team, user +from . import bot, conversation, meeting, reaction, team, user from .api_client import ApiClient from .api_client_settings import DEFAULT_API_CLIENT_SETTINGS, ApiClientSettings, merge_api_client_settings -from .batch import * # noqa: F403 from .bot import * # noqa: F403 from .conversation import * # noqa: F403 from .meeting import * # noqa: F403 @@ -21,7 +20,6 @@ "DEFAULT_API_CLIENT_SETTINGS", "merge_api_client_settings", ] -__all__.extend(batch.__all__) __all__.extend(bot.__all__) __all__.extend(conversation.__all__) __all__.extend(meeting.__all__) diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py index 733fdda5..3576b147 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -10,7 +10,6 @@ from .api_client_settings import ApiClientSettings from .base_client import BaseClient -from .batch import BatchClient from .bot import BotClient from .conversation import ConversationClient from .meeting import MeetingClient @@ -44,7 +43,6 @@ def __init__( self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings) self.teams = TeamClient(self.service_url, self._http, self._api_client_settings) self.meetings = MeetingClient(self.service_url, self._http, self._api_client_settings) - self.batch = BatchClient(self.service_url, self._http, self._api_client_settings) self._reactions: Optional[ReactionClient] = None @property @@ -67,7 +65,6 @@ def http(self, value: HttpClient) -> None: self.users.http = value self.teams.http = value self.meetings.http = value - self.batch.http = value if self._reactions is not None: self._reactions.http = value self._http = value diff --git a/packages/api/src/microsoft_teams/api/clients/batch/__init__.py b/packages/api/src/microsoft_teams/api/clients/batch/__init__.py deleted file mode 100644 index 915af0d7..00000000 --- a/packages/api/src/microsoft_teams/api/clients/batch/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from .client import BatchClient -from .params import BatchChannelsParams, BatchTeamParams, BatchTenantParams, BatchUsersParams - -__all__ = [ - "BatchClient", - "BatchChannelsParams", - "BatchTeamParams", - "BatchTenantParams", - "BatchUsersParams", -] diff --git a/packages/api/src/microsoft_teams/api/clients/batch/client.py b/packages/api/src/microsoft_teams/api/clients/batch/client.py deleted file mode 100644 index 1530a975..00000000 --- a/packages/api/src/microsoft_teams/api/clients/batch/client.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from typing import Optional, Union - -from microsoft_teams.common.http import Client, ClientOptions - -from ...models.batch import BatchOperationResult -from ..api_client_settings import ApiClientSettings -from ..base_client import BaseClient -from .params import BatchChannelsParams, BatchTeamParams, BatchTenantParams, BatchUsersParams - - -class BatchClient(BaseClient): - """Client for sending messages to large audiences in batch.""" - - def __init__( - self, - service_url: str, - options: Optional[Union[Client, ClientOptions]] = None, - api_client_settings: Optional[ApiClientSettings] = None, - ) -> None: - super().__init__(options, api_client_settings) - self.service_url = service_url.rstrip("/") - - async def send_to_users(self, params: BatchUsersParams) -> BatchOperationResult: - """ - Send a message to a specific list of users. - - Args: - params: The batch users parameters including tenant_id, members, and activity. - - Returns: - BatchOperationResult containing the operation_id to track the operation. - """ - response = await self.http.post( - f"{self.service_url}/v3/batch/conversation/users", - json=params.model_dump(by_alias=True, exclude_none=True), - ) - return BatchOperationResult.model_validate(response.json()) - - async def send_to_tenant(self, params: BatchTenantParams) -> BatchOperationResult: - """ - Send a message to all users in a tenant. - - Args: - params: The batch tenant parameters including tenant_id and activity. - - Returns: - BatchOperationResult containing the operation_id to track the operation. - """ - response = await self.http.post( - f"{self.service_url}/v3/batch/conversation/tenant", - json=params.model_dump(by_alias=True, exclude_none=True), - ) - return BatchOperationResult.model_validate(response.json()) - - async def send_to_team(self, params: BatchTeamParams) -> BatchOperationResult: - """ - Send a message to all members of a team. - - Args: - params: The batch team parameters including tenant_id, team_id, and activity. - - Returns: - BatchOperationResult containing the operation_id to track the operation. - """ - response = await self.http.post( - f"{self.service_url}/v3/batch/conversation/team", - json=params.model_dump(by_alias=True, exclude_none=True), - ) - return BatchOperationResult.model_validate(response.json()) - - async def send_to_channels(self, params: BatchChannelsParams) -> BatchOperationResult: - """ - Send a message to a list of channels. - - Args: - params: The batch channels parameters including tenant_id, members, and activity. - - Returns: - BatchOperationResult containing the operation_id to track the operation. - """ - response = await self.http.post( - f"{self.service_url}/v3/batch/conversation/channels", - json=params.model_dump(by_alias=True, exclude_none=True), - ) - return BatchOperationResult.model_validate(response.json()) diff --git a/packages/api/src/microsoft_teams/api/clients/batch/params.py b/packages/api/src/microsoft_teams/api/clients/batch/params.py deleted file mode 100644 index e6a78313..00000000 --- a/packages/api/src/microsoft_teams/api/clients/batch/params.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from typing import List - -from ...activities.message.message import MessageActivityInput -from ...models import Account -from ...models.custom_base_model import CustomBaseModel - - -class BatchUsersParams(CustomBaseModel): - """Parameters for sending a message to a list of users.""" - - tenant_id: str - "The tenant ID." - - members: List[Account] - "The users to send the message to. Must contain between 5 and 1000 members." - - activity: MessageActivityInput - "The message activity to send." - - -class BatchTenantParams(CustomBaseModel): - """Parameters for sending a message to all users in a tenant.""" - - tenant_id: str - "The tenant ID." - - activity: MessageActivityInput - "The message activity to send." - - -class BatchTeamParams(CustomBaseModel): - """Parameters for sending a message to all members of a team.""" - - tenant_id: str - "The tenant ID." - - team_id: str - "The team ID (e.g. '19:...@thread.tacv2')." - - activity: MessageActivityInput - "The message activity to send." - - -class BatchChannelsParams(CustomBaseModel): - """Parameters for sending a message to a list of channels.""" - - tenant_id: str - "The tenant ID." - - members: List[Account] - "The channels to send the message to." - - activity: MessageActivityInput - "The message activity to send." diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index c08a4e0e..1b9062d5 100644 --- a/packages/api/src/microsoft_teams/api/models/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/__init__.py @@ -6,7 +6,6 @@ from . import ( adaptive_card, attachment, - batch, card, channel_data, config, @@ -30,7 +29,6 @@ from .adaptive_card import * # noqa: F403 from .app_based_link_query import AppBasedLinkQuery from .attachment import * # noqa: F403 -from .batch import * # noqa: F403 from .cache_info import CacheInfo from .card import * # noqa: F403 from .channel_data import * # noqa: F403 @@ -106,7 +104,6 @@ "is_invoke_response", ] __all__.extend(adaptive_card.__all__) -__all__.extend(batch.__all__) __all__.extend(attachment.__all__) __all__.extend(card.__all__) __all__.extend(channel_data.__all__) diff --git a/packages/api/src/microsoft_teams/api/models/batch/__init__.py b/packages/api/src/microsoft_teams/api/models/batch/__init__.py deleted file mode 100644 index caf6801a..00000000 --- a/packages/api/src/microsoft_teams/api/models/batch/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from .batch_operation_result import BatchOperationResult - -__all__ = ["BatchOperationResult"] diff --git a/packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py b/packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py deleted file mode 100644 index 9630ebeb..00000000 --- a/packages/api/src/microsoft_teams/api/models/batch/batch_operation_result.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from ..custom_base_model import CustomBaseModel - - -class BatchOperationResult(CustomBaseModel): - """ - Result of a batch conversation operation, containing the operation ID - that can be used to poll for status or cancel the operation. - """ - - operation_id: str - "The ID of the created batch operation." diff --git a/packages/api/tests/conftest.py b/packages/api/tests/conftest.py index fa5e8485..90efb2ff 100644 --- a/packages/api/tests/conftest.py +++ b/packages/api/tests/conftest.py @@ -111,8 +111,6 @@ def handler(request: httpx.Request) -> httpx.Response: ], "continuationToken": "mock_continuation_token", } - elif "/v3/batch/conversation/" in str(request.url) and request.method == "POST": - response_data = {"operationId": "mock_operation_id"} elif "/notification" in str(request.url) and request.method == "POST": response_data = { "recipientsFailureInfo": [ diff --git a/packages/api/tests/unit/test_batch_client.py b/packages/api/tests/unit/test_batch_client.py deleted file mode 100644 index 359a3d00..00000000 --- a/packages/api/tests/unit/test_batch_client.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" -# pyright: basic - -import pytest -from microsoft_teams.api.activities.message.message import MessageActivityInput -from microsoft_teams.api.clients.batch import ( - BatchChannelsParams, - BatchClient, - BatchTeamParams, - BatchTenantParams, - BatchUsersParams, -) -from microsoft_teams.api.models import Account -from microsoft_teams.api.models.batch import BatchOperationResult -from microsoft_teams.common.http import Client, ClientOptions - - -@pytest.mark.unit -class TestBatchClient: - """Unit tests for BatchClient.""" - - def test_batch_client_strips_trailing_slash(self, mock_http_client): - """Test BatchClient strips trailing slash from service_url.""" - service_url = "https://test.service.url/" - client = BatchClient(service_url, mock_http_client) - - assert client.service_url == "https://test.service.url" - - def test_http_client_property(self, mock_http_client): - """Test HTTP client property getter and setter.""" - service_url = "https://test.service.url" - client = BatchClient(service_url, mock_http_client) - - assert client.http == mock_http_client - - new_http_client = Client(ClientOptions(base_url="https://new.api.com")) - client.http = new_http_client - - assert client.http == new_http_client - - @pytest.mark.asyncio - async def test_send_to_tenant(self, mock_http_client): - """Test sending a message to all users in a tenant.""" - service_url = "https://test.service.url" - client = BatchClient(service_url, mock_http_client) - - result = await client.send_to_tenant( - BatchTenantParams( - tenant_id="mock_tenant_id", - activity=MessageActivityInput(text="hello"), - ) - ) - - assert isinstance(result, BatchOperationResult) - assert result.operation_id == "mock_operation_id" - - @pytest.mark.asyncio - async def test_send_to_users(self, mock_http_client): - """Test sending a message to a list of users.""" - service_url = "https://test.service.url" - client = BatchClient(service_url, mock_http_client) - - result = await client.send_to_users( - BatchUsersParams( - tenant_id="mock_tenant_id", - members=[Account(id=f"29:user-{i}") for i in range(5)], - activity=MessageActivityInput(text="hello"), - ) - ) - - assert isinstance(result, BatchOperationResult) - assert result.operation_id == "mock_operation_id" - - @pytest.mark.asyncio - async def test_send_to_team(self, mock_http_client): - """Test sending a message to all members of a team.""" - service_url = "https://test.service.url" - client = BatchClient(service_url, mock_http_client) - - result = await client.send_to_team( - BatchTeamParams( - tenant_id="mock_tenant_id", - team_id="19:mock@thread.tacv2", - activity=MessageActivityInput(text="hello"), - ) - ) - - assert isinstance(result, BatchOperationResult) - assert result.operation_id == "mock_operation_id" - - @pytest.mark.asyncio - async def test_send_to_channels(self, mock_http_client): - """Test sending a message to a list of channels.""" - service_url = "https://test.service.url" - client = BatchClient(service_url, mock_http_client) - - result = await client.send_to_channels( - BatchChannelsParams( - tenant_id="mock_tenant_id", - members=[Account(id="19:mock-channel@thread.tacv2")], - activity=MessageActivityInput(text="hello"), - ) - ) - - assert isinstance(result, BatchOperationResult) - assert result.operation_id == "mock_operation_id"