diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py index 683d9c1ed..6d641f928 100644 --- a/packages/api/src/microsoft_teams/api/clients/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/__init__.py @@ -4,6 +4,7 @@ """ from . import bot, conversation, meeting, reaction, team, user +from ._auth_provider_interceptor import AuthProvider from .api_client import ApiClient from .api_client_settings import ApiClientSettings, merge_api_client_settings from .bot import * # noqa: F403 @@ -17,6 +18,7 @@ __all__: list[str] = [ "ApiClient", "ApiClientSettings", + "AuthProvider", "merge_api_client_settings", ] __all__.extend(bot.__all__) diff --git a/packages/api/src/microsoft_teams/api/clients/base_client.py b/packages/api/src/microsoft_teams/api/clients/base_client.py index 4e52406fd..41628e4ed 100644 --- a/packages/api/src/microsoft_teams/api/clients/base_client.py +++ b/packages/api/src/microsoft_teams/api/clients/base_client.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from typing import Optional, Union, cast from microsoft_teams.common import Client, ClientOptions @@ -21,7 +21,7 @@ def __init__( """Initialize the BaseClient. Args: - options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. + options: Optional Client or ClientOptions instance. If not provided, a default Client is created. api_client_settings: Optional API client settings. """ if options is None: @@ -42,3 +42,10 @@ def http(self) -> Client: def http(self, value: Client) -> None: """Set the HTTP client instance.""" self._http = value + + def _get_service_url(self, service_url: str | None = None) -> str: + current_service_url = cast(str | None, getattr(self, "service_url", None)) + resolved_service_url = service_url or current_service_url + if resolved_service_url is None: + raise ValueError("service_url is required") + return resolved_service_url.rstrip("/") 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 a1ee86ef8..66c6928e7 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,8 @@ from microsoft_teams.common.http import Client from ...activities import ActivityParams, SentActivity -from ...models import TeamsChannelAccount +from ...models import AgenticIdentity, TeamsChannelAccount +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -38,7 +39,14 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url.rstrip("/") - async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create( + self, + conversation_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Create a new activity in a conversation. @@ -51,8 +59,9 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc """ response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities", json=activity.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) # Note: Typing activities (non-streaming) always produce empty responses. @@ -61,7 +70,15 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) - async def update(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def update( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Update an existing activity in a conversation. @@ -74,13 +91,22 @@ async def update(self, conversation_id: str, activity_id: str, activity: Activit The updated activity """ response = await self.http.put( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}", json=activity.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def reply(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def reply( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Reply to an activity in a conversation. @@ -95,13 +121,21 @@ async def reply(self, conversation_id: str, activity_id: str, activity: Activity activity_json = activity.model_dump(by_alias=True, exclude_none=True) activity_json["replyToId"] = activity_id response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}", json=activity_json, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def delete(self, conversation_id: str, activity_id: str) -> None: + async def delete( + self, + conversation_id: str, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> None: """ Delete an activity from a conversation. @@ -109,9 +143,19 @@ async def delete(self, conversation_id: str, activity_id: str) -> None: conversation_id: The ID of the conversation activity_id: The ID of the activity to delete """ - await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}") + await self.http.delete( + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) - async def get_members(self, conversation_id: str, activity_id: str) -> List[TeamsChannelAccount]: + async def get_members( + self, + conversation_id: str, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> List[TeamsChannelAccount]: """ Get the members associated with an activity. @@ -123,12 +167,19 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Team 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" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}/members", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) return [TeamsChannelAccount.model_validate(member) for member in response.json()] @experimental("ExperimentalTeamsTargeted") - async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create_targeted( + self, + conversation_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + ) -> SentActivity: """ Create a new targeted activity in a conversation. @@ -146,14 +197,21 @@ async def create_targeted(self, conversation_id: str, activity: ActivityParams) The created activity """ response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true", json=activity.model_dump(by_alias=True, exclude_none=True), ) id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) @experimental("ExperimentalTeamsTargeted") - async def update_targeted(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def update_targeted( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + ) -> SentActivity: """ Update an existing targeted activity in a conversation. @@ -170,14 +228,20 @@ async def update_targeted(self, conversation_id: str, activity_id: str, activity The updated activity """ response = await self.http.put( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true", json=activity.model_dump(by_alias=True, exclude_none=True), ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @experimental("ExperimentalTeamsTargeted") - async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: + async def delete_targeted( + self, + conversation_id: str, + activity_id: str, + *, + service_url: str | None = None, + ) -> None: """ Delete a targeted activity from a conversation. @@ -190,5 +254,5 @@ async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: activity_id: The ID of the activity to delete """ await self.http.delete( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true" ) 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 cb9e491ef..97473eece 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -7,7 +7,8 @@ from microsoft_teams.common.http import Client, ClientOptions -from ...models import ConversationResource +from ...models import AgenticIdentity, ConversationResource +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient from .activity import ActivityParams, ConversationActivityClient @@ -26,45 +27,128 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None: class ActivityOperations(ConversationOperations): """Operations for managing activities in a conversation.""" - async def create(self, activity: ActivityParams): - return await self._client.activities_client.create(self._conversation_id, activity) + async def create( + self, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.create( + self._conversation_id, activity, service_url=service_url, agentic_identity=agentic_identity + ) - async def update(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.update(self._conversation_id, activity_id, activity) + async def update( + self, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.update( + self._conversation_id, activity_id, activity, service_url=service_url, agentic_identity=agentic_identity + ) - async def reply(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.reply(self._conversation_id, activity_id, activity) + async def reply( + self, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.reply( + self._conversation_id, activity_id, activity, service_url=service_url, agentic_identity=agentic_identity + ) - async def delete(self, activity_id: str): - await self._client.activities_client.delete(self._conversation_id, activity_id) + async def delete( + self, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + await self._client.activities_client.delete( + self._conversation_id, activity_id, service_url=service_url, agentic_identity=agentic_identity + ) - async def get_members(self, activity_id: str): - return await self._client.activities_client.get_members(self._conversation_id, activity_id) + async def get_members( + self, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.get_members( + self._conversation_id, activity_id, service_url=service_url, agentic_identity=agentic_identity + ) - async def create_targeted(self, activity: ActivityParams): + async def create_targeted( + self, + activity: ActivityParams, + *, + service_url: str | None = None, + ): """Create a new targeted activity visible only to the specified recipient.""" - return await self._client.activities_client.create_targeted(self._conversation_id, activity) + return await self._client.activities_client.create_targeted( + self._conversation_id, activity, service_url=service_url + ) - async def update_targeted(self, activity_id: str, activity: ActivityParams): + async def update_targeted( + self, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + ): """Update an existing targeted activity.""" - return await self._client.activities_client.update_targeted(self._conversation_id, activity_id, activity) + return await self._client.activities_client.update_targeted( + self._conversation_id, activity_id, activity, service_url=service_url + ) - async def delete_targeted(self, activity_id: str): + async def delete_targeted( + self, + activity_id: str, + *, + service_url: str | None = None, + ): """Delete a targeted activity.""" - await self._client.activities_client.delete_targeted(self._conversation_id, activity_id) + await self._client.activities_client.delete_targeted( + self._conversation_id, activity_id, service_url=service_url + ) class MemberOperations(ConversationOperations): """Operations for managing members in a conversation.""" - async def get_all(self): - return await self._client.members_client.get(self._conversation_id) + async def get_all(self, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None): + return await self._client.members_client.get( + self._conversation_id, service_url=service_url, agentic_identity=agentic_identity + ) - 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_paged( + self, + page_size: Optional[int] = None, + continuation_token: Optional[str] = None, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.members_client.get_paged( + self._conversation_id, + page_size, + continuation_token, + service_url=service_url, + agentic_identity=agentic_identity, + ) - async def get(self, member_id: str): - return await self._client.members_client.get_by_id(self._conversation_id, member_id) + async def get( + self, member_id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ): + return await self._client.members_client.get_by_id( + self._conversation_id, member_id, service_url=service_url, agentic_identity=agentic_identity + ) class ConversationClient(BaseClient): @@ -86,8 +170,16 @@ def __init__( super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") - self._activities_client = ConversationActivityClient(self.service_url, self.http, self._api_client_settings) - self._members_client = ConversationMemberClient(self.service_url, self.http, self._api_client_settings) + self._activities_client = ConversationActivityClient( + self.service_url, + self.http, + self._api_client_settings, + ) + self._members_client = ConversationMemberClient( + self.service_url, + self.http, + self._api_client_settings, + ) @property def http(self) -> Client: @@ -133,7 +225,13 @@ def members(self, conversation_id: str) -> MemberOperations: """ return MemberOperations(self, conversation_id) - async def create(self, params: CreateConversationParams) -> ConversationResource: + async def create( + self, + params: CreateConversationParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> ConversationResource: """Create a new conversation. Args: @@ -143,7 +241,8 @@ async def create(self, params: CreateConversationParams) -> ConversationResource The created conversation resource. """ response = await self.http.post( - f"{self.service_url}/v3/conversations", + f"{self._get_service_url(service_url)}/v3/conversations", json=params.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) return ConversationResource.model_validate(response.json()) 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 78a7a958a..5c662f89e 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/member.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/member.py @@ -3,12 +3,15 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import List, Optional from microsoft_teams.common.http import Client -from ...models import TeamsChannelAccount +from ...models import AgenticIdentity, TeamsChannelAccount from ...models.conversation import PagedMembersResult +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -35,7 +38,13 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url.rstrip("/") - async def get(self, conversation_id: str) -> List[TeamsChannelAccount]: + async def get( + self, + conversation_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> List[TeamsChannelAccount]: """ Get all members in a conversation. @@ -45,7 +54,10 @@ async def get(self, conversation_id: str) -> List[TeamsChannelAccount]: Returns: List of TeamsChannelAccount objects representing the conversation members """ - response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/members", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return [TeamsChannelAccount.model_validate(member) for member in response.json()] async def get_paged( @@ -53,6 +65,9 @@ async def get_paged( conversation_id: str, page_size: Optional[int] = None, continuation_token: Optional[str] = None, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> PagedMembersResult: """ Get a page of members in a conversation. @@ -66,11 +81,27 @@ async def get_paged( PagedMembersResult containing the members and an optional continuation token for fetching subsequent pages. """ - url = f"{self.service_url}/v3/conversations/{conversation_id}/pagedMembers" - response = await self.http.get(url, params={"pageSize": page_size, "continuationToken": continuation_token}) + url = f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/pagedMembers" + params: dict[str, int | str] = {} + if page_size is not None: + params["pageSize"] = page_size + if continuation_token is not None: + params["continuationToken"] = continuation_token + response = await self.http.get( + url, + params=params, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return PagedMembersResult.model_validate(response.json()) - async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelAccount: + async def get_by_id( + self, + conversation_id: str, + member_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> TeamsChannelAccount: """ Get a specific member in a conversation. @@ -81,5 +112,8 @@ async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelA Returns: TeamsChannelAccount object representing the conversation member """ - response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members/{member_id}") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/members/{member_id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return TeamsChannelAccount.model_validate(response.json()) 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 cea7277b9..b9ae3e417 100644 --- a/packages/api/src/microsoft_teams/api/clients/meeting/client.py +++ b/packages/api/src/microsoft_teams/api/clients/meeting/client.py @@ -3,12 +3,15 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ...models import MeetingInfo, MeetingParticipant +from ...models import AgenticIdentity, MeetingInfo, MeetingParticipant from ...models.meetings.meeting_notification import MeetingNotificationParams, MeetingNotificationResponse +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -33,7 +36,9 @@ def __init__( super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") - async def get_by_id(self, id: str) -> MeetingInfo: + async def get_by_id( + self, id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> MeetingInfo: """ Retrieves meeting information including details, organizer, and conversation. @@ -43,10 +48,21 @@ async def get_by_id(self, id: str) -> MeetingInfo: Returns: The meeting information. """ - response = await self.http.get(f"{self.service_url}/v1/meetings/{id}") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v1/meetings/{id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return MeetingInfo.model_validate(response.json()) - async def get_participant(self, meeting_id: str, id: str, tenant_id: str) -> MeetingParticipant: + async def get_participant( + self, + meeting_id: str, + id: str, + tenant_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> MeetingParticipant: """ Retrieves information about a specific participant in a meeting. @@ -58,12 +74,20 @@ 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) + url = f"{self._get_service_url(service_url)}/v1/meetings/{meeting_id}/participants/{id}?tenantId={tenant_id}" + response = await self.http.get( + url, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return MeetingParticipant.model_validate(response.json()) async def send_notification( - self, meeting_id: str, params: MeetingNotificationParams + self, + meeting_id: str, + params: MeetingNotificationParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> Optional[MeetingNotificationResponse]: """ Send a targeted meeting notification to participants. @@ -80,8 +104,9 @@ async def send_notification( with per-recipient failure details on partial success. """ response = await self.http.post( - f"{self.service_url}/v1/meetings/{meeting_id}/notification", + f"{self._get_service_url(service_url)}/v1/meetings/{meeting_id}/notification", json=params.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) if not response.text: return None diff --git a/packages/api/src/microsoft_teams/api/clients/reaction/client.py b/packages/api/src/microsoft_teams/api/clients/reaction/client.py index ac9d68c45..3a6296168 100644 --- a/packages/api/src/microsoft_teams/api/clients/reaction/client.py +++ b/packages/api/src/microsoft_teams/api/clients/reaction/client.py @@ -7,7 +7,9 @@ from microsoft_teams.common.http import Client +from ...models import AgenticIdentity from ...models.message import MessageReactionType +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -39,6 +41,9 @@ async def add( conversation_id: str, activity_id: str, reaction_type: MessageReactionType, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> None: """ Adds a reaction on an activity in a conversation. @@ -49,15 +54,22 @@ async def add( reaction_type: The reaction type (for example: "like", "heart", "laugh", etc.). """ url = ( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}" + f"/activities/{activity_id}/reactions/{reaction_type}" + ) + await self.http.put( + url, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) - await self.http.put(url) async def delete( self, conversation_id: str, activity_id: str, reaction_type: MessageReactionType, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> None: """ Removes a reaction from an activity in a conversation. @@ -68,6 +80,10 @@ async def delete( reaction_type: The reaction type to remove (for example: "like", "heart", "laugh", etc.). """ url = ( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}" + f"/activities/{activity_id}/reactions/{reaction_type}" + ) + await self.http.delete( + url, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) - await self.http.delete(url) 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 7ffca2e92..d3dabb49b 100644 --- a/packages/api/src/microsoft_teams/api/clients/team/client.py +++ b/packages/api/src/microsoft_teams/api/clients/team/client.py @@ -3,11 +3,14 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import List, Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ...models import ChannelInfo, TeamDetails +from ...models import AgenticIdentity, ChannelInfo, TeamDetails +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient from .params import GetTeamConversationsResponse @@ -33,7 +36,9 @@ def __init__( super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") - async def get_by_id(self, id: str) -> TeamDetails: + async def get_by_id( + self, id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> TeamDetails: """ Get team details by ID. @@ -43,10 +48,15 @@ async def get_by_id(self, id: str) -> TeamDetails: Returns: The team details. """ - response = await self.http.get(f"{self.service_url}/v3/teams/{id}") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/teams/{id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return TeamDetails.model_validate(response.json()) - async def get_conversations(self, id: str) -> List[ChannelInfo]: + async def get_conversations( + self, id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> List[ChannelInfo]: """ Get team conversations (channels). @@ -56,5 +66,8 @@ async def get_conversations(self, id: str) -> List[ChannelInfo]: Returns: List of channel information. """ - response = await self.http.get(f"{self.service_url}/v3/teams/{id}/conversations") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/teams/{id}/conversations", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return GetTeamConversationsResponse.model_validate(response.json()).conversations diff --git a/packages/api/tests/unit/test_api_client.py b/packages/api/tests/unit/test_api_client.py index b1b439e45..1181da6b6 100644 --- a/packages/api/tests/unit/test_api_client.py +++ b/packages/api/tests/unit/test_api_client.py @@ -6,6 +6,8 @@ import pytest from microsoft_teams.api.clients import ApiClient, ReactionClient +from microsoft_teams.api.clients._auth_provider_interceptor import AuthProviderInterceptor +from microsoft_teams.api.models import AgenticIdentity from microsoft_teams.common.http import Client, ClientOptions @@ -23,6 +25,28 @@ def test_reactions_first_access_creates_reaction_client(self, mock_http_client): assert reactions is not None assert isinstance(reactions, ReactionClient) + def test_reactions_inherits_agentic_auth_defaults(self, mock_http_client): + """Test reactions inherits agentic auth defaults from ApiClient.""" + auth_provider = object() + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient( + "https://mock.service.url", + mock_http_client, + auth_provider=auth_provider, + agentic_identity=identity, + ) + + reactions = client.reactions + + assert not hasattr(reactions, "_auth_provider") + assert not hasattr(reactions, "_agentic_identity") + interceptor = next( + interceptor + for interceptor in mock_http_client.interceptors + if isinstance(interceptor, AuthProviderInterceptor) + ) + assert interceptor._default_agentic_identity is identity + def test_reactions_second_access_returns_cached_client(self, mock_http_client): """Test that the reactions property returns the same instance on subsequent accesses.""" client = ApiClient("https://mock.service.url", mock_http_client) diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index cc309015d..d8a701af4 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -9,9 +9,11 @@ import httpx import pytest +from microsoft_teams.api.auth.cloud_environment import PUBLIC, with_overrides +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.conversation import ConversationClient from microsoft_teams.api.clients.conversation.params import CreateConversationParams -from microsoft_teams.api.models import ConversationResource, PagedMembersResult, TeamsChannelAccount +from microsoft_teams.api.models import AgenticIdentity, ConversationResource, PagedMembersResult, TeamsChannelAccount from microsoft_teams.common.http import Client, ClientOptions @@ -100,6 +102,54 @@ async def test_create_conversation_without_activity(self, request_capture, mock_ assert last_request.method == "POST" assert str(last_request.url) == "https://test.service.url/v3/conversations" + @pytest.mark.asyncio + async def test_create_conversation_uses_service_url_override(self, request_capture, mock_account): + client = ConversationClient("https://test.service.url", request_capture) + params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id") + + await client.create(params, service_url="https://override.service.url/") + + request = request_capture._capture.last_request + assert request.method == "POST" + assert str(request.url) == "https://override.service.url/v3/conversations" + + @pytest.mark.asyncio + async def test_create_conversation_uses_auth_provider_for_bot_token(self, request_capture, mock_account): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id") + + await client.create(params) + + assert calls == [(None, None)] + request = request_capture._capture.last_request + assert request.headers["authorization"] == "Bearer bot-token" + + @pytest.mark.asyncio + async def test_create_conversation_uses_agentic_identity(self, request_capture, mock_account): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id") + + await client.create(params, agentic_identity=identity) + + assert calls == [(None, identity)] + request = request_capture._capture.last_request + assert request.headers["authorization"] == "Bearer agentic-token" + def test_conversation_resource_with_all_fields(self): """Test that ConversationResource correctly handles all fields present.""" resource = ConversationResource.model_validate( @@ -199,6 +249,95 @@ async def test_activity_create(self, request_capture, mock_activity): payload = json.loads(last_request.content) assert payload["type"] == "message" + async def test_activity_create_with_service_url_override(self, request_capture, mock_activity): + """Test creating an activity with a per-request service URL override.""" + client = ConversationClient("https://default.service.url", request_capture) + + await client.activities("test_conversation_id").create( + mock_activity, service_url="https://override.service.url/" + ) + + last_request = request_capture._capture.last_request + assert str(last_request.url) == "https://override.service.url/v3/conversations/test_conversation_id/activities" + + async def test_activity_create_uses_auth_provider_for_bot_token(self, request_capture, mock_activity): + """Test creating an activity with an auth provider but no agentic identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + + await client.activities("test_conversation_id").create(mock_activity) + + assert calls == [(None, None)] + last_request = request_capture._capture.last_request + assert last_request.headers["authorization"] == "Bearer bot-token" + + async def test_activity_create_uses_client_agentic_identity(self, request_capture, mock_activity): + """Test creating an activity with the client's default agentic identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + cloud = with_overrides(PUBLIC, agentic_bot_scope="agentic-scope") + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient( + "https://test.service.url", + request_capture, + auth_provider=TestAuthProvider(), + agentic_identity=identity, + cloud=cloud, + ).conversations + + await client.activities("test_conversation_id").create(mock_activity) + + assert calls == [(None, identity)] + last_request = request_capture._capture.last_request + assert last_request.headers["authorization"] == "Bearer agentic-token" + + async def test_activity_create_agentic_identity_overrides_client_default(self, request_capture, mock_activity): + """Test per-request agentic identity overrides the client's default identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "override-token" + + default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id") + override_identity = AgenticIdentity("override-app-id", "override-user-id", tenant_id="override-tenant-id") + client = ApiClient( + "https://test.service.url", + request_capture, + auth_provider=TestAuthProvider(), + agentic_identity=default_identity, + ).conversations + + await client.activities("test_conversation_id").create(mock_activity, agentic_identity=override_identity) + + assert calls == [(None, override_identity)] + last_request = request_capture._capture.last_request + assert last_request.headers["authorization"] == "Bearer override-token" + + async def test_activity_create_agentic_identity_without_auth_provider_uses_http_client_auth( + self, request_capture, mock_activity + ): + """Test agentic identity without an auth provider leaves auth resolution to the HTTP client.""" + client = ConversationClient("https://test.service.url", request_capture) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.activities("test_conversation_id").create(mock_activity, agentic_identity=identity) + + last_request = request_capture._capture.last_request + assert "authorization" not in last_request.headers + async def test_activity_update(self, request_capture, mock_activity): """Test updating an activity.""" service_url = "https://test.service.url" @@ -365,6 +504,68 @@ async def test_member_get(self, request_capture): str(last_request.url) == f"https://test.service.url/v3/conversations/{conversation_id}/members/{member_id}" ) + async def test_member_operations_use_service_url_override(self, request_capture): + client = ConversationClient("https://test.service.url", request_capture) + members = client.members("test_conversation_id") + + await members.get_all(service_url="https://override.service.url/") + await members.get("test_member_id", service_url="https://override.service.url/") + await members.get_paged(page_size=10, service_url="https://override.service.url/") + + urls = [str(request.url) for request in request_capture._capture.requests[-3:]] + assert urls == [ + "https://override.service.url/v3/conversations/test_conversation_id/members", + "https://override.service.url/v3/conversations/test_conversation_id/members/test_member_id", + "https://override.service.url/v3/conversations/test_conversation_id/pagedMembers?pageSize=10", + ] + + async def test_member_operations_use_auth_provider_for_bot_token(self, request_capture): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + members = client.members("test_conversation_id") + + await members.get_all() + await members.get("test_member_id") + await members.get_paged(page_size=10) + + assert calls == [ + (None, None), + (None, None), + (None, None), + ] + for request in request_capture._capture.requests[-3:]: + assert request.headers["authorization"] == "Bearer bot-token" + + async def test_member_operations_use_agentic_identity(self, request_capture): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + members = client.members("test_conversation_id") + + await members.get_all(agentic_identity=identity) + await members.get("test_member_id", agentic_identity=identity) + await members.get_paged(page_size=10, agentic_identity=identity) + + assert calls == [ + (None, identity), + (None, identity), + (None, identity), + ] + for request in request_capture._capture.requests[-3:]: + assert request.headers["authorization"] == "Bearer agentic-token" + async def test_member_get_paged(self, mock_http_client): """Test getting a page of members returns PagedMembersResult.""" diff --git a/packages/api/tests/unit/test_meeting_client.py b/packages/api/tests/unit/test_meeting_client.py index 4f4e7d183..5a6ff7619 100644 --- a/packages/api/tests/unit/test_meeting_client.py +++ b/packages/api/tests/unit/test_meeting_client.py @@ -8,8 +8,10 @@ import httpx import pytest +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.meeting import MeetingClient from microsoft_teams.api.models import ( + AgenticIdentity, MeetingInfo, MeetingNotificationParams, MeetingNotificationResponse, @@ -48,6 +50,99 @@ async def test_get_participant(self, mock_http_client): assert isinstance(result, MeetingParticipant) + @pytest.mark.asyncio + async def test_meeting_operations_use_service_url_override(self, mock_http_client): + client = MeetingClient("https://test.service.url", mock_http_client) + + meeting_response = httpx.Response( + 200, + json={ + "id": "meeting-id", + "details": { + "id": "meeting-id", + "type": "meetingChat", + "joinUrl": "https://teams.microsoft.com/l/meetup-join/meeting-id", + "title": "Meeting", + "msGraphResourceId": "graph-resource-id", + }, + }, + headers={"content-type": "application/json"}, + ) + with patch.object(mock_http_client, "get", new_callable=AsyncMock, return_value=meeting_response) as mock_get: + await client.get_by_id("meeting-id", service_url="https://override.service.url/") + + mock_get.assert_called_once_with( + "https://override.service.url/v1/meetings/meeting-id", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + participant_response = httpx.Response( + 200, + json={"user": {"id": "participant-id"}}, + headers={"content-type": "application/json"}, + ) + with patch.object( + mock_http_client, "get", new_callable=AsyncMock, return_value=participant_response + ) as mock_get_participant: + await client.get_participant( + "meeting-id", + "participant-id", + "tenant-id", + service_url="https://override.service.url/", + ) + + mock_get_participant.assert_called_once_with( + "https://override.service.url/v1/meetings/meeting-id/participants/participant-id?tenantId=tenant-id", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + params = MeetingNotificationParams( + value=MeetingNotificationValue( + recipients=["mock_aad_oid"], + surfaces=[MeetingNotificationSurface(surface="meetingTabIcon", tab_entity_id="test")], + ) + ) + notification_response = httpx.Response(202, content=b"", headers={"content-type": "application/json"}) + with patch.object( + mock_http_client, "post", new_callable=AsyncMock, return_value=notification_response + ) as mock_post: + await client.send_notification("meeting-id", params, service_url="https://override.service.url/") + + mock_post.assert_called_once_with( + "https://override.service.url/v1/meetings/meeting-id/notification", + json=params.model_dump(by_alias=True, exclude_none=True), + extensions={"microsoft_teams.agentic_identity": None}, + ) + + @pytest.mark.asyncio + async def test_get_by_id_uses_auth_provider_for_bot_token(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).meetings + await client.get_by_id("meeting-id") + + assert calls == [(None, None)] + + @pytest.mark.asyncio + async def test_get_participant_uses_agentic_identity(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).meetings + await client.get_participant("meeting-id", "participant-id", "tenant-id", agentic_identity=identity) + + assert calls == [(None, identity)] + def test_http_client_property(self, mock_http_client): """Test HTTP client property getter and setter.""" service_url = "https://test.service.url" diff --git a/packages/api/tests/unit/test_reaction_client.py b/packages/api/tests/unit/test_reaction_client.py index f17868847..72eeecbd0 100644 --- a/packages/api/tests/unit/test_reaction_client.py +++ b/packages/api/tests/unit/test_reaction_client.py @@ -7,7 +7,10 @@ from unittest.mock import AsyncMock, patch import pytest +from microsoft_teams.api.auth.cloud_environment import PUBLIC, with_overrides +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.reaction import ReactionClient +from microsoft_teams.api.models import AgenticIdentity @pytest.mark.unit @@ -53,7 +56,77 @@ async def test_add_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_put.assert_called_once_with(expected_url) + mock_put.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) + + @pytest.mark.asyncio + async def test_reaction_operations_use_service_url_override(self, mock_http_client): + client = ReactionClient("https://test.service.url", mock_http_client) + + with patch.object(mock_http_client, "put", new_callable=AsyncMock) as mock_put: + await client.add( + "test_conversation_id", + "test_activity_id", + "like", + service_url="https://override.service.url/", + ) + + mock_put.assert_called_once_with( + "https://override.service.url/v3/conversations/test_conversation_id/activities/test_activity_id/reactions/like", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + with patch.object(mock_http_client, "delete", new_callable=AsyncMock) as mock_delete: + await client.delete( + "test_conversation_id", + "test_activity_id", + "like", + service_url="https://override.service.url/", + ) + + mock_delete.assert_called_once_with( + "https://override.service.url/v3/conversations/test_conversation_id/activities/test_activity_id/reactions/like", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + @pytest.mark.asyncio + async def test_add_reaction_uses_agentic_identity(self, mock_http_client): + """Test adding a reaction with an agentic token.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + cloud = with_overrides(PUBLIC, agentic_bot_scope="agentic-scope") + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient( + "https://test.service.url", + mock_http_client, + auth_provider=TestAuthProvider(), + agentic_identity=identity, + cloud=cloud, + ).reactions + + await client.add("test_conversation_id", "test_activity_id", "like") + + assert calls == [(None, identity)] + + @pytest.mark.asyncio + async def test_add_reaction_uses_auth_provider_for_bot_token(self, mock_http_client): + """Test adding a reaction with an auth provider but no agentic identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).reactions + + await client.add("test_conversation_id", "test_activity_id", "like") + + assert calls == [(None, None)] @pytest.mark.asyncio async def test_add_heart_reaction(self, mock_http_client): @@ -71,7 +144,7 @@ async def test_add_heart_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_put.assert_called_once_with(expected_url) + mock_put.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) @pytest.mark.asyncio async def test_delete_reaction(self, mock_http_client): @@ -89,7 +162,24 @@ async def test_delete_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_delete.assert_called_once_with(expected_url) + mock_delete.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) + + @pytest.mark.asyncio + async def test_delete_reaction_uses_method_agentic_identity(self, mock_http_client): + """Test removing a reaction with a per-call agentic token.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).reactions + + await client.delete("test_conversation_id", "test_activity_id", "like", agentic_identity=identity) + + assert calls == [(None, identity)] @pytest.mark.asyncio async def test_delete_laugh_reaction(self, mock_http_client): @@ -107,4 +197,4 @@ async def test_delete_laugh_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_delete.assert_called_once_with(expected_url) + mock_delete.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) diff --git a/packages/api/tests/unit/test_team_client.py b/packages/api/tests/unit/test_team_client.py index a47b5edb1..2f68013eb 100644 --- a/packages/api/tests/unit/test_team_client.py +++ b/packages/api/tests/unit/test_team_client.py @@ -4,9 +4,13 @@ """ # pyright: basic +from unittest.mock import AsyncMock, patch + +import httpx import pytest +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.team import TeamClient -from microsoft_teams.api.models import ChannelInfo, TeamDetails +from microsoft_teams.api.models import AgenticIdentity, ChannelInfo, TeamDetails from microsoft_teams.common.http import Client, ClientOptions @@ -37,6 +41,67 @@ async def test_get_conversations(self, mock_http_client): assert isinstance(result, list) assert all(isinstance(channel, ChannelInfo) for channel in result) + @pytest.mark.asyncio + async def test_team_operations_use_service_url_override(self, mock_http_client): + client = TeamClient("https://test.service.url", mock_http_client) + + team_response = httpx.Response( + 200, + json={"id": "team-id", "name": "Team"}, + headers={"content-type": "application/json"}, + ) + with patch.object(mock_http_client, "get", new_callable=AsyncMock, return_value=team_response) as mock_get: + await client.get_by_id("team-id", service_url="https://override.service.url/") + + mock_get.assert_called_once_with( + "https://override.service.url/v3/teams/team-id", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + conversations_response = httpx.Response( + 200, + json={"conversations": []}, + headers={"content-type": "application/json"}, + ) + with patch.object( + mock_http_client, "get", new_callable=AsyncMock, return_value=conversations_response + ) as mock_get_conversations: + await client.get_conversations("team-id", service_url="https://override.service.url/") + + mock_get_conversations.assert_called_once_with( + "https://override.service.url/v3/teams/team-id/conversations", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + @pytest.mark.asyncio + async def test_get_by_id_uses_auth_provider_for_bot_token(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).teams + await client.get_by_id("team-id") + + assert calls == [(None, None)] + + @pytest.mark.asyncio + async def test_get_conversations_uses_agentic_identity(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).teams + await client.get_conversations("team-id", agentic_identity=identity) + + assert calls == [(None, identity)] + def test_http_client_property(self, mock_http_client): """Test HTTP client property getter and setter.""" service_url = "https://test.service.url"