diff --git a/README.md b/README.md index 5068afedb..592ca9788 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ A comprehensive SDK for building Microsoft Teams applications, bots, and AI agents using Python. This SDK provides a high-level framework with built-in Microsoft Graph integration, OAuth handling, and extensible plugin architecture. +## Agent 365 Support + +Agent 365 support is being developed on this integration branch. + diff --git a/examples/agent365/README.md b/examples/agent365/README.md new file mode 100644 index 000000000..21bf73109 --- /dev/null +++ b/examples/agent365/README.md @@ -0,0 +1,30 @@ +# agent365 + +Demonstrates passing `AgenticIdentity` directly to Teams API surfaces. + +## Reactive Echo + +`src/main.py` mimics the echo example. Incoming messages are handled normally; the inbound service URL and agentic identity are carried by the context/API layer. + +```bash +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/main.py +``` + +## Proactive API Send + +`src/proactive.py` shows both `app.send(..., agentic_identity=...)` and the lower-level conversation activity API. In both cases the API layer asks the auth provider for the right Agent ID token and uses it in the request header. + +```bash +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/proactive.py \ + \ + \ + +``` diff --git a/examples/agent365/pyproject.toml b/examples/agent365/pyproject.toml new file mode 100644 index 000000000..43fc1ee3d --- /dev/null +++ b/examples/agent365/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "agent365" +version = "0.1.0" +description = "Agent 365 token example" +readme = "README.md" +requires-python = ">=3.11,<4.0" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py new file mode 100644 index 000000000..355b3d1f8 --- /dev/null +++ b/examples/agent365/src/main.py @@ -0,0 +1,51 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio +import logging +import re + +from microsoft_teams.api import MessageActivity +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.apps import ActivityContext, App + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = App() + + +@app.on_message_pattern(re.compile(r"hello|hi|greetings")) +async def handle_greeting(ctx: ActivityContext[MessageActivity]) -> None: + """Handle greeting messages using the inbound AgenticIdentity when present.""" + await ctx.reply("Hello! How can I assist you today?") + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + """Echo incoming messages using the inbound AgenticIdentity when present.""" + logger.info("[Agent365 reactive] Message received: %s", ctx.activity.text) + logger.info("[Agent365 reactive] From: %s", ctx.activity.from_) + logger.info("[Agent365 reactive] Agentic identity: %s", ctx.activity.recipient.agentic_identity) + + await ctx.reply(TypingActivityInput()) + + if "react" in ctx.activity.text.lower(): + await ctx.api.reactions.add( + conversation_id=ctx.activity.conversation.id, + activity_id=ctx.activity.id, + reaction_type="like", + ) + await ctx.reply("Added a like reaction to your message.") + return + + if "reply" in ctx.activity.text.lower(): + await ctx.reply("Hello! How can I assist you today?") + else: + await ctx.send(f"You said '{ctx.activity.text}'") + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/examples/agent365/src/proactive.py b/examples/agent365/src/proactive.py new file mode 100644 index 000000000..4736bdab6 --- /dev/null +++ b/examples/agent365/src/proactive.py @@ -0,0 +1,43 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import argparse +import asyncio +import logging + +from microsoft_teams.api import MessageActivityInput +from microsoft_teams.apps import App + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + parser = argparse.ArgumentParser(description="Send proactive messages using AgenticIdentity") + parser.add_argument("conversation_id", help="The Teams conversation ID to send messages to") + parser.add_argument("agentic_app_id", help="The concrete agent identity app/client ID") + parser.add_argument("agentic_user_id", help="The agent user object ID") + args = parser.parse_args() + + app = App() + await app.initialize() + + agentic_identity = app.get_agentic_identity(args.agentic_app_id, args.agentic_user_id) + sent = await app.send( + args.conversation_id, + "Hello from app.send with an AgenticIdentity.", + agentic_identity=agentic_identity, + ) + logger.info("Sent activity through app.send. Activity ID: %s", sent.id) + + api_sent = await app.api.conversations.activities(args.conversation_id).create( + MessageActivityInput(text="Hello from the conversation activity API with an AgenticIdentity."), + agentic_identity=agentic_identity, + ) + logger.info("Sent activity through app.api. Activity ID: %s", api_sent.id) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/api/src/microsoft_teams/api/auth/__init__.py b/packages/api/src/microsoft_teams/api/auth/__init__.py index 10f62a86b..ff54c02eb 100644 --- a/packages/api/src/microsoft_teams/api/auth/__init__.py +++ b/packages/api/src/microsoft_teams/api/auth/__init__.py @@ -10,11 +10,13 @@ ) from .cloud_environment import from_name as config_from_cloud_name from .credentials import ( + BasicTokenProvider, ClientCredentials, Credentials, FederatedIdentityCredentials, ManagedIdentityCredentials, TokenCredentials, + TokenProvider, ) from .json_web_token import JsonWebToken, JsonWebTokenPayload from .token import TokenProtocol @@ -23,12 +25,14 @@ "CallerIds", "CallerType", "CloudEnvironment", + "BasicTokenProvider", "ClientCredentials", "config_from_cloud_name", "Credentials", "FederatedIdentityCredentials", "ManagedIdentityCredentials", "TokenCredentials", + "TokenProvider", "TokenProtocol", "JsonWebToken", "JsonWebTokenPayload", diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py index 4821ca5b8..ef7ac2ed0 100644 --- a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py +++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py @@ -21,6 +21,8 @@ class CloudEnvironment: """The default multi-tenant login tenant (e.g. "botframework.com").""" bot_scope: str """The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default").""" + agentic_bot_scope: str + """The Teams Bot API scope for Agent ID user-token calls.""" token_service_url: str """The Bot Framework token service base URL (e.g. "https://token.botframework.com").""" openid_metadata_url: str @@ -35,6 +37,7 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.com", login_tenant="botframework.com", bot_scope="https://api.botframework.com/.default", + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://token.botframework.com", openid_metadata_url="https://login.botframework.com/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.com", @@ -46,6 +49,8 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.us", login_tenant="MicrosoftServices.onmicrosoft.us", bot_scope="https://api.botframework.us/.default", + # TODO: confirm Agent ID Bot API scope for this cloud before enabling sovereign Agent ID scenarios. + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://tokengcch.botframework.azure.us", openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.us", @@ -57,6 +62,8 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.us", login_tenant="MicrosoftServices.onmicrosoft.us", bot_scope="https://api.botframework.us/.default", + # TODO: confirm Agent ID Bot API scope for this cloud before enabling sovereign Agent ID scenarios. + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://apiDoD.botframework.azure.us", openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.us", @@ -68,6 +75,8 @@ class CloudEnvironment: login_endpoint="https://login.partner.microsoftonline.cn", login_tenant="microsoftservices.partner.onmschina.cn", bot_scope="https://api.botframework.azure.cn/.default", + # TODO: confirm Agent ID Bot API scope for this cloud before enabling China cloud Agent ID scenarios. + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://token.botframework.azure.cn", openid_metadata_url="https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.azure.cn", diff --git a/packages/api/src/microsoft_teams/api/auth/credentials.py b/packages/api/src/microsoft_teams/api/auth/credentials.py index 471c7952c..5d3b4c3fc 100644 --- a/packages/api/src/microsoft_teams/api/auth/credentials.py +++ b/packages/api/src/microsoft_teams/api/auth/credentials.py @@ -3,9 +3,34 @@ Licensed under the MIT License. """ -from typing import Awaitable, Callable, Literal, Optional, Union +from typing import Awaitable, Callable, Literal, Optional, Protocol, TypeAlias, Union, runtime_checkable -from ..models import CustomBaseModel +from ..models import AgenticIdentity, CustomBaseModel + +TokenScope: TypeAlias = Union[str, list[str]] +TokenResult: TypeAlias = Union[str, Awaitable[str]] +BasicTokenProvider: TypeAlias = Callable[[TokenScope, Optional[str]], TokenResult] +_PositionalAgenticTokenProvider: TypeAlias = Callable[ + [TokenScope, Optional[str], Optional[AgenticIdentity]], TokenResult +] + + +@runtime_checkable +class _KeywordAgenticTokenProvider(Protocol): + def __call__( + self, + scope: TokenScope, + tenant_id: Optional[str], + *, + agentic_identity: Optional[AgenticIdentity] = None, + ) -> TokenResult: ... + + +TokenProvider: TypeAlias = Union[ + BasicTokenProvider, + _PositionalAgenticTokenProvider, + _KeywordAgenticTokenProvider, +] class ClientCredentials(CustomBaseModel): @@ -36,8 +61,8 @@ class TokenCredentials(CustomBaseModel): """ The tenant ID. """ - # (scope: string | string[], tenantId?: string) => string | Promise - token: Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]] + # (scope: string | string[], tenantId?: string, agenticIdentity?: AgenticIdentity) => string | Promise + token: TokenProvider """ The token function. """ 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/_auth_provider_interceptor.py b/packages/api/src/microsoft_teams/api/clients/_auth_provider_interceptor.py new file mode 100644 index 000000000..2f97d2e2e --- /dev/null +++ b/packages/api/src/microsoft_teams/api/clients/_auth_provider_interceptor.py @@ -0,0 +1,54 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import logging +from typing import Awaitable, Protocol, cast + +from microsoft_teams.common import InterceptorRequestContext, resolve_token +from microsoft_teams.common.http.client_token import StringLike + +from ..models.agentic_identity import AgenticIdentity + + +class AuthProvider(Protocol): + def token( + self, *, scope: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> str | StringLike | None | Awaitable[str | StringLike | None]: ... + + +AGENTIC_IDENTITY_EXTENSION = "microsoft_teams.agentic_identity" +logger = logging.getLogger(__name__) + + +class AuthProviderInterceptor: + """Adds an auth-provider token when a request has no Authorization header.""" + + def __init__( + self, + auth_provider: AuthProvider, + *, + default_agentic_identity: AgenticIdentity | None = None, + ) -> None: + self._auth_provider = auth_provider + self._default_agentic_identity = default_agentic_identity + + async def request(self, ctx: InterceptorRequestContext) -> None: + if "Authorization" in ctx.request.headers: + return + + request_agentic_identity = cast(AgenticIdentity | None, ctx.request.extensions.get(AGENTIC_IDENTITY_EXTENSION)) + agentic_identity = request_agentic_identity or self._default_agentic_identity + token = await resolve_token(lambda: self._auth_provider.token(agentic_identity=agentic_identity)) + if token is None: + return + + token = token.strip() + if not token: + logger.warning("Auth provider returned an empty token; skipping Authorization header.") + return + + ctx.request.headers["Authorization"] = f"Bearer {token}" 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 2da68c7d5..bbc9b69a5 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -5,12 +5,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union, cast from microsoft_teams.common import Client as HttpClient -from microsoft_teams.common import ClientOptions +from microsoft_teams.common import ClientOptions, Interceptor -from .api_client_settings import ApiClientSettings +from ..auth.cloud_environment import PUBLIC, CloudEnvironment +from ..models import AgenticIdentity +from ._auth_provider_interceptor import AuthProvider, AuthProviderInterceptor +from .api_client_settings import ApiClientSettings, merge_api_client_settings from .base_client import BaseClient from .bot import BotClient from .conversation import ConversationClient @@ -19,9 +22,6 @@ from .team import TeamClient from .user import UserClient -if TYPE_CHECKING: - from ..auth.cloud_environment import CloudEnvironment - class ApiClient(BaseClient): """Unified client for Microsoft Teams API operations.""" @@ -32,6 +32,9 @@ def __init__( options: Optional[Union[HttpClient, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, cloud: Optional[CloudEnvironment] = None, + *, + auth_provider: Optional[AuthProvider] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> None: """Initialize the unified Teams API client. @@ -41,12 +44,17 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options, api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) self.service_url = service_url.rstrip("/") + self._auth_provider = auth_provider + self._default_agentic_identity = agentic_identity + self._apply_auth_provider_interceptor() # Initialize all client types - self.bots = BotClient(self._http, self._api_client_settings, cloud=cloud) - self.users = UserClient(self._http, self._api_client_settings) + self.bots = BotClient(self._http, self._api_client_settings, cloud=self._cloud) + self.users = UserClient(self._http, self._api_client_settings, cloud=self._cloud) 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) @@ -59,6 +67,23 @@ def reactions(self) -> ReactionClient: self._reactions = ReactionClient(self.service_url, self._http, self._api_client_settings) return self._reactions + def _apply_auth_provider_interceptor(self) -> None: + if self._auth_provider is None: + return + + if any(isinstance(interceptor, AuthProviderInterceptor) for interceptor in self._http.interceptors): + return + + self._http.use_interceptor( + cast( + Interceptor, + AuthProviderInterceptor( + self._auth_provider, + default_agentic_identity=self._default_agentic_identity, + ), + ) + ) + @property def http(self) -> HttpClient: """Get the HTTP client instance.""" @@ -67,11 +92,12 @@ def http(self) -> HttpClient: @http.setter def http(self, value: HttpClient) -> None: """Set the HTTP client instance and propagate to all sub-clients.""" - self.bots.http = value - self.conversations.http = value - self.users.http = value - self.teams.http = value - self.meetings.http = value - if self._reactions is not None: - self._reactions.http = value self._http = value + self._apply_auth_provider_interceptor() + self.bots.http = self._http + self.conversations.http = self._http + self.users.http = self._http + self.teams.http = self._http + self.meetings.http = self._http + if self._reactions is not None: + self._reactions.http = self._http 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 bc5ccd8f2..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,9 +3,9 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from typing import Optional, Union, cast -from microsoft_teams.common.http import Client, ClientOptions +from microsoft_teams.common import Client, ClientOptions from .api_client_settings import ApiClientSettings, merge_api_client_settings @@ -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/bot/client.py b/packages/api/src/microsoft_teams/api/clients/bot/client.py index c4e170ae4..efacd2ec0 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/client.py @@ -5,18 +5,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ..api_client_settings import ApiClientSettings +from ...auth.cloud_environment import PUBLIC, CloudEnvironment +from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient from .sign_in_client import BotSignInClient from .token_client import BotTokenClient -if TYPE_CHECKING: - from ...auth.cloud_environment import CloudEnvironment - class BotClient(BaseClient): """Client for managing bot operations.""" @@ -34,9 +32,11 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options, api_client_settings) - self.token = BotTokenClient(self.http, self._api_client_settings, cloud=cloud) - self.sign_in = BotSignInClient(self.http, self._api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) + self.token = BotTokenClient(self.http, self._api_client_settings, cloud=self._cloud) + self.sign_in = BotSignInClient(self.http, self._api_client_settings, cloud=self._cloud) @property def http(self) -> Client: diff --git a/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py b/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py index a6c65ae54..1251b24c8 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py @@ -3,10 +3,13 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Optional, Union, cast from microsoft_teams.common.http import Client, ClientOptions +from ...auth.cloud_environment import PUBLIC, CloudEnvironment from ...models import SignInUrlResponse from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient @@ -26,6 +29,7 @@ def __init__( self, options: Union[Client, ClientOptions, None] = None, api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the bot sign-in client. @@ -33,8 +37,9 @@ def __init__( options: Optional Client or ClientOptions instance. api_client_settings: Optional API client settings. """ - super().__init__(options) - self._api_client_settings = merge_api_client_settings(api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) async def get_url(self, params: GetBotSignInUrlParams) -> str: """Get a sign-in URL. diff --git a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py index d2479e780..2865792ca 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py @@ -6,7 +6,7 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Awaitable, Literal, Optional, Union, cast from microsoft_teams.api.auth.credentials import ClientCredentials from microsoft_teams.common.http import Client, ClientOptions @@ -44,7 +44,11 @@ class GetBotTokenResponse(BaseModel): class BotTokenClient(BaseClient): - """Client for managing bot tokens.""" + """Deprecated client for managing bot tokens. + + Token minting for Teams apps now happens through the MSAL-backed TokenManager + in microsoft-teams-apps. + """ def __init__( self, @@ -59,9 +63,9 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options) self._cloud = cloud or PUBLIC - self._api_client_settings = merge_api_client_settings(api_client_settings, self._cloud) + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) async def get(self, credentials: Credentials) -> GetBotTokenResponse: """Get a bot token. @@ -73,10 +77,7 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse: The bot token response. """ if isinstance(credentials, TokenCredentials): - token = credentials.token( - self._cloud.bot_scope, - credentials.tenant_id, - ) + token = self._call_token_provider(credentials, self._cloud.bot_scope) if inspect.isawaitable(token): token = await token @@ -114,10 +115,7 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: The bot token response. """ if isinstance(credentials, TokenCredentials): - token = credentials.token( - self._cloud.graph_scope, - credentials.tenant_id, - ) + token = self._call_token_provider(credentials, self._cloud.graph_scope) if inspect.isawaitable(token): token = await token @@ -144,3 +142,30 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: ) return GetBotTokenResponse.model_validate(res.json()) + + def _call_token_provider(self, credentials: TokenCredentials, scope: str) -> str | Awaitable[str]: + token_provider = cast(Any, credentials.token) + try: + parameters = list(inspect.signature(token_provider).parameters.values()) + except (TypeError, ValueError): + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id)) + + accepts_agentic_identity = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD or parameter.name == "agentic_identity" + for parameter in parameters + ) + if accepts_agentic_identity: + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id, agentic_identity=None)) + + positional_parameters = [ + parameter + for parameter in parameters + if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + required_positional_parameters = [ + parameter for parameter in positional_parameters if parameter.default is inspect.Parameter.empty + ] + if len(required_positional_parameters) >= 3: + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id, None)) + + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id)) 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/src/microsoft_teams/api/clients/user/client.py b/packages/api/src/microsoft_teams/api/clients/user/client.py index f0177fe76..2881a7729 100644 --- a/packages/api/src/microsoft_teams/api/clients/user/client.py +++ b/packages/api/src/microsoft_teams/api/clients/user/client.py @@ -3,11 +3,14 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ..api_client_settings import ApiClientSettings +from ...auth.cloud_environment import PUBLIC, CloudEnvironment +from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient from .token_client import UserTokenClient @@ -19,6 +22,8 @@ def __init__( self, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + cloud: Optional[CloudEnvironment] = None, ) -> None: """ Initialize the UserClient. @@ -27,8 +32,10 @@ def __init__( options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. api_client_settings: Optional API client settings. """ - super().__init__(options, api_client_settings) - self.token = UserTokenClient(self.http, self._api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) + self.token = UserTokenClient(self.http, self._api_client_settings, cloud=self._cloud) @property def http(self) -> Client: diff --git a/packages/api/src/microsoft_teams/api/clients/user/token_client.py b/packages/api/src/microsoft_teams/api/clients/user/token_client.py index acab73ba3..473496c45 100644 --- a/packages/api/src/microsoft_teams/api/clients/user/token_client.py +++ b/packages/api/src/microsoft_teams/api/clients/user/token_client.py @@ -3,10 +3,13 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Dict, List, Optional, Union from microsoft_teams.common.http import Client, ClientOptions +from ...auth.cloud_environment import PUBLIC, CloudEnvironment from ...models import TokenResponse, TokenStatus from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient @@ -35,6 +38,8 @@ def __init__( self, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + cloud: Optional[CloudEnvironment] = None, ) -> None: """ Initialize the UserTokenClient. @@ -43,8 +48,9 @@ def __init__( options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. api_client_settings: Optional API client settings. """ - super().__init__(options) - self._api_client_settings = merge_api_client_settings(api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) async def get(self, params: GetUserTokenParams) -> TokenResponse: """ diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index df7e229fd..b00644124 100644 --- a/packages/api/src/microsoft_teams/api/models/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/__init__.py @@ -27,6 +27,7 @@ from .activity import Activity as ActivityBase from .activity import ActivityInput as ActivityInputBase from .adaptive_card import * # noqa: F403 +from .agentic_identity import AgenticIdentity from .app_based_link_query import AppBasedLinkQuery from .attachment import * # noqa: F403 from .cache_info import CacheInfo @@ -75,6 +76,7 @@ "Action", "ActivityBase", "ActivityInputBase", + "AgenticIdentity", "AppBasedLinkQuery", "CacheInfo", "ChannelID", diff --git a/packages/api/src/microsoft_teams/api/models/account.py b/packages/api/src/microsoft_teams/api/models/account.py index 9c58c8f2b..33fad58e0 100644 --- a/packages/api/src/microsoft_teams/api/models/account.py +++ b/packages/api/src/microsoft_teams/api/models/account.py @@ -7,9 +7,11 @@ from pydantic import AliasChoices, Field +from .agentic_identity import AgenticIdentity from .custom_base_model import CustomBaseModel AccountType = Literal["person", "tag", "channel", "team", "bot"] +AccountRole = Literal["agenticUser"] class Account(CustomBaseModel): @@ -44,6 +46,30 @@ class Account(CustomBaseModel): """ The name of the account. """ + role: Optional[AccountRole | str] = None + """The role of the account in the activity.""" + agentic_user_id: Optional[str] = None + """The Agent ID user-shaped identity object ID.""" + agentic_app_id: Optional[str] = None + """The Agent ID app/client ID for the concrete agent identity.""" + agentic_app_blueprint_id: Optional[str] = None + """The Agent ID blueprint app/client ID.""" + callback_uri: Optional[str] = None + """The callback URI associated with the agent identity.""" + tenant_id: Optional[str] = None + """The tenant ID associated with the account.""" + + @property + def agentic_identity(self) -> Optional[AgenticIdentity]: + if self.agentic_app_id is None or self.agentic_user_id is None: + return None + + return AgenticIdentity( + agentic_app_id=self.agentic_app_id, + agentic_user_id=self.agentic_user_id, + tenant_id=self.tenant_id, + agentic_app_blueprint_id=self.agentic_app_blueprint_id, + ) class TeamsChannelAccount(CustomBaseModel): diff --git a/packages/api/src/microsoft_teams/api/models/agentic_identity.py b/packages/api/src/microsoft_teams/api/models/agentic_identity.py new file mode 100644 index 000000000..dcf893614 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/agentic_identity.py @@ -0,0 +1,19 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgenticIdentity: + """Identifies an Agent ID user-shaped identity and its backing agent app.""" + + agentic_app_id: str + agentic_user_id: str + tenant_id: str | None = None + agentic_app_blueprint_id: str | None = None + + +__all__ = ["AgenticIdentity"] 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_base_client.py b/packages/api/tests/unit/test_base_client.py new file mode 100644 index 000000000..8f259cbd1 --- /dev/null +++ b/packages/api/tests/unit/test_base_client.py @@ -0,0 +1,224 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import logging +from typing import cast + +import httpx +import pytest +from microsoft_teams.api.auth.cloud_environment import US_GOV +from microsoft_teams.api.clients import ApiClient +from microsoft_teams.api.clients._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION, AuthProviderInterceptor +from microsoft_teams.api.clients.base_client import BaseClient +from microsoft_teams.api.models import AgenticIdentity +from microsoft_teams.common import Client, ClientOptions, Interceptor, Token + + +class RequestRecorder: + def __init__(self): + self.requests: list[httpx.Request] = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return httpx.Response(200, json={"ok": True}, headers={"content-type": "application/json"}) + + @property + def last_request(self) -> httpx.Request: + return self.requests[-1] + + +class RecordingAuthProvider: + def __init__(self, token_value: str | None = "auth-provider-token"): + self._token_value = token_value + self.calls: list[tuple[str | None, AgenticIdentity | None]] = [] + + def token( + self, + *, + scope: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> str | None: + self.calls.append((scope, agentic_identity)) + return self._token_value + + +class HarnessClient(BaseClient): + async def post_resource( + self, + *, + token: Token | None = None, + agentic_identity: AgenticIdentity | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + return await self.http.post( + "/resource", + json={"ok": True}, + headers=headers, + token=token, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) + + +def create_client(*, default_token: Token | None = None) -> tuple[Client, RequestRecorder]: + recorder = RequestRecorder() + client = Client(ClientOptions(base_url="https://mock.api.com", token=default_token)) + client.http._transport = httpx.MockTransport(recorder.handler) + return client, recorder + + +def use_auth_provider( + http_client: Client, + auth_provider: RecordingAuthProvider, + default_agentic_identity: AgenticIdentity | None = None, +) -> None: + http_client.use_interceptor( + cast(Interceptor, AuthProviderInterceptor(auth_provider, default_agentic_identity=default_agentic_identity)) + ) + + +def test_api_client_adds_auth_provider_interceptor_once_for_shared_http_client(): + http_client, _ = create_client() + auth_provider = RecordingAuthProvider() + + ApiClient("https://test.service.url", http_client, auth_provider=auth_provider) + ApiClient("https://test.service.url", http_client, auth_provider=auth_provider) + + assert len(http_client.http.event_hooks["request"]) == 1 + + +def test_api_client_uses_cloud_token_service_url_for_default_settings(): + client = ApiClient("https://test.service.url", cloud=US_GOV) + + assert client._api_client_settings.oauth_url == US_GOV.token_service_url + + +@pytest.mark.asyncio +async def test_explicit_request_token_wins_over_auth_provider_and_http_client_token(): + http_client, recorder = create_client(default_token="http-client-token") + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.post_resource(token="explicit-token", agentic_identity=identity) + + assert auth_provider.calls == [] + assert recorder.last_request.headers["authorization"] == "Bearer explicit-token" + + +@pytest.mark.asyncio +async def test_explicit_authorization_header_wins_over_auth_provider(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource(headers={"Authorization": "Bearer explicit-header-token"}) + + assert auth_provider.calls == [] + assert recorder.last_request.headers["authorization"] == "Bearer explicit-header-token" + + +@pytest.mark.asyncio +async def test_http_client_token_wins_over_auth_provider_token(): + http_client, recorder = create_client(default_token="http-client-token") + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [] + assert recorder.last_request.headers["authorization"] == "Bearer http-client-token" + + +@pytest.mark.asyncio +async def test_auth_provider_token_is_used_when_request_has_no_auth(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [(None, None)] + assert recorder.last_request.headers["authorization"] == "Bearer auth-provider-token" + + +@pytest.mark.asyncio +async def test_no_authorization_is_added_when_auth_provider_returns_none(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value=None) + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [(None, None)] + assert "authorization" not in recorder.last_request.headers + + +@pytest.mark.asyncio +async def test_no_authorization_is_added_when_auth_provider_returns_blank_token(caplog): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value=" ") + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + with caplog.at_level(logging.WARNING): + await client.post_resource() + + assert auth_provider.calls == [(None, None)] + assert "authorization" not in recorder.last_request.headers + assert "Auth provider returned an empty token" in caplog.text + + +@pytest.mark.asyncio +async def test_http_client_token_is_used_when_no_auth_provider(): + http_client, recorder = create_client(default_token="http-client-token") + client = HarnessClient(http_client) + + await client.post_resource() + + assert recorder.last_request.headers["authorization"] == "Bearer http-client-token" + + +@pytest.mark.asyncio +async def test_agentic_identity_is_passed_to_auth_provider_interceptor(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value="agentic-token") + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.post_resource(agentic_identity=identity) + + assert auth_provider.calls == [(None, identity)] + assert recorder.last_request.headers["authorization"] == "Bearer agentic-token" + + +@pytest.mark.asyncio +async def test_default_agentic_identity_is_passed_to_auth_provider_interceptor(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value="agentic-token") + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + use_auth_provider(http_client, auth_provider, default_agentic_identity=identity) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [(None, identity)] + assert recorder.last_request.headers["authorization"] == "Bearer agentic-token" + + +@pytest.mark.asyncio +async def test_agentic_identity_without_auth_provider_uses_http_client_token(): + http_client, recorder = create_client(default_token="http-client-token") + client = HarnessClient(http_client) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.post_resource(agentic_identity=identity) + + assert recorder.last_request.headers["authorization"] == "Bearer http-client-token" diff --git a/packages/api/tests/unit/test_bot_client.py b/packages/api/tests/unit/test_bot_client.py index 3e2b17c28..c6a2e9ce4 100644 --- a/packages/api/tests/unit/test_bot_client.py +++ b/packages/api/tests/unit/test_bot_client.py @@ -5,7 +5,14 @@ # pyright: basic import pytest -from microsoft_teams.api import ApiClientSettings, BotClient, GetBotSignInResourceParams, GetBotSignInUrlParams +from microsoft_teams.api import ( + ApiClient, + ApiClientSettings, + BotClient, + GetBotSignInResourceParams, + GetBotSignInUrlParams, +) +from microsoft_teams.api.auth.credentials import TokenCredentials from microsoft_teams.common.http import Client, ClientOptions @@ -43,6 +50,57 @@ async def test_bot_token_get_graph_with_token_credentials(self, mock_http_client assert response.access_token is not None assert response.expires_in == -1 + @pytest.mark.asyncio + async def test_bot_token_get_with_uninspectable_token_provider_signature(self, mock_http_client): + from unittest.mock import patch + + calls = [] + + def token_provider(scope, tenant_id): + calls.append((scope, tenant_id)) + return "token" + + credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider) + client = BotClient(mock_http_client) + + with patch("inspect.signature", side_effect=ValueError("no signature")): + response = await client.token.get(credentials) + + assert response.access_token == "token" + assert calls == [("https://api.botframework.com/.default", "tenant-id")] + + @pytest.mark.asyncio + async def test_bot_token_get_with_positional_agentic_identity_provider(self, mock_http_client): + calls = [] + + def token_provider(scope, tenant_id, agentic_identity): + calls.append((scope, tenant_id, agentic_identity)) + return "token" + + credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider) + client = BotClient(mock_http_client) + + response = await client.token.get(credentials) + + assert response.access_token == "token" + assert calls == [("https://api.botframework.com/.default", "tenant-id", None)] + + @pytest.mark.asyncio + async def test_bot_token_get_with_optional_third_argument_uses_default(self, mock_http_client): + calls = [] + + def token_provider(scope, tenant_id, timeout=30): + calls.append((scope, tenant_id, timeout)) + return "token" + + credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider) + client = BotClient(mock_http_client) + + response = await client.token.get(credentials) + + assert response.access_token == "token" + assert calls == [("https://api.botframework.com/.default", "tenant-id", 30)] + @pytest.mark.asyncio async def test_bot_sign_in_get_url(self, mock_http_client): client = BotClient(mock_http_client) @@ -65,6 +123,24 @@ async def test_bot_sign_in_get_resource(self, mock_http_client): assert response.sign_in_link.startswith("http") assert response.token_exchange_resource is not None + @pytest.mark.asyncio + async def test_bot_sign_in_uses_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()) + params = GetBotSignInResourceParams(state="test_state", code_challenge="test_challenge") + + await client.bots.sign_in.get_resource(params) + + assert calls == [(None, None)] + request = request_capture._capture.last_request + assert request.headers["authorization"] == "Bearer bot-token" + @pytest.mark.unit class TestBotClientHttpClientSharing: @@ -106,6 +182,12 @@ def test_bot_token_client_receives_cloud(self): assert client.token._cloud.bot_scope == "https://api.botframework.us/.default" assert client.token._cloud.login_endpoint == "https://login.microsoftonline.us" + def test_bot_sign_in_client_uses_cloud_token_service_url(self): + from microsoft_teams.api.auth.cloud_environment import US_GOV + + client = BotClient(cloud=US_GOV) + assert client.sign_in._api_client_settings.oauth_url == US_GOV.token_service_url + def test_bot_token_client_defaults_to_public(self): from microsoft_teams.api.auth.cloud_environment import PUBLIC 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" diff --git a/packages/api/tests/unit/test_user_client.py b/packages/api/tests/unit/test_user_client.py index cfbfee5ef..99b17f8ee 100644 --- a/packages/api/tests/unit/test_user_client.py +++ b/packages/api/tests/unit/test_user_client.py @@ -7,6 +7,7 @@ import pytest from microsoft_teams.api import ( + ApiClient, ApiClientSettings, ExchangeUserTokenParams, GetUserAADTokenParams, @@ -37,6 +38,26 @@ async def test_user_token_get(self, mock_http_client): assert response.token == "mock_access_token_123" assert response.connection_name == "test_connection" + @pytest.mark.asyncio + async def test_user_token_get_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()).users + params = GetUserTokenParams( + user_id="test_user_id", + connection_name="test_connection", + channel_id="test_channel_id", + ) + + await client.token.get(params) + + assert calls == [(None, None)] + @pytest.mark.asyncio async def test_user_token_get_aad(self, mock_http_client): client = UserClient(mock_http_client) @@ -126,6 +147,15 @@ def test_http_client_update_propagates(self, mock_http_client): assert client.token.http == new_http_client +@pytest.mark.unit +class TestUserClientSovereignCloud: + def test_user_token_client_uses_cloud_token_service_url(self): + from microsoft_teams.api.auth.cloud_environment import US_GOV + + client = UserClient(cloud=US_GOV) + assert client.token._api_client_settings.oauth_url == US_GOV.token_service_url + + @pytest.mark.unit class TestUserClientRegionalEndpoints: @pytest.mark.asyncio diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml index c27b9db69..336c07c54 100644 --- a/packages/apps/pyproject.toml +++ b/packages/apps/pyproject.toml @@ -14,10 +14,10 @@ dependencies = [ "microsoft-teams-api", "microsoft-teams-common", "uvicorn>=0.34.3", - "cryptography>=3.4.0", + "cryptography>=48.0.1", "pyjwt[crypto]>=2.12.0", "dependency-injector>=4.48.1", - "msal>=1.33.0", + "msal>=1.37.0", "python-dotenv>=1.0.0", "pydantic-settings>=2.11.0", ] diff --git a/packages/apps/src/microsoft_teams/apps/activity_send.py b/packages/apps/src/microsoft_teams/apps/activity_send.py new file mode 100644 index 000000000..6d5924b3f --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/activity_send.py @@ -0,0 +1,58 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_teams.api import ( + ActivityParams, + AgenticIdentity, + ApiClient, + ConversationReference, + MessageActivityInput, + SentActivity, +) + + +async def send_or_update_activity( + api: ApiClient, + activity: ActivityParams, + ref: ConversationReference, + *, + agentic_identity: AgenticIdentity | None = None, +) -> SentActivity: + """Send or update an activity using the same routing rules as the removed ActivitySender.""" + is_targeted = ( + isinstance(activity, MessageActivityInput) + and activity.recipient is not None + and activity.recipient.is_targeted is True + ) + + if is_targeted and ref.conversation.conversation_type == "personal": + raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.") + + activity.from_ = ref.bot + activity.conversation = ref.conversation + + activities = api.conversations.activities(ref.conversation.id) + if activity.id: + activity_id = activity.id + if is_targeted: + res = await activities.update_targeted( + activity_id, + activity, + service_url=ref.service_url, + ) + else: + res = await activities.update( + activity_id, + activity, + service_url=ref.service_url, + agentic_identity=agentic_identity, + ) + return SentActivity.merge(activity, res) + + if is_targeted: + res = await activities.create_targeted(activity, service_url=ref.service_url) + else: + res = await activities.create(activity, service_url=ref.service_url, agentic_identity=agentic_identity) + return SentActivity.merge(activity, res) diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py deleted file mode 100644 index 1ab97c96c..000000000 --- a/packages/apps/src/microsoft_teams/apps/activity_sender.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -import logging -from typing import cast - -from microsoft_teams.api import ( - ActivityParams, - ApiClient, - ConversationReference, - MessageActivityInput, - SentActivity, -) -from microsoft_teams.common import Client - -from .http_stream import HttpStream -from .plugins.streamer import StreamerProtocol - -logger = logging.getLogger(__name__) - - -class ActivitySender: - """ - Handles sending activities to the Bot Framework. - Separate from transport concerns (HTTP, WebSocket, etc.) - """ - - def __init__(self, client: Client): - """ - Initialize ActivitySender. - - Args: - client: HTTP client with token provider configured - """ - self._client = client - - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: - """ - Send an activity to the Bot Framework. - - Args: - activity: The activity to send - ref: The conversation reference - - Returns: - The sent activity with id and other server-populated fields - """ - is_targeted = ( - isinstance(activity, MessageActivityInput) - and activity.recipient is not None - and activity.recipient.is_targeted is True - ) - - if is_targeted and ref.conversation.conversation_type == "personal": - raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.") - - # Create API client for this conversation's service URL - api = ApiClient(service_url=ref.service_url, options=self._client) - - # Merge activity with conversation reference - activity.from_ = ref.bot - activity.conversation = ref.conversation - - is_update = hasattr(activity, "id") and activity.id - activities = api.conversations.activities(ref.conversation.id) - - if is_update: - activity_id = cast(str, activity.id) - if is_targeted: - res = await activities.update_targeted(activity_id, activity) - else: - res = await activities.update(activity_id, activity) - return SentActivity.merge(activity, res) - - if is_targeted: - res = await activities.create_targeted(activity) - else: - res = await activities.create(activity) - return SentActivity.merge(activity, res) - - def create_stream(self, ref: ConversationReference) -> StreamerProtocol: - """ - Create a new activity stream for real-time updates. - - Args: - ref: The conversation reference - - Returns: - A new streaming instance - """ - # Create API client for this conversation's service URL - api = ApiClient(ref.service_url, self._client) - return HttpStream(api, ref) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..1af304b19 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -15,6 +15,7 @@ Account, ActivityBase, ActivityParams, + AgenticIdentity, ApiClient, ClientCredentials, ConversationAccount, @@ -35,13 +36,14 @@ if TYPE_CHECKING: from msgraph.graph_service_client import GraphServiceClient -from .activity_sender import ActivitySender +from .activity_send import send_or_update_activity from .app_events import EventManager from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor from .app_process import ActivityProcessor from .auth import TokenValidator from .auth.remote_function_jwt_middleware import validate_remote_function_request +from .auth_provider import AppAuthProvider from .container import Container from .contexts.function_context import FunctionContext from .events import ( @@ -100,6 +102,7 @@ def __init__(self, **options: Unpack[AppOptions]): credentials=self.credentials, cloud=self.cloud, ) + self._auth_provider = AppAuthProvider(self._token_manager, self.cloud) self.container = Container() self.container.set_provider("storage", providers.Object(self.storage)) @@ -110,9 +113,10 @@ def __init__(self, **options: Unpack[AppOptions]): self.api = ApiClient( service_url, - self.http_client.clone(ClientOptions(token=self._get_bot_token)), + self.http_client.clone(), self.options.api_client_settings, cloud=self.cloud, + auth_provider=self._auth_provider, ) plugins: List[PluginBase] = list(self.options.plugins) @@ -125,9 +129,6 @@ def __init__(self, **options: Unpack[AppOptions]): self._port: Optional[int] = None self._initialized = False - # initialize ActivitySender for sending activities - self.activity_sender = ActivitySender(self.http_client.clone(ClientOptions(token=self._get_bot_token))) - # initialize all event, activity, and plugin processors self.activity_processor = ActivityProcessor( self._router, @@ -136,8 +137,8 @@ def __init__(self, **options: Unpack[AppOptions]): self.options.default_connection_name, self.http_client, self._token_manager, + self._auth_provider, self.options.api_client_settings, - self.activity_sender, self.cloud, ) self.event_manager = EventManager(self._events) @@ -290,7 +291,14 @@ async def stop(self) -> None: self._events.emit("error", ErrorEvent(error, context={"method": "stop"})) raise - async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard): + async def send( + self, + conversation_id: str, + activity: str | ActivityParams | AdaptiveCard, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, + ) -> SentActivity: """Send an activity proactively to a conversation. Sends to the exact conversation ID provided. For channel threads, @@ -306,7 +314,7 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap conversation_ref = ConversationReference( channel_id="msteams", - service_url=self.api.service_url, + service_url=service_url or self.api.service_url, bot=Account(id=self.id), conversation=ConversationAccount(id=conversation_id), ) @@ -318,7 +326,32 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap else: activity = activity - return await self.activity_sender.send(activity, conversation_ref) + return await send_or_update_activity( + self.api, + activity, + conversation_ref, + agentic_identity=agentic_identity, + ) + + def get_agentic_identity( + self, + agentic_app_id: str, + agentic_user_id: str, + *, + tenant_id: Optional[str] = None, + agentic_app_blueprint_id: Optional[str] = None, + ) -> AgenticIdentity: + """Get an Agent ID identity for API calls.""" + resolved_tenant_id = tenant_id or (self.credentials.tenant_id if self.credentials else None) + if resolved_tenant_id is None: + raise ValueError("tenant_id is required to get an agentic identity") + + return AgenticIdentity( + agentic_app_id=agentic_app_id, + agentic_user_id=agentic_user_id, + tenant_id=resolved_tenant_id, + agentic_app_blueprint_id=agentic_app_blueprint_id, + ) @overload async def reply( @@ -326,6 +359,9 @@ async def reply( conversation_id: str, message_id: str, activity: str | ActivityParams | AdaptiveCard, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> SentActivity: ... @overload @@ -333,6 +369,9 @@ async def reply( self, conversation_id: str, message_id: str | ActivityParams | AdaptiveCard, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> SentActivity: ... async def reply( # type: ignore[reportInconsistentOverload] @@ -340,6 +379,9 @@ async def reply( # type: ignore[reportInconsistentOverload] conversation_id: str, message_id: str | ActivityParams | AdaptiveCard = "", activity: str | ActivityParams | AdaptiveCard | None = None, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> SentActivity: """Send an activity proactively to a conversation, optionally as a threaded reply. @@ -360,9 +402,19 @@ async def reply( # type: ignore[reportInconsistentOverload] if activity is not None: if not isinstance(message_id, str): raise TypeError("message_id must be a string when activity is provided") - return await self.send(to_threaded_conversation_id(conversation_id, message_id), activity) + return await self.send( + to_threaded_conversation_id(conversation_id, message_id), + activity, + service_url=service_url, + agentic_identity=agentic_identity, + ) - return await self.send(conversation_id, message_id) + return await self.send( + conversation_id, + message_id, + service_url=service_url, + agentic_identity=agentic_identity, + ) def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None: """Add middleware to run on all activities.""" @@ -571,7 +623,6 @@ async def handler(request: HttpRequest) -> HttpResponse: ctx = FunctionContext( id=self.id, api=self.api, - activity_sender=self.activity_sender, data=request["body"], **client_context.__dict__, ) diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 1b2e9f27a..aad80d1e0 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -21,12 +21,12 @@ from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment from microsoft_teams.api.clients.user.params import GetUserTokenParams from microsoft_teams.cards import AdaptiveCard -from microsoft_teams.common import Client, ClientOptions, LocalStorage, Storage +from microsoft_teams.common import Client, LocalStorage, Storage if TYPE_CHECKING: from .app_events import EventManager -from .activity_sender import ActivitySender +from .auth_provider import AppAuthProvider from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError from .routing.activity_context import ActivityContext @@ -48,8 +48,8 @@ def __init__( default_connection_name: str, http_client: Client, token_manager: TokenManager, + auth_provider: AppAuthProvider, api_client_settings: Optional[ApiClientSettings], - activity_sender: ActivitySender, cloud: CloudEnvironment = PUBLIC, ) -> None: self.router = router @@ -58,8 +58,8 @@ def __init__( self.default_connection_name = default_connection_name self.http_client = http_client self.token_manager = token_manager + self.auth_provider = auth_provider self.api_client_settings = api_client_settings - self.activity_sender = activity_sender self.cloud = cloud # This will be set after the EventManager is initialized due to @@ -93,8 +93,10 @@ async def _build_context( ) api_client = ApiClient( service_url, - self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token)), + self.http_client, self.api_client_settings, + auth_provider=self.auth_provider, + agentic_identity=activity.recipient.agentic_identity, ) # Check if user is signed in @@ -127,7 +129,6 @@ async def _build_context( conversation_ref, is_signed_in, self.default_connection_name, - activity_sender=self.activity_sender, app_token=lambda: self.token_manager.get_graph_token(tenant_id), cloud=self.cloud, ) diff --git a/packages/apps/src/microsoft_teams/apps/auth/__init__.py b/packages/apps/src/microsoft_teams/apps/auth/__init__.py index f64ff6335..bf7b38ffe 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/auth/__init__.py @@ -4,6 +4,6 @@ """ from .remote_function_jwt_middleware import validate_remote_function_request -from .token_validator import TokenValidator +from .token_validator import InboundActivityTokenValidator, TokenValidator -__all__ = ["TokenValidator", "validate_remote_function_request"] +__all__ = ["InboundActivityTokenValidator", "TokenValidator", "validate_remote_function_request"] diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py index a4f1df1d4..437500600 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -14,6 +14,8 @@ from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment JWT_LEEWAY_SECONDS = 300 # Allowable clock skew when validating JWTs +_MAX_ENTRA_VALIDATOR_CACHE_SIZE = 100 +ENTRA_V1_ISSUER_PREFIX = "https://sts.windows.net/" logger = logging.getLogger(__name__) @@ -113,7 +115,7 @@ def for_entra( # are still issued with the v1 issuer. # See: https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens valid_issuers.append(f"{env.login_endpoint}/{tenant_id}/v2.0") - valid_issuers.append(f"https://sts.windows.net/{tenant_id}/") + valid_issuers.append(f"{ENTRA_V1_ISSUER_PREFIX}{tenant_id}/") else: logger.warning( "No tenant_id provided for Entra token validation. " @@ -222,3 +224,61 @@ def _validate_scope(self, payload: Dict[str, Any], required_scope: str) -> None: if required_scope not in scope_set: logger.error(f"Token missing required scope: {required_scope}") raise jwt.InvalidTokenError(f"Token missing required scope: {required_scope}") + + +class InboundActivityTokenValidator: + """Validator for inbound Teams activities. + + Classic bot activities use Bot Framework connector tokens. Agent ID activities use + Entra tokens whose audience is the agent identity blueprint app ID. + """ + + def __init__(self, app_id: str, cloud: Optional[CloudEnvironment] = None): + self._app_id = app_id + self._cloud = cloud or PUBLIC + self._service_validator = TokenValidator.for_service(app_id, cloud=self._cloud) + self._entra_validators_by_tenant: dict[str, TokenValidator] = {} + + async def validate_token(self, raw_token: str, service_url: Optional[str] = None) -> Dict[str, Any]: + if not raw_token: + logger.error("No token provided") + raise jwt.InvalidTokenError("No token provided") + + unverified_payload = jwt.decode(raw_token, algorithms=["RS256"], options={"verify_signature": False}) + issuer = unverified_payload.get("iss", "") + if self._is_entra_issuer(issuer): + return await self._validate_entra_token(raw_token, unverified_payload) + + return await self._service_validator.validate_token(raw_token, service_url) + + def _is_entra_issuer(self, issuer: Any) -> bool: + if not isinstance(issuer, str): + return False + + return issuer.startswith(self._cloud.login_endpoint) or issuer.startswith(ENTRA_V1_ISSUER_PREFIX) + + async def _validate_entra_token(self, raw_token: str, unverified_payload: Dict[str, Any]) -> Dict[str, Any]: + tenant_id = unverified_payload.get("tid") + if not tenant_id or not isinstance(tenant_id, str): + raise jwt.InvalidTokenError("Entra inbound token is missing tid") + + validator = self._get_entra_validator(tenant_id) + # TODO: Agent ID inbound Entra tokens currently do not include serviceurl. Revisit service URL + # validation for this path once the platform defines a signed service URL claim or equivalent. + return await validator.validate_token(raw_token) + + def _get_entra_validator(self, tenant_id: str) -> TokenValidator: + cached_validator = self._entra_validators_by_tenant.get(tenant_id) + if cached_validator: + return cached_validator + + validator = TokenValidator.for_entra( + self._app_id, + tenant_id, + cloud=self._cloud, + ) + self._entra_validators_by_tenant[tenant_id] = validator + if len(self._entra_validators_by_tenant) > _MAX_ENTRA_VALIDATOR_CACHE_SIZE: + oldest_tenant_id = next(iter(self._entra_validators_by_tenant)) + self._entra_validators_by_tenant.pop(oldest_tenant_id) + return validator diff --git a/packages/apps/src/microsoft_teams/apps/auth_provider.py b/packages/apps/src/microsoft_teams/apps/auth_provider.py new file mode 100644 index 000000000..69cc65a1b --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/auth_provider.py @@ -0,0 +1,35 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_teams.api import AgenticIdentity, TokenProtocol +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment + +from .token_manager import TokenManager + + +class AppAuthProvider: + """Provides app and agentic tokens for Teams API clients.""" + + def __init__(self, token_manager: TokenManager, cloud: CloudEnvironment): + self._token_manager = token_manager + self._cloud = cloud + + async def token( + self, *, scope: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> TokenProtocol | None: + if agentic_identity is None: + return await self._token_manager.get_app_token( + scope or self._cloud.bot_scope, + caller_name="token", + ) + + return await self._token_manager.get_agentic_token( + scope or self._cloud.agentic_bot_scope, + agentic_identity, + caller_name="token", + ) + + +__all__ = ["AppAuthProvider"] 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 4ce90e9e3..754ebcfa9 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -21,7 +21,7 @@ ) from microsoft_teams.cards import AdaptiveCard -from ..activity_sender import ActivitySender +from ..activity_send import send_or_update_activity from .client_context import ClientContext T = TypeVar("T") @@ -44,9 +44,6 @@ class FunctionContext(ClientContext, Generic[T]): api: ApiClient """The API client instance for conversation client.""" - activity_sender: ActivitySender - """The activity sender instance for sending messages.""" - data: T """The function payload.""" @@ -65,13 +62,6 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[ logger.warning("Cannot send activity: conversation ID could not be resolved") return None - conversation_ref = ConversationReference( - channel_id="msteams", - service_url=self.api.service_url, - bot=Account(id=self.id, name=self.name), - conversation=ConversationAccount(id=conversation_id, conversation_type="personal"), - ) - if isinstance(activity, str): activity = MessageActivityInput(text=activity) elif isinstance(activity, AdaptiveCard): @@ -79,7 +69,13 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[ else: activity = activity - return await self.activity_sender.send(activity, conversation_ref) + conversation_ref = ConversationReference( + channel_id="msteams", + service_url=self.api.service_url, + bot=Account(id=self.id, name=self.name), + conversation=ConversationAccount(id=conversation_id, conversation_type="personal"), + ) + return await send_or_update_activity(self.api, activity, conversation_ref) async def _resolve_conversation_id(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[str]: """Resolve or create a conversation ID for the current user/context. diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py index e11c411a9..eb7a5129a 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -13,7 +13,7 @@ from microsoft_teams.api.auth.json_web_token import JsonWebToken from pydantic import BaseModel -from ..auth import TokenValidator +from ..auth import InboundActivityTokenValidator from ..events import ActivityEvent, CoreActivity from .adapter import HttpRequest, HttpResponse, HttpServerAdapter @@ -43,7 +43,7 @@ def __init__(self, adapter: HttpServerAdapter, messaging_endpoint: str = "/api/m raise ValueError("messaging_endpoint must be a non-empty path starting with '/'.") self._messaging_endpoint = normalized_endpoint self._on_request: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]] = None - self._token_validator: Optional[TokenValidator] = None + self._token_validator: Optional[InboundActivityTokenValidator] = None self._skip_auth: bool = False self._cloud: CloudEnvironment = PUBLIC self._initialized: bool = False @@ -89,7 +89,7 @@ def initialize( app_id = getattr(credentials, "client_id", None) if credentials else None if app_id and not skip_auth: - self._token_validator = TokenValidator.for_service( + self._token_validator = InboundActivityTokenValidator( app_id, cloud=self._cloud, ) diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index c9d7a363f..b3bf46228 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -6,10 +6,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast +from typing import Any, List, Optional, TypedDict, Union, cast from microsoft_teams.api import ApiClientSettings from microsoft_teams.api.auth.cloud_environment import CloudEnvironment +from microsoft_teams.api.auth.credentials import TokenProvider from microsoft_teams.common import Client, ClientOptions, Storage from typing_extensions import Unpack @@ -30,7 +31,7 @@ class AppOptions(TypedDict, total=False): """Application ID URI from the Azure portal. Used for user authentication. Matches webApplicationInfo.resource in the app manifest.""" # Custom token provider function - token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] + token: Optional[TokenProvider] """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" # Managed identity configuration (used when client_id provided without client_secret or token) @@ -111,7 +112,7 @@ class InternalAppOptions: application_id_uri: Optional[str] = None """Application ID URI from the Azure portal. Used for user authentication. Matches webApplicationInfo.resource in the app manifest.""" - token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None + token: Optional[TokenProvider] = None """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" managed_identity_client_id: Optional[str] = None """ 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 58365143b..9c05bc0eb 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -41,7 +41,9 @@ from microsoft_teams.common.experimental import ExperimentalWarning from microsoft_teams.common.http.client_token import Token -from ..activity_sender import ActivitySender +from ..activity_send import send_or_update_activity +from ..http_stream import HttpStream +from ..plugins.streamer import StreamerProtocol from ..utils import create_graph_client if TYPE_CHECKING: @@ -86,7 +88,6 @@ def __init__( conversation_ref: ConversationReference, is_signed_in: bool, connection_name: str, - activity_sender: ActivitySender, app_token: Token, cloud: CloudEnvironment = PUBLIC, ): @@ -100,9 +101,8 @@ def __init__( self.connection_name = connection_name self.is_signed_in = is_signed_in self.cloud = cloud - self._activity_sender = activity_sender self._app_token = app_token - self.stream = activity_sender.create_stream(conversation_ref) + self._stream: Optional[StreamerProtocol] = None self._next_handler: Optional[Callable[[], Awaitable[None]]] = None @@ -110,6 +110,12 @@ def __init__( self._user_graph: Optional["GraphServiceClient"] = None self._app_graph: Optional["GraphServiceClient"] = None + @property + def stream(self) -> StreamerProtocol: + if self._stream is None: + self._stream = HttpStream(self.api, self.conversation_ref) + return self._stream + @property def user_graph(self) -> "GraphServiceClient": """ @@ -191,8 +197,12 @@ async def send( self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref - res = await self._activity_sender.send(activity, ref) - return res + return await send_or_update_activity( + self.api, + activity, + ref, + agentic_identity=self.activity.recipient.agentic_identity, + ) async def reply(self, input: str | ActivityParams) -> SentActivity: """Send a message in the current conversation with a visual quote of the inbound message. diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index e4c3525f1..9da102ece 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -5,11 +5,12 @@ import asyncio import logging -from inspect import isawaitable -from typing import Any, Optional +from inspect import Parameter, isawaitable, signature +from typing import Any, Awaitable, Callable, Optional, cast import requests from microsoft_teams.api import ( + AgenticIdentity, ClientCredentials, Credentials, JsonWebToken, @@ -29,6 +30,8 @@ ) DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common" +TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default" +AGENT_BOT_API_SCOPE = "https://botapi.skype.com/.default" logger = logging.getLogger(__name__) @@ -45,12 +48,29 @@ def __init__( self._cloud = cloud or PUBLIC self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} self._federated_identity_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} + self._agent_identity_clients_by_tenant_and_app_id: dict[tuple[str, str], ConfidentialClientApplication] = {} self._managed_identity_client: Optional[ManagedIdentityClient] = None async def get_bot_token(self) -> Optional[TokenProtocol]: """Refresh the bot authentication token.""" + return await self.get_app_token(self._cloud.bot_scope, default_tenant_id=self._cloud.login_tenant) + + async def get_app_token( + self, + scope: str, + tenant_id: Optional[str] = None, + *, + default_tenant_id: str | None = None, + caller_name: str | None = None, + ) -> Optional[TokenProtocol]: + """Get an app token for the requested scope.""" + resolved_tenant_id = self._resolve_tenant_id(tenant_id, default_tenant_id or self._cloud.login_tenant) + if resolved_tenant_id is None: + raise ValueError("tenant_id is required to get an app token") return await self._get_token( - self._cloud.bot_scope, tenant_id=self._resolve_tenant_id(None, self._cloud.login_tenant) + scope, + tenant_id=resolved_tenant_id, + caller_name=caller_name, ) async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: @@ -64,10 +84,78 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok Returns: The graph token or None if not available """ - return await self._get_token( - self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) + return await self.get_app_token( + self._cloud.graph_scope, + tenant_id=tenant_id, + default_tenant_id=DEFAULT_TENANT_FOR_GRAPH_TOKEN, ) + async def get_agentic_token( + self, + scope: str, + agentic_identity: AgenticIdentity, + *, + caller_name: str | None = None, + ) -> Optional[TokenProtocol]: + """Get a resource token for an agentic identity acting through its agentic user.""" + if not agentic_identity.agentic_user_id: + raise ValueError("agentic_identity.agentic_user_id is required to get an agentic token") + if self._credentials is None: + if caller_name: + logger.debug(f"No credentials provided for {caller_name}") + return None + + tenant_id = self._resolve_tenant_id(agentic_identity.tenant_id, None) + if tenant_id is None: + raise ValueError("tenant_id is required to get an agentic token") + + credentials = self._credentials + if isinstance(credentials, TokenCredentials): + return await self._get_token_with_token_provider(credentials, scope, tenant_id, agentic_identity) + + if not isinstance(credentials, ClientCredentials): + raise ValueError("Agent user tokens require ClientCredentials") + confidential_client = self._get_confidential_client(credentials, tenant_id) + + def get_t1_assertion(_context: dict[str, Any]) -> str: + t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client( + [TOKEN_EXCHANGE_SCOPE], fmi_path=agentic_identity.agentic_app_id + ) + return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed") + + # The agent identity app needs its own MSAL client. It uses the Federated Managed + # Identity assertion from step 1 as its client assertion for the next exchanges. + t2_confidential_client = self._get_agent_identity_client( + tenant_id, + agentic_identity.agentic_app_id, + get_t1_assertion, + ) + + t2_raw: dict[str, Any] = await asyncio.to_thread( + lambda: t2_confidential_client.acquire_token_for_client([TOKEN_EXCHANGE_SCOPE]) + ) + + t2 = self._get_access_token_or_raise(t2_raw, "Agent token exchange step 2 failed") + + t3_raw: dict[str, Any] = await asyncio.to_thread( + lambda: t2_confidential_client.acquire_token_by_user_federated_identity_credential( + [scope], + assertion=t2, + user_object_id=agentic_identity.agentic_user_id, + username=None, + data={"requested_token_use": "on_behalf_of"}, + ) + ) + return self._handle_token_response(t3_raw, caller_name or "get_agentic_token") + + def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: str) -> str: + if token_res.get("access_token", None): + return token_res["access_token"] + + error_description = token_res.get("error_description") or token_res.get("error") or "Could not acquire token" + logger.error(f"{error_prefix}: {error_description}") + raise ValueError(f"{error_prefix}: {error_description}") + async def _get_token( self, scope: str, tenant_id: str, *, caller_name: str | None = None ) -> Optional[TokenProtocol]: @@ -160,9 +248,10 @@ async def _get_token_with_token_provider( credentials: TokenCredentials, scope: str, tenant_id: str, + agentic_identity: AgenticIdentity | None = None, ) -> TokenProtocol: """Get token using custom token provider function.""" - token = credentials.token(scope, tenant_id) + token = self._call_token_provider(credentials, scope, tenant_id, agentic_identity) if isawaitable(token): access_token = await token @@ -171,6 +260,45 @@ async def _get_token_with_token_provider( return JsonWebToken(access_token) + def _call_token_provider( + self, + credentials: TokenCredentials, + scope: str, + tenant_id: str, + agentic_identity: AgenticIdentity | None = None, + ) -> str | Awaitable[str]: + token_provider = cast(Any, credentials.token) + try: + parameters = list(signature(token_provider).parameters.values()) + except (TypeError, ValueError) as error: + if agentic_identity is not None: + raise ValueError("Token provider must accept agentic_identity to mint agentic tokens") from error + return cast(str | Awaitable[str], token_provider(scope, tenant_id)) + + accepts_agentic_identity = any( + parameter.kind == Parameter.VAR_KEYWORD or parameter.name == "agentic_identity" for parameter in parameters + ) + if accepts_agentic_identity: + return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity=agentic_identity)) + + positional_parameters = [ + parameter + for parameter in parameters + if parameter.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + ] + required_positional_parameters = [ + parameter for parameter in positional_parameters if parameter.default is Parameter.empty + ] + if len(positional_parameters) >= 3 and ( + agentic_identity is not None or len(required_positional_parameters) >= 3 + ): + return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity)) + + if agentic_identity is not None: + raise ValueError("Token provider must accept agentic_identity to mint agentic tokens") + + return cast(str | Awaitable[str], token_provider(scope, tenant_id)) + def _handle_token_response(self, token_res: dict[str, Any], error_prefix: str = "") -> TokenProtocol: """Handle token response from MSAL client.""" if token_res.get("access_token", None): @@ -220,6 +348,24 @@ def _get_federated_identity_client( self._federated_identity_clients_by_tenant[tenant_id] = client return client + def _get_agent_identity_client( + self, + tenant_id: str, + agentic_app_id: str, + client_assertion: Callable[[dict[str, Any]], str], + ) -> ConfidentialClientApplication: + cached_client = self._agent_identity_clients_by_tenant_and_app_id.get((tenant_id, agentic_app_id)) + if cached_client: + return cached_client + + client: ConfidentialClientApplication = ConfidentialClientApplication( + agentic_app_id, + client_credential={"client_assertion": client_assertion}, + authority=f"{self._cloud.login_endpoint}/{tenant_id}", + ) + self._agent_identity_clients_by_tenant_and_app_id[(tenant_id, agentic_app_id)] = client + return client + def _get_managed_identity_client( self, credentials: ManagedIdentityCredentials | FederatedIdentityCredentials ) -> ManagedIdentityClient: @@ -247,5 +393,5 @@ def _get_managed_identity_client( ) return self._managed_identity_client - def _resolve_tenant_id(self, tenant_id: str | None, default_tenant_id: str): - return tenant_id or (self._credentials.tenant_id if self._credentials else False) or default_tenant_id + def _resolve_tenant_id(self, tenant_id: str | None, default_tenant_id: str | None) -> str | None: + return tenant_id or (self._credentials.tenant_id if self._credentials else None) or default_tenant_id diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 7f1eeb5da..db18e431f 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -39,6 +39,28 @@ def _create_activity_context( ) ) mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) + activities = MagicMock() + activities.create = mock_activity_sender.send + activities.update = AsyncMock( + return_value=SentActivity( + id="updated-activity-id", + activity_params=MessageActivityInput(text="updated"), + ) + ) + activities.create_targeted = AsyncMock( + return_value=SentActivity( + id="targeted-activity-id", + activity_params=MessageActivityInput(text="targeted"), + ) + ) + activities.update_targeted = AsyncMock( + return_value=SentActivity( + id="updated-targeted-activity-id", + activity_params=MessageActivityInput(text="updated targeted"), + ) + ) + api = MagicMock() + api.conversations.activities.return_value = activities conversation_ref = ConversationReference( bot=Account(id="bot-id", name="Test Bot"), @@ -51,12 +73,11 @@ def _create_activity_context( activity=mock_activity, app_id="test-app-id", storage=MagicMock(), - api=MagicMock(), + api=api, user_token=user_token, conversation_ref=conversation_ref, is_signed_in=is_signed_in, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=MagicMock(), cloud=PUBLIC, ) @@ -88,16 +109,21 @@ async def test_defaults_send_to_targeted_when_inbound_message_is_targeted(self) await ctx.send("Secret message") - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Secret message" + assert sent_activity.from_ == ctx.conversation_ref.bot + assert sent_activity.conversation == ctx.conversation_ref.conversation assert sent_activity.recipient is not None assert sent_activity.recipient.id == incoming_sender.id assert sent_activity.recipient.name == incoming_sender.name assert sent_activity.recipient.is_targeted is True assert sent_activity.entities is not None assert any(isinstance(entity, TargetedMessageInfoEntity) for entity in sent_activity.entities) + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + ctx.api.conversations.activities.return_value.create.assert_not_called() @pytest.mark.asyncio async def test_reply_defaults_to_targeted_when_inbound_message_is_targeted(self) -> None: @@ -107,8 +133,9 @@ async def test_reply_defaults_to_targeted_when_inbound_message_is_targeted(self) await ctx.reply("Private reply") - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.reply_to_id is None assert sent_activity.recipient is not None @@ -151,8 +178,8 @@ async def test_different_conversation_does_not_default_to_targeted(self) -> None mock_sender.send.assert_called_once() sent_activity = mock_sender.send.call_args[0][0] - sent_ref = mock_sender.send.call_args[0][1] - assert sent_ref == other_ref + ctx.api.conversations.activities.assert_called_once_with("other-conversation") + assert mock_sender.send.call_args.kwargs["service_url"] == other_ref.service_url assert sent_activity.recipient is None assert sent_activity.entities is None @@ -165,8 +192,9 @@ async def test_explicit_targeted_send_from_public_inbound_does_not_add_targeted_ await ctx.send(activity) - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.recipient is not None assert sent_activity.recipient.is_targeted is True assert sent_activity.entities is None @@ -183,8 +211,9 @@ async def test_targeted_outbound_strips_quoted_reply_metadata(self) -> None: await ctx.send(activity) - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.text == "Secret" assert sent_activity.entities is not None assert all(not isinstance(entity, QuotedReplyEntity) for entity in sent_activity.entities) @@ -207,10 +236,10 @@ async def test_targeted_message_with_explicit_recipient(self) -> None: await ctx.send(activity) # Verify send was called - mock_sender.send.assert_called_once() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + mock_sender.send.assert_not_called() - # Get the activity that was passed to send - sent_activity = mock_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] # Verify recipient was preserved assert sent_activity.recipient is not None @@ -232,11 +261,8 @@ async def test_targeted_update_preserves_recipient(self) -> None: await ctx.send(activity) - # Verify send was called - mock_sender.send.assert_called_once() - - # Get the activity that was passed to send - sent_activity = mock_sender.send.call_args[0][0] + ctx.api.conversations.activities.return_value.update_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.update_targeted.call_args.args[1] # Verify recipient was preserved assert sent_activity.recipient is not None @@ -244,6 +270,33 @@ async def test_targeted_update_preserves_recipient(self) -> None: assert sent_activity.recipient.name == incoming_sender.name assert sent_activity.recipient.is_targeted is True + @pytest.mark.asyncio + async def test_send_existing_activity_updates(self) -> None: + incoming_sender = Account(id="user-123", name="Test User") + ctx, mock_sender = self._create_activity_context(from_account=incoming_sender) + activity = MessageActivityInput(text="Updated message") + activity.id = "existing-msg-id" + + await ctx.send(activity) + + ctx.api.conversations.activities.return_value.update.assert_called_once_with( + "existing-msg-id", + activity, + service_url=ctx.conversation_ref.service_url, + agentic_identity=None, + ) + mock_sender.send.assert_not_called() + + @pytest.mark.asyncio + async def test_targeted_send_in_personal_chat_raises(self) -> None: + incoming_sender = Account(id="user-123", name="Test User") + ctx, _ = self._create_activity_context(from_account=incoming_sender) + ctx.conversation_ref.conversation.conversation_type = "personal" + activity = MessageActivityInput(text="Nope").with_recipient(incoming_sender, is_targeted=True) + + with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"): + await ctx.send(activity) + @pytest.mark.asyncio async def test_targeted_with_different_recipient(self) -> None: """ @@ -261,10 +314,9 @@ async def test_targeted_with_different_recipient(self) -> None: await ctx.send(activity) # Verify send was called - mock_sender.send.assert_called_once() - - # Get the activity that was passed to send - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] # Verify recipient was preserved assert sent_activity.recipient is not None @@ -328,6 +380,30 @@ async def test_send_with_adaptive_card(self) -> None: assert len(sent_activity.attachments) > 0 assert isinstance(result, SentActivity) + @pytest.mark.asyncio + async def test_send_passes_inbound_agentic_identity(self) -> None: + """Sending from an Agent ID activity uses the inbound agentic identity.""" + recipient = Account( + id="bot-id", + name="Test Bot", + agentic_app_id="agentic-app-id", + agentic_user_id="agentic-user-id", + tenant_id="tenant-id", + ) + activity = MessageActivity( + id="incoming-activity-id", + text="Incoming message", + from_=Account(id="user-id", name="Test User"), + recipient=recipient, + conversation=ConversationAccount(id="test-conversation"), + ) + ctx, mock_sender = _create_activity_context(activity=activity) + + await ctx.send("Hello") + + mock_sender.send.assert_called_once() + assert mock_sender.send.call_args.kwargs["agentic_identity"] == recipient.agentic_identity + class TestActivityContextReply: """Tests for ActivityContext.reply().""" @@ -372,6 +448,30 @@ async def test_reply_with_activity_params(self) -> None: assert isinstance(sent_activity.entities[0], QuotedReplyEntity) assert sent_activity.entities[0].quoted_reply.message_id == "evt-id-999" + @pytest.mark.asyncio + async def test_reply_passes_inbound_agentic_identity(self) -> None: + """Replying to an Agent ID activity uses the inbound agentic identity.""" + recipient = Account( + id="bot-id", + name="Test Bot", + agentic_app_id="agentic-app-id", + agentic_user_id="agentic-user-id", + tenant_id="tenant-id", + ) + activity = MessageActivity( + id="incoming-activity-id", + text="Incoming message", + from_=Account(id="user-id", name="Test User"), + recipient=recipient, + conversation=ConversationAccount(id="test-conversation"), + ) + ctx, mock_sender = _create_activity_context(activity=activity) + + await ctx.reply("Hello") + + mock_sender.send.assert_called_once() + assert mock_sender.send.call_args.kwargs["agentic_identity"] == recipient.agentic_identity + class TestActivityContextUserGraph: """Tests for ActivityContext.user_graph property.""" @@ -504,17 +604,20 @@ async def test_sign_in_returns_token_when_already_available(self) -> None: @pytest.mark.asyncio async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None: """sign_in falls through to OAuth card flow when token API fails, returns None.""" - mock_activity = MagicMock() - mock_activity.channel_id = "msteams" - mock_activity.from_ = Account(id="user-001") - mock_activity.conversation.is_group = False + mock_activity = MessageActivity( + id="activity-id", + channel_id="msteams", + from_=Account(id="user-001"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="test-conversation", is_group=False), + ) ctx, mock_sender = _create_activity_context(activity=mock_activity) ctx.api.users.token.get = AsyncMock(side_effect=Exception("no token")) resource_response = MagicMock() - resource_response.token_exchange_resource = MagicMock() - resource_response.token_post_resource = MagicMock() + resource_response.token_exchange_resource = None + resource_response.token_post_resource = None resource_response.sign_in_link = "https://login.example.com" ctx.api.bots.sign_in.get_resource = AsyncMock(return_value=resource_response) @@ -525,10 +628,6 @@ async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None: "microsoft_teams.apps.routing.activity_context.TokenExchangeState", return_value=token_state, ), - patch("microsoft_teams.apps.routing.activity_context.card_attachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCard"), - patch("microsoft_teams.apps.routing.activity_context.CardAction"), patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"), ): result = await ctx.sign_in() @@ -540,11 +639,13 @@ async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None: @pytest.mark.asyncio async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> None: """For group conversations, sign_in creates a 1:1 conversation before sending the OAuth card.""" - mock_activity = MagicMock() - mock_activity.channel_id = "msteams" - mock_activity.from_ = Account(id="user-001") - mock_activity.conversation.is_group = True - mock_activity.conversation.tenant_id = "tenant-001" + mock_activity = MessageActivity( + id="activity-id", + channel_id="msteams", + from_=Account(id="user-001"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="test-conversation", is_group=True, tenant_id="tenant-001"), + ) ctx, mock_sender = _create_activity_context(activity=mock_activity) ctx.api.users.token.get = AsyncMock(side_effect=Exception("no token")) @@ -554,8 +655,8 @@ async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> N ctx.api.conversations.create = AsyncMock(return_value=one_on_one) resource_response = MagicMock() - resource_response.token_exchange_resource = MagicMock() - resource_response.token_post_resource = MagicMock() + resource_response.token_exchange_resource = None + resource_response.token_post_resource = None resource_response.sign_in_link = "https://login.example.com" ctx.api.bots.sign_in.get_resource = AsyncMock(return_value=resource_response) @@ -567,10 +668,6 @@ async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> N return_value=token_state, ), patch("microsoft_teams.apps.routing.activity_context.CreateConversationParams"), - patch("microsoft_teams.apps.routing.activity_context.card_attachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCard"), - patch("microsoft_teams.apps.routing.activity_context.CardAction"), patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"), ): result = await ctx.sign_in() @@ -585,17 +682,20 @@ async def test_sign_in_uses_signin_options_connection_name_override(self) -> Non """sign_in respects SignInOptions.connection_name override when fetching the existing token.""" from microsoft_teams.apps.routing.activity_context import SignInOptions - mock_activity = MagicMock() - mock_activity.channel_id = "msteams" - mock_activity.from_ = Account(id="user-001") - mock_activity.conversation.is_group = False + mock_activity = MessageActivity( + id="activity-id", + channel_id="msteams", + from_=Account(id="user-001"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="test-conversation", is_group=False), + ) ctx, _ = _create_activity_context(activity=mock_activity) ctx.api.users.token.get = AsyncMock(side_effect=Exception("no token")) resource_response = MagicMock() - resource_response.token_exchange_resource = MagicMock() - resource_response.token_post_resource = MagicMock() + resource_response.token_exchange_resource = None + resource_response.token_post_resource = None resource_response.sign_in_link = "https://login.example.com" ctx.api.bots.sign_in.get_resource = AsyncMock(return_value=resource_response) @@ -612,10 +712,6 @@ async def test_sign_in_uses_signin_options_connection_name_override(self) -> Non "microsoft_teams.apps.routing.activity_context.TokenExchangeState", return_value=token_state, ), - patch("microsoft_teams.apps.routing.activity_context.card_attachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCard"), - patch("microsoft_teams.apps.routing.activity_context.CardAction"), patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"), ): result = await ctx.sign_in(options=custom_options) @@ -692,7 +788,8 @@ async def test_send_auto_adds_targeted_message_info_entity(self) -> None: await ctx.send("Here is your agenda") - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -720,7 +817,8 @@ async def test_send_does_not_duplicate_entity_if_already_present(self) -> None: msg = MessageActivityInput(text="Reply").add_entity(TargetedMessageInfoEntity(message_id="custom-id")) await ctx.send(msg) - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 assert sent_activity.entities[0].message_id == "custom-id" @@ -735,7 +833,8 @@ async def test_reply_auto_adds_targeted_message_info_entity(self) -> None: await ctx.reply("Reply with prompt preview") - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.entities is not None targeted_entities = [e for e in sent_activity.entities if isinstance(e, TargetedMessageInfoEntity)] assert len(targeted_entities) == 1 diff --git a/packages/apps/tests/test_activity_sender.py b/packages/apps/tests/test_activity_sender.py deleted file mode 100644 index 599659341..000000000 --- a/packages/apps/tests/test_activity_sender.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" -# pyright: basic - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from microsoft_teams.api import ( - Account, - ConversationAccount, - ConversationReference, - MessageActivityInput, - SentActivity, -) -from microsoft_teams.apps.activity_sender import ActivitySender - - -class TestActivitySender: - """Test cases for ActivitySender.""" - - @pytest.fixture - def sender(self): - """Create an ActivitySender for testing.""" - mock_client = MagicMock() - return ActivitySender(client=mock_client) - - @pytest.fixture - def conversation_ref(self): - """Create a conversation reference for testing.""" - return ConversationReference( - bot=Account(id="bot-123", name="Test Bot", role="bot"), - conversation=ConversationAccount(id="conv-456", conversation_type="personal"), - channel_id="msteams", - service_url="https://test.service.url", - ) - - @pytest.fixture - def group_conversation_ref(self): - """Create a group chat conversation reference for testing.""" - return ConversationReference( - bot=Account(id="bot-123", name="Test Bot", role="bot"), - conversation=ConversationAccount(id="conv-789", conversation_type="groupChat"), - channel_id="msteams", - service_url="https://test.service.url", - ) - - def _create_sent_activity(self, activity, activity_id="msg-123"): - """Helper to create a proper SentActivity mock.""" - return SentActivity(id=activity_id, activity_params=activity) - - @pytest.mark.asyncio - async def test_send_new_message_calls_create(self, sender, conversation_ref): - """Test that new messages (no id) use the create method.""" - activity = MessageActivityInput(text="Hello") - - mock_activities = MagicMock() - mock_activities.create = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.create.assert_called_once_with(activity) - - @pytest.mark.asyncio - async def test_send_existing_message_calls_update(self, sender, conversation_ref): - """Test that messages with an id use the update method.""" - activity = MessageActivityInput(text="Updated message") - activity.id = "existing-msg-id" - - mock_activities = MagicMock() - mock_activities.update = AsyncMock(return_value=self._create_sent_activity(activity, "existing-msg-id")) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.update.assert_called_once_with("existing-msg-id", activity) - - @pytest.mark.asyncio - async def test_send_sets_from_and_conversation(self, sender, conversation_ref): - """Test that send merges activity with conversation reference.""" - activity = MessageActivityInput(text="Hello") - - mock_activities = MagicMock() - mock_activities.create = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - assert activity.from_ == conversation_ref.bot - assert activity.conversation == conversation_ref.conversation - - @pytest.mark.asyncio - async def test_send_targeted_message_calls_create_targeted(self, sender, group_conversation_ref): - """Test that targeted messages use the create_targeted method.""" - recipient = Account(id="user-123", name="Test User", role="user") - activity = MessageActivityInput(text="Hello").with_recipient(recipient, is_targeted=True) - - mock_activities = MagicMock() - mock_activities.create_targeted = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, group_conversation_ref) - - mock_activities.create_targeted.assert_called_once_with(activity) - mock_activities.create.assert_not_called() - - @pytest.mark.asyncio - async def test_send_non_targeted_message_does_not_call_create_targeted(self, sender, conversation_ref): - """Test that non-targeted messages use the regular create method.""" - activity = MessageActivityInput(text="Hello") - - mock_activities = MagicMock() - mock_activities.create = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.create.assert_called_once_with(activity) - mock_activities.create_targeted.assert_not_called() - - @pytest.mark.asyncio - async def test_update_targeted_message_calls_update_targeted(self, sender, group_conversation_ref): - """Test that targeted message updates use the update_targeted method.""" - activity = MessageActivityInput(text="Updated targeted message") - activity.recipient = Account(id="user-123", name="Test User", role="user", is_targeted=True) - activity.id = "existing-msg-id" - - mock_activities = MagicMock() - mock_activities.update_targeted = AsyncMock( - return_value=self._create_sent_activity(activity, "existing-msg-id") - ) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, group_conversation_ref) - - mock_activities.update_targeted.assert_called_once_with("existing-msg-id", activity) - mock_activities.update.assert_not_called() - - @pytest.mark.asyncio - async def test_update_non_targeted_message_calls_update(self, sender, conversation_ref): - """Test that non-targeted message updates use the regular update method.""" - activity = MessageActivityInput(text="Updated message") - activity.id = "existing-msg-id" - - mock_activities = MagicMock() - mock_activities.update = AsyncMock(return_value=self._create_sent_activity(activity, "existing-msg-id")) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.update.assert_called_once_with("existing-msg-id", activity) - mock_activities.update_targeted.assert_not_called() - - @pytest.mark.asyncio - async def test_send_targeted_in_personal_chat_raises(self, sender, conversation_ref): - """Test that sending a targeted message in a personal (1:1) chat raises ValueError.""" - activity = MessageActivityInput(text="Hello").with_recipient( - Account(id="user-1", name="User"), is_targeted=True - ) - - with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"): - await sender.send(activity, conversation_ref) - - @pytest.mark.asyncio - async def test_send_targeted_in_group_chat_succeeds(self, sender, group_conversation_ref): - """Test that sending a targeted message in a group chat proceeds normally.""" - activity = MessageActivityInput(text="Hello").with_recipient( - Account(id="user-1", name="User"), is_targeted=True - ) - - mock_activities = MagicMock() - mock_activities.create_targeted = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, group_conversation_ref) - - mock_activities.create_targeted.assert_called_once() diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index a090860f5..8a27eb15e 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -15,6 +15,7 @@ import pytest from microsoft_teams.api import ( Account, + AgenticIdentity, ConversationAccount, FederatedIdentityCredentials, InvokeActivity, @@ -778,9 +779,15 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st ) app = App(**options) app._initialized = True - app.activity_sender.send = AsyncMock( + create = AsyncMock( return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) ) + activities = MagicMock() + activities.create = create + activities.create_targeted = AsyncMock( + return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) + ) + app.api.conversations.activities = MagicMock(return_value=activities) # Create a targeted message with explicit recipient recipient = Account(id="user-456", name="Test User", role="user") @@ -789,7 +796,42 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st # Should not raise - explicit recipient provided result = await app.send("conv-123", activity) - app.activity_sender.send.assert_called_once() + activities.create_targeted.assert_called_once() + create.assert_not_called() + assert result.id == "sent-activity-id" + sent_activity = activities.create_targeted.call_args.args[0] + assert sent_activity.from_.id == "test-client-id" + assert sent_activity.conversation.id == "conv-123" + + @pytest.mark.asyncio + async def test_send_passes_agentic_identity_and_service_url(self, mock_storage) -> None: + options = AppOptions(storage=mock_storage, client_id="test-client-id", client_secret="test-secret") + app = App(**options) + app._initialized = True + create = AsyncMock( + return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) + ) + activities = MagicMock() + activities.create = create + app.api.conversations.activities = MagicMock(return_value=activities) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + result = await app.send( + "conv-123", + "Hello", + service_url="https://override.service.url", + agentic_identity=agentic_identity, + ) + + app.api.conversations.activities.assert_called_once_with("conv-123") + create.assert_called_once() + activity = create.call_args.args[0] + assert isinstance(activity, MessageActivityInput) + assert activity.text == "Hello" + assert create.call_args.kwargs == { + "service_url": "https://override.service.url", + "agentic_identity": agentic_identity, + } assert result.id == "sent-activity-id" @@ -800,9 +842,13 @@ class TestAppInitialize: async def test_initialize_enables_send(self): """After initialize(), app.send() should work without starting the server.""" app = App(client_id="test-id", client_secret="test-secret", skip_auth=True) - app.activity_sender.send = AsyncMock( - return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi")) + create = AsyncMock(return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi"))) + activities = MagicMock() + activities.create = create + activities.update = AsyncMock( + return_value=SentActivity(id="existing-msg-id", activity_params=MessageActivityInput(text="updated")) ) + app.api.conversations.activities = MagicMock(return_value=activities) with pytest.raises(ValueError, match="app not initialized"): await app.send("conv-1", "hello") @@ -811,6 +857,17 @@ async def test_initialize_enables_send(self): result = await app.send("conv-1", "hello") assert result.id == "msg-1" + activity = MessageActivityInput(text="updated") + activity.id = "existing-msg-id" + result = await app.send("conv-1", activity) + assert result.id == "existing-msg-id" + activities.update.assert_called_once_with( + "existing-msg-id", + activity, + service_url=app.api.service_url, + agentic_identity=None, + ) + @pytest.mark.asyncio async def test_initialize_emits_error_on_plugin_failure(self): """If a plugin's on_init raises, the error event fires and the exception propagates.""" @@ -839,34 +896,77 @@ def started_app(self): options = AppOptions(client_id="test-client-id", client_secret="test-secret") app = App(**options) app._initialized = True - app.activity_sender.send = AsyncMock( + create = AsyncMock( return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) ) + activities = MagicMock() + activities.create = create + activities.update = AsyncMock( + return_value=SentActivity(id="updated-activity-id", activity_params=MessageActivityInput(text="updated")) + ) + app.api.conversations.activities = MagicMock(return_value=activities) return app @pytest.mark.asyncio async def test_reply_with_three_args_constructs_threaded_id(self, started_app): await started_app.reply("19:abc@thread.skype", "1680000000000", "Hello thread") - started_app.activity_sender.send.assert_called_once() - _, ref = started_app.activity_sender.send.call_args[0] - assert ref.conversation.id == "19:abc@thread.skype;messageid=1680000000000" + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000") + + @pytest.mark.asyncio + async def test_reply_with_three_args_passes_agentic_identity_and_service_url(self, started_app): + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await started_app.reply( + "19:abc@thread.skype", + "1680000000000", + "Hello thread", + service_url="https://override.service.url", + agentic_identity=agentic_identity, + ) + + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000") + create = started_app.api.conversations.activities.return_value.create + activity = create.call_args.args[0] + assert isinstance(activity, MessageActivityInput) + assert activity.text == "Hello thread" + assert create.call_args.kwargs == { + "service_url": "https://override.service.url", + "agentic_identity": agentic_identity, + } @pytest.mark.asyncio async def test_reply_with_two_args_passes_conversation_id_as_is(self, started_app): await started_app.reply("19:abc@thread.skype", "Hello flat") - started_app.activity_sender.send.assert_called_once() - _, ref = started_app.activity_sender.send.call_args[0] - assert ref.conversation.id == "19:abc@thread.skype" + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype") + + @pytest.mark.asyncio + async def test_reply_with_two_args_passes_agentic_identity_and_service_url(self, started_app): + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await started_app.reply( + "19:abc@thread.skype", + "Hello flat", + service_url="https://override.service.url", + agentic_identity=agentic_identity, + ) + + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype") + create = started_app.api.conversations.activities.return_value.create + activity = create.call_args.args[0] + assert isinstance(activity, MessageActivityInput) + assert activity.text == "Hello flat" + assert create.call_args.kwargs == { + "service_url": "https://override.service.url", + "agentic_identity": agentic_identity, + } @pytest.mark.asyncio async def test_reply_with_pre_constructed_threaded_id(self, started_app): await started_app.reply("19:abc@thread.skype;messageid=123", "Hello") - started_app.activity_sender.send.assert_called_once() - _, ref = started_app.activity_sender.send.call_args[0] - assert ref.conversation.id == "19:abc@thread.skype;messageid=123" + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=123") @pytest.mark.asyncio async def test_reply_with_invalid_message_id_raises(self, started_app): diff --git a/packages/apps/tests/test_app_oauth.py b/packages/apps/tests/test_app_oauth.py index 9d6fd4c5f..f6034686a 100644 --- a/packages/apps/tests/test_app_oauth.py +++ b/packages/apps/tests/test_app_oauth.py @@ -27,6 +27,7 @@ ) from microsoft_teams.apps.app_oauth import OauthHandlers from microsoft_teams.apps.app_process import ActivityProcessor +from microsoft_teams.apps.auth_provider import AppAuthProvider from microsoft_teams.apps.events import ErrorEvent, SignInEvent from microsoft_teams.apps.routing import ActivityContext from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES @@ -445,8 +446,8 @@ def processor(self, router): default_connection_name="graph", http_client=MagicMock(), token_manager=MagicMock(), + auth_provider=MagicMock(spec=AppAuthProvider), api_client_settings=None, - activity_sender=MagicMock(), cloud=PUBLIC, ) @@ -462,7 +463,6 @@ def _make_ctx(activity): conversation_ref=MagicMock(), is_signed_in=False, connection_name="graph", - activity_sender=MagicMock(), app_token=MagicMock(), cloud=PUBLIC, ) diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py index b11d3beb4..fb01ccbc6 100644 --- a/packages/apps/tests/test_app_process.py +++ b/packages/apps/tests/test_app_process.py @@ -5,7 +5,7 @@ # pyright: basic from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from microsoft_teams.api import ( @@ -17,9 +17,9 @@ ) from microsoft_teams.api.auth.cloud_environment import PUBLIC from microsoft_teams.apps import ActivityContext, ActivityEvent -from microsoft_teams.apps.activity_sender import ActivitySender from microsoft_teams.apps.app_events import EventManager from microsoft_teams.apps.app_process import ActivityProcessor +from microsoft_teams.apps.auth_provider import AppAuthProvider from microsoft_teams.apps.events import CoreActivity from microsoft_teams.apps.routing.router import ActivityHandler, ActivityRouter from microsoft_teams.apps.token_manager import TokenManager @@ -43,11 +43,7 @@ def activity_processor(self, mock_http_client): mock_storage = MagicMock(spec=LocalStorage) mock_activity_router = MagicMock(spec=ActivityRouter) mock_token_manager = MagicMock(spec=TokenManager) - mock_activity_sender = MagicMock(spec=ActivitySender) - # Mock the stream object with async close - mock_stream = MagicMock() - mock_stream.close = AsyncMock() - mock_activity_sender.create_stream.return_value = mock_stream + mock_auth_provider = MagicMock(spec=AppAuthProvider) return ActivityProcessor( mock_activity_router, "id", @@ -55,8 +51,8 @@ def activity_processor(self, mock_http_client): "default_connection", mock_http_client, mock_token_manager, + mock_auth_provider, None, - mock_activity_sender, PUBLIC, ) @@ -72,18 +68,16 @@ async def test_execute_middleware_chain_with_no_handlers(self, activity_processo @pytest.mark.asyncio async def test_execute_middleware_chain_with_two_handlers(self, activity_processor, mock_http_client): """Test the execute_middleware_chain method with two handlers.""" - mock_activity_sender = MagicMock(spec=ActivitySender) - mock_activity_sender.create_stream.return_value = MagicMock() + api = MagicMock() context = ActivityContext( activity=MagicMock(spec=ActivityBase), app_id="app_id", storage=MagicMock(spec=LocalStorage), - api=mock_http_client, + api=api, user_token=None, conversation_ref=MagicMock(spec=ConversationReference), is_signed_in=True, connection_name="default_connection", - activity_sender=mock_activity_sender, app_token=lambda: None, cloud=PUBLIC, ) @@ -209,22 +203,29 @@ async def test_updated_send_emits_activity_sent_event(self, activity_processor): mock_token.service_url = "https://service.url" mock_activity_event = ActivityEvent(body=core_activity, token=mock_token) - # Activity sender returns a SentActivity from send() + # ApiClient returns a SentActivity from send() sent = SentActivity(id="sent-1", activity_params=MessageActivityInput(text="hi")) - activity_processor.activity_sender.send = AsyncMock(return_value=sent) + activities = MagicMock() + activities.create = AsyncMock(return_value=sent) + activity_processor.http_client.clone.return_value = activity_processor.http_client + with patch("microsoft_teams.apps.app_process.ApiClient") as mock_api_client: + mock_context_api = MagicMock() + mock_context_api.users.token.get = AsyncMock(side_effect=Exception("no token")) + mock_context_api.conversations.activities.return_value = activities + mock_api_client.return_value = mock_context_api + + # Handler that calls ctx.send to exercise the updated_send wrapper + async def calling_handler(ctx): + await ctx.send("hi") + return None + + activity_processor.router.select_handlers = MagicMock(return_value=[calling_handler]) + activity_processor.event_manager = MagicMock() + activity_processor.event_manager.on_activity_response = AsyncMock() + activity_processor.event_manager.on_activity_sent = AsyncMock() + activity_processor.event_manager.on_error = AsyncMock() - # Handler that calls ctx.send to exercise the updated_send wrapper - async def calling_handler(ctx): - await ctx.send("hi") - return None - - activity_processor.router.select_handlers = MagicMock(return_value=[calling_handler]) - activity_processor.event_manager = MagicMock() - activity_processor.event_manager.on_activity_response = AsyncMock() - activity_processor.event_manager.on_activity_sent = AsyncMock() - activity_processor.event_manager.on_error = AsyncMock() - - await activity_processor.process_activity([], mock_activity_event) + await activity_processor.process_activity([], mock_activity_event) activity_processor.event_manager.on_activity_sent.assert_called_once() @@ -248,24 +249,26 @@ async def test_stream_handlers_emit_activity_sent_events(self, activity_processo mock_token.service_url = "https://service.url" mock_activity_event = ActivityEvent(body=core_activity, token=mock_token) - mock_stream = activity_processor.activity_sender.create_stream.return_value - activity_processor.router.select_handlers = MagicMock(return_value=[]) activity_processor.event_manager = MagicMock() activity_processor.event_manager.on_activity_response = AsyncMock() activity_processor.event_manager.on_activity_sent = AsyncMock() activity_processor.event_manager.on_error = AsyncMock() - await activity_processor.process_activity([], mock_activity_event) + with patch("microsoft_teams.apps.routing.activity_context.HttpStream") as mock_stream_class: + mock_stream = mock_stream_class.return_value + mock_stream.close = AsyncMock() - # Stream's on_chunk and on_close were registered with the inner handlers. - # Invoke them to exercise their bodies. - chunk_handler = mock_stream.on_chunk.call_args[0][0] - close_handler = mock_stream.on_close.call_args[0][0] + await activity_processor.process_activity([], mock_activity_event) - sent = SentActivity(id="chunk-1", activity_params=MessageActivityInput(text="chunk")) - await chunk_handler(sent) - await close_handler(sent) + # Stream's on_chunk and on_close were registered with the inner handlers. + # Invoke them to exercise their bodies. + chunk_handler = mock_stream.on_chunk.call_args[0][0] + close_handler = mock_stream.on_close.call_args[0][0] + + sent = SentActivity(id="chunk-1", activity_params=MessageActivityInput(text="chunk")) + await chunk_handler(sent) + await close_handler(sent) assert activity_processor.event_manager.on_activity_sent.call_count == 2 @@ -305,6 +308,46 @@ async def test_build_context_marks_signed_in_when_token_available(self, activity mock_api_client.users.token.get.assert_called_once() + @pytest.mark.asyncio + async def test_build_context_scopes_api_to_inbound_agentic_identity(self, activity_processor): + """Inbound Agent ID activities scope ctx.api with the inbound agentic identity.""" + core_activity = CoreActivity( + type="message", + id="activity-agentic", + service_url="https://service.url", + **{ + "from": {"id": "user-1", "name": "Test User"}, + "conversation": {"id": "conv-1"}, + "recipient": { + "id": "bot-1", + "name": "Test Bot", + "agenticAppId": "agentic-app-id", + "agenticUserId": "agentic-user-id", + "tenantId": "tenant-id", + }, + "channelId": "msteams", + }, + ) + mock_token = MagicMock(spec=TokenProtocol) + mock_token.service_url = "https://service.url" + mock_activity_event = ActivityEvent(body=core_activity, token=mock_token) + mock_api_client = MagicMock() + mock_api_client.users.token.get = AsyncMock(side_effect=Exception("no token")) + + activity_processor.router.select_handlers = MagicMock(return_value=[]) + activity_processor.event_manager = MagicMock() + activity_processor.event_manager.on_activity_response = AsyncMock() + activity_processor.event_manager.on_error = AsyncMock() + + with patch("microsoft_teams.apps.app_process.ApiClient", return_value=mock_api_client) as mock_api_client_type: + await activity_processor.process_activity([], mock_activity_event) + + assert mock_api_client_type.call_args.kwargs["auth_provider"] is activity_processor.auth_provider + agentic_identity = mock_api_client_type.call_args.kwargs["agentic_identity"] + assert agentic_identity.agentic_app_id == "agentic-app-id" + assert agentic_identity.agentic_user_id == "agentic-user-id" + assert agentic_identity.tenant_id == "tenant-id" + @pytest.mark.asyncio async def test_process_activity_raises_when_event_manager_missing(self, activity_processor): """process_activity raises ValueError if event_manager was never initialized.""" diff --git a/packages/apps/tests/test_function_context.py b/packages/apps/tests/test_function_context.py index 66d153ab3..af091023d 100644 --- a/packages/apps/tests/test_function_context.py +++ b/packages/apps/tests/test_function_context.py @@ -7,9 +7,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from microsoft_teams.api import ApiClient +from microsoft_teams.api import ApiClient, SentActivity from microsoft_teams.api.activities.message.message import MessageActivityInput -from microsoft_teams.api.models.conversation.conversation_reference import ConversationReference from microsoft_teams.apps import FunctionContext from microsoft_teams.cards import AdaptiveCard @@ -26,30 +25,29 @@ def mock_api(self): mock_conversations = MagicMock() mock_conversations.create = AsyncMock(return_value=MagicMock(id="new-conv")) mock_conversations.members_client.get_by_id = AsyncMock(return_value=True) + mock_activities = MagicMock() + mock_activities.create = AsyncMock( + return_value=SentActivity(id="sent-activity", activity_params=MessageActivityInput(text="sent")) + ) + mock_activities.update = AsyncMock( + return_value=SentActivity(id="updated-activity", activity_params=MessageActivityInput(text="updated")) + ) + mock_conversations.activities.return_value = mock_activities api.conversations = mock_conversations return api - @pytest.fixture - def mock_http(self): - """Create a mock activity sender.""" - http = MagicMock() - http.send = AsyncMock() - http.send.return_value = "sent-activity" - return http - @pytest.fixture def mock_logger(self): """Create a mock Logger.""" return MagicMock() @pytest.fixture - def function_context(self, mock_api: ApiClient, mock_http: Any) -> FunctionContext[Any]: + def function_context(self, mock_api: ApiClient) -> FunctionContext[Any]: ctx: FunctionContext[Any] = FunctionContext( id="bot-123", name="Test Bot", api=mock_api, - activity_sender=mock_http, data={"some": "payload"}, app_session_id="dummy-session", tenant_id="tenant-789", @@ -64,51 +62,68 @@ def function_context(self, mock_api: ApiClient, mock_http: Any) -> FunctionConte async def test_send_string_activity( self, function_context: FunctionContext[Any], - mock_http: Any, ) -> None: """Test sending a string message.""" result = await function_context.send("Hello world") - assert result == "sent-activity" + assert result is not None + assert result.id == "sent-activity" - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Hello world" - - assert isinstance(conversation_ref, ConversationReference) - assert conversation_ref.conversation.id == "conv-123" + assert sent_activity.from_.id == "bot-123" + assert sent_activity.conversation.id == "conv-123" + function_context.api.conversations.activities.assert_called_once_with("conv-123") async def test_send_adaptive_card( self, function_context: FunctionContext[Any], - mock_http: Any, ) -> None: """Test sending an AdaptiveCard message.""" card = AdaptiveCard(schema="1.0") result = await function_context.send(card) - assert result == "sent-activity" + assert result is not None + assert result.id == "sent-activity" - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.attachments[0].content == card - assert conversation_ref.conversation.id == "conv-123" + function_context.api.conversations.activities.assert_called_once_with("conv-123") - async def test_send_creates_conversation_if_none( - self, function_context: FunctionContext[Any], mock_http: Any - ) -> None: + async def test_send_creates_conversation_if_none(self, function_context: FunctionContext[Any]) -> None: """Test send() creates a conversation when _resolved_conversation_id is None.""" function_context.chat_id = None - mock_http.send.return_value = "sent-new-conv" + function_context.api.conversations.activities.return_value.create.return_value = SentActivity( + id="sent-new-conv", activity_params=MessageActivityInput(text="sent") + ) result = await function_context.send("Hello new conversation") - assert result == "sent-new-conv" + assert result is not None + assert result.id == "sent-new-conv" # Ensure conversation was created assert function_context.api.conversations.create.call_count == 1 # type: ignore - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == "Hello new conversation" - assert conversation_ref.conversation.id == "new-conv" + function_context.api.conversations.activities.assert_called_with("new-conv") + + async def test_send_existing_activity_updates(self, function_context: FunctionContext[Any]) -> None: + activity = MessageActivityInput(text="Updated message") + activity.id = "existing-msg-id" + + result = await function_context.send(activity) + + assert result is not None + assert result.id == "updated-activity" + function_context.api.conversations.activities.return_value.update.assert_called_once_with( + "existing-msg-id", + activity, + service_url="https://test.service.url", + agentic_identity=None, + ) + function_context.api.conversations.activities.return_value.create.assert_not_called() diff --git a/packages/apps/tests/test_optional_graph_dependencies.py b/packages/apps/tests/test_optional_graph_dependencies.py index 2045e88dd..b01c56907 100644 --- a/packages/apps/tests/test_optional_graph_dependencies.py +++ b/packages/apps/tests/test_optional_graph_dependencies.py @@ -65,8 +65,6 @@ def _create_activity_context(self) -> ActivityContext[Any]: mock_storage = MagicMock() mock_api = MagicMock() mock_conversation_ref = MagicMock() - mock_activity_sender = MagicMock() - mock_activity_sender.create_stream.return_value = MagicMock() mock_app_token = MagicMock() # Provide an app token for graph access return ActivityContext( @@ -78,7 +76,6 @@ def _create_activity_context(self) -> ActivityContext[Any]: conversation_ref=mock_conversation_ref, is_signed_in=False, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=mock_app_token, # This is needed for app_graph to work cloud=PUBLIC, ) @@ -126,7 +123,6 @@ def test_user_graph_property_not_signed_in(self) -> None: conversation_ref=MagicMock(), is_signed_in=False, # Not signed in connection_name="test-connection", - activity_sender=MagicMock(), app_token=None, cloud=PUBLIC, ) @@ -146,7 +142,6 @@ def test_user_graph_property_no_token(self) -> None: conversation_ref=MagicMock(), is_signed_in=True, # Signed in but no token connection_name="test-connection", - activity_sender=MagicMock(), app_token=None, cloud=PUBLIC, ) @@ -166,7 +161,6 @@ def test_app_graph_property_no_token(self) -> None: conversation_ref=MagicMock(), is_signed_in=False, connection_name="test-connection", - activity_sender=MagicMock(), app_token=None, # No app token cloud=PUBLIC, ) diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py index 712888c1d..909b2fe32 100644 --- a/packages/apps/tests/test_quoted_reply.py +++ b/packages/apps/tests/test_quoted_reply.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from microsoft_teams.api import Account, MessageActivityInput, SentActivity +from microsoft_teams.api import Account, ConversationAccount, ConversationReference, MessageActivityInput, SentActivity from microsoft_teams.api.models.entity import QuotedReplyEntity from microsoft_teams.apps.routing.activity_context import ActivityContext @@ -36,19 +36,27 @@ def _create_activity_context( ) ) mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) - - mock_conversation_ref = MagicMock() + activities = MagicMock() + activities.create = mock_activity_sender.send + api = MagicMock() + api.conversations.activities.return_value = activities + + conversation_ref = ConversationReference( + bot=Account(id="bot-id", name="Test Bot"), + conversation=ConversationAccount(id="test-conversation"), + channel_id="msteams", + service_url="https://service.example", + ) return ActivityContext( activity=mock_activity, app_id="test-app-id", storage=MagicMock(), - api=MagicMock(), + api=api, user_token=None, - conversation_ref=mock_conversation_ref, + conversation_ref=conversation_ref, is_signed_in=False, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=MagicMock(), ) @@ -58,7 +66,7 @@ async def test_reply_stamps_quoted_reply_entity(self) -> None: ctx = self._create_activity_context(activity_id="msg-abc") await ctx.reply("Thanks!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -71,7 +79,7 @@ async def test_reply_prepends_placeholder(self) -> None: ctx = self._create_activity_context(activity_id="msg-abc") await ctx.reply("Thanks!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' Thanks!' @pytest.mark.asyncio @@ -81,7 +89,7 @@ async def test_reply_with_empty_text(self) -> None: activity = MessageActivityInput(text="") await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio @@ -91,7 +99,7 @@ async def test_reply_with_none_text(self) -> None: activity = MessageActivityInput() await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio @@ -100,7 +108,7 @@ async def test_reply_with_no_activity_id(self) -> None: ctx = self._create_activity_context(activity_id=None) await ctx.reply("Thanks!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == "Thanks!" assert sent_activity.entities is None @@ -114,7 +122,7 @@ async def test_reply_preserves_existing_entities(self) -> None: await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 2 assert sent_activity.entities[0] is existing_entity @@ -127,7 +135,7 @@ async def test_reply_with_activity_params(self) -> None: activity = MessageActivityInput(text="Hello world") await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' Hello world' @@ -148,19 +156,27 @@ def _create_activity_context(self) -> ActivityContext[Any]: ) ) mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) - - mock_conversation_ref = MagicMock() + activities = MagicMock() + activities.create = mock_activity_sender.send + api = MagicMock() + api.conversations.activities.return_value = activities + + conversation_ref = ConversationReference( + bot=Account(id="bot-id", name="Test Bot"), + conversation=ConversationAccount(id="test-conversation"), + channel_id="msteams", + service_url="https://service.example", + ) return ActivityContext( activity=mock_activity, app_id="test-app-id", storage=MagicMock(), - api=MagicMock(), + api=api, user_token=None, - conversation_ref=mock_conversation_ref, + conversation_ref=conversation_ref, is_signed_in=False, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=MagicMock(), ) @@ -170,7 +186,7 @@ async def test_quote_stamps_entity_with_message_id(self) -> None: ctx = self._create_activity_context() await ctx.quote("arbitrary-msg-id", "Quoting this!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -183,7 +199,7 @@ async def test_quote_prepends_placeholder(self) -> None: ctx = self._create_activity_context() await ctx.quote("msg-xyz", "My reply text") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' My reply text' @pytest.mark.asyncio @@ -193,7 +209,7 @@ async def test_quote_with_empty_text(self) -> None: activity = MessageActivityInput(text="") await ctx.quote("msg-xyz", activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio @@ -203,7 +219,7 @@ async def test_quote_with_activity_params(self) -> None: activity = MessageActivityInput(text="Hello world") await ctx.quote("msg-xyz", activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' Hello world' assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py index dd75d3601..ac24f7034 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -6,17 +6,20 @@ # pyright: basic from typing import Literal, cast -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch import pytest from microsoft_teams.api import ( + AgenticIdentity, ClientCredentials, FederatedIdentityCredentials, JsonWebToken, ManagedIdentityCredentials, ) +from microsoft_teams.api.auth.cloud_environment import PUBLIC from microsoft_teams.api.auth.credentials import TokenCredentials -from microsoft_teams.apps.token_manager import TokenManager +from microsoft_teams.apps.auth_provider import AppAuthProvider +from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TOKEN_EXCHANGE_SCOPE, TokenManager from msal import ManagedIdentityClient # pyright: ignore[reportMissingTypeStubs] # Valid JWT-like token for testing (format: header.payload.signature) @@ -30,6 +33,266 @@ class TestTokenManager: """Test TokenManager functionality.""" + @pytest.mark.asyncio + async def test_get_agentic_token_uses_agent_identity_flow(self): + mock_credentials = ClientCredentials( + client_id="blueprint-client-id", + client_secret="blueprint-client-secret", + tenant_id="tenant-id", + ) + + blueprint_app = MagicMock() + blueprint_app.acquire_token_for_client.return_value = {"access_token": "t1-token"} + + agent_app = MagicMock() + agent_app.acquire_token_for_client.side_effect = lambda _scopes: ( + mock_confidential_app.call_args_list[1].kwargs["client_credential"]["client_assertion"]({}), + {"access_token": "t2-token"}, + )[1] + agent_app.acquire_token_by_user_federated_identity_credential.return_value = {"access_token": VALID_TEST_TOKEN} + + with patch("microsoft_teams.apps.token_manager.ConfidentialClientApplication") as mock_confidential_app: + mock_confidential_app.side_effect = [blueprint_app, agent_app] + + manager = TokenManager(credentials=mock_credentials) + token = await manager.get_agentic_token( + AGENT_BOT_API_SCOPE, + AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id"), + ) + + assert token is not None + assert str(token) == VALID_TEST_TOKEN + + blueprint_app.acquire_token_for_client.assert_called_once_with( + [TOKEN_EXCHANGE_SCOPE], fmi_path="agentic-app-id" + ) + agent_app.acquire_token_for_client.assert_called_once_with([TOKEN_EXCHANGE_SCOPE]) + agent_app.acquire_token_by_user_federated_identity_credential.assert_called_once_with( + [AGENT_BOT_API_SCOPE], + assertion="t2-token", + user_object_id="agentic-user-id", + username=None, + data={"requested_token_use": "on_behalf_of"}, + ) + + first_call, second_call = mock_confidential_app.call_args_list + assert first_call.args == ("blueprint-client-id",) + assert first_call.kwargs == { + "client_credential": "blueprint-client-secret", + "authority": "https://login.microsoftonline.com/tenant-id", + } + assert second_call.args == ("agentic-app-id",) + assert second_call.kwargs["authority"] == "https://login.microsoftonline.com/tenant-id" + assert callable(second_call.kwargs["client_credential"]["client_assertion"]) + + @pytest.mark.asyncio + async def test_get_agentic_token_caches_agent_identity_client(self): + mock_credentials = ClientCredentials( + client_id="blueprint-client-id", + client_secret="blueprint-client-secret", + tenant_id="tenant-id", + ) + + blueprint_app = MagicMock() + blueprint_app.acquire_token_for_client.return_value = {"access_token": "t1-token"} + + agent_app = MagicMock() + agent_app.acquire_token_for_client.return_value = {"access_token": "t2-token"} + agent_app.acquire_token_by_user_federated_identity_credential.return_value = {"access_token": VALID_TEST_TOKEN} + + with patch("microsoft_teams.apps.token_manager.ConfidentialClientApplication") as mock_confidential_app: + mock_confidential_app.side_effect = [blueprint_app, agent_app] + + manager = TokenManager(credentials=mock_credentials) + await manager.get_agentic_token( + AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + ) + await manager.get_agentic_token( + AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + ) + + assert mock_confidential_app.call_count == 2 + + @pytest.mark.asyncio + async def test_get_agentic_token_with_token_credentials_passes_agentic_identity(self): + calls = [] + + async def token_provider(scope: str, tenant_id: str | None, *, agentic_identity: AgenticIdentity | None): + calls.append((scope, tenant_id, agentic_identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="blueprint-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity) + + assert token is not None + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", identity)] + + @pytest.mark.asyncio + async def test_get_agentic_token_with_token_credentials_accepts_positional_identity(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None, identity: AgenticIdentity | None): + calls.append((scope, tenant_id, identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="blueprint-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity) + + assert token is not None + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", identity)] + + @pytest.mark.asyncio + async def test_get_token_with_required_third_argument_passes_none_for_non_agentic_token(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None, identity: AgenticIdentity | None): + calls.append((scope, tenant_id, identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id") + + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", None)] + + @pytest.mark.asyncio + async def test_get_token_with_optional_third_argument_uses_default_for_non_agentic_token(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None, timeout: int = 30): + calls.append((scope, tenant_id, timeout)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id") + + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", 30)] + + @pytest.mark.asyncio + async def test_token_provider_uninspectable_signature_uses_legacy_args_without_agentic_identity(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None): + calls.append((scope, tenant_id)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + with patch("microsoft_teams.apps.token_manager.signature", side_effect=ValueError("no signature")): + token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id") + + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id")] + + @pytest.mark.asyncio + async def test_token_provider_uninspectable_signature_rejects_agentic_identity(self): + credentials = TokenCredentials( + client_id="test-client-id", + token=lambda _scope, _tenant_id: VALID_TEST_TOKEN, + tenant_id="tenant-id", + ) + manager = TokenManager(credentials=credentials) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + with patch("microsoft_teams.apps.token_manager.signature", side_effect=ValueError("no signature")): + with pytest.raises(ValueError, match="Token provider must accept agentic_identity"): + await manager._get_token_with_token_provider( + credentials, AGENT_BOT_API_SCOPE, "tenant-id", agentic_identity + ) + + @pytest.mark.asyncio + async def test_app_auth_provider_uses_app_token_without_agentic_identity(self): + token_manager = MagicMock(spec=TokenManager) + token_manager.get_app_token = AsyncMock(return_value="app-token") + auth_provider = AppAuthProvider(token_manager, PUBLIC) + + token = await auth_provider.token() + + assert token == "app-token" + token_manager.get_app_token.assert_awaited_once_with(PUBLIC.bot_scope, caller_name="token") + token_manager.get_agentic_token.assert_not_called() + + @pytest.mark.asyncio + async def test_app_auth_provider_uses_agentic_token_with_agentic_identity(self): + token_manager = MagicMock(spec=TokenManager) + token_manager.get_agentic_token = AsyncMock(return_value="agentic-token") + auth_provider = AppAuthProvider(token_manager, PUBLIC) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + token = await auth_provider.token(agentic_identity=agentic_identity) + + assert token == "agentic-token" + token_manager.get_agentic_token.assert_awaited_once_with( + AGENT_BOT_API_SCOPE, + agentic_identity, + caller_name="token", + ) + token_manager.get_app_token.assert_not_called() + + @pytest.mark.asyncio + async def test_app_auth_provider_passes_missing_agentic_tenant_to_token_manager(self): + token_manager = MagicMock(spec=TokenManager) + token_manager.get_agentic_token = AsyncMock(return_value="agentic-token") + auth_provider = AppAuthProvider(token_manager, PUBLIC) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id") + + token = await auth_provider.token(agentic_identity=agentic_identity) + + assert token == "agentic-token" + token_manager.get_agentic_token.assert_awaited_once_with( + AGENT_BOT_API_SCOPE, + agentic_identity, + caller_name="token", + ) + token_manager.get_app_token.assert_not_called() + + @pytest.mark.asyncio + async def test_get_agentic_token_uses_credentials_tenant_when_missing(self): + calls = [] + + async def token_provider(scope: str, tenant_id: str | None, *, agentic_identity: AgenticIdentity | None): + calls.append((scope, tenant_id, agentic_identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials( + client_id="blueprint-client-id", token=token_provider, tenant_id="credential-tenant-id" + ) + manager = TokenManager(credentials=credentials) + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id") + token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity) + + assert token is not None + assert calls == [(AGENT_BOT_API_SCOPE, "credential-tenant-id", identity)] + + @pytest.mark.asyncio + async def test_get_agentic_token_requires_tenant_when_missing_from_request_and_credentials(self): + credentials = TokenCredentials( + client_id="blueprint-client-id", + token=lambda _scope, _tenant_id: VALID_TEST_TOKEN, + ) + manager = TokenManager(credentials=credentials) + + with pytest.raises(ValueError, match="tenant_id is required to get an agentic token"): + await manager.get_agentic_token( + AGENT_BOT_API_SCOPE, + AgenticIdentity("agentic-app-id", "agentic-user-id"), + ) + @pytest.mark.asyncio async def test_get_bot_token_success(self): """Test successful bot token retrieval using MSAL.""" diff --git a/packages/apps/tests/test_token_validator.py b/packages/apps/tests/test_token_validator.py index eaa1bab60..f949be367 100644 --- a/packages/apps/tests/test_token_validator.py +++ b/packages/apps/tests/test_token_validator.py @@ -3,11 +3,12 @@ Licensed under the MIT License. """ -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import jwt import pytest -from microsoft_teams.apps.auth.token_validator import TokenValidator +from microsoft_teams.apps.auth import token_validator as token_validator_module +from microsoft_teams.apps.auth.token_validator import InboundActivityTokenValidator, TokenValidator # pyright: basic @@ -407,3 +408,94 @@ async def test_validate_entra_token_v1_sts_issuer(self, mock_jwks_client): result = await validator.validate_token("v1.entra.token") assert result["iss"] == "https://sts.windows.net/test-tenant-id/" assert result["ver"] == "1.0" + + +class TestInboundActivityTokenValidator: + @pytest.mark.asyncio + async def test_validate_token_uses_service_validator_for_bot_framework_tokens(self): + validator = InboundActivityTokenValidator("test-app-id") + validator._service_validator.validate_token = AsyncMock(return_value={"iss": "https://api.botframework.com"}) + + with patch("jwt.decode", return_value={"iss": "https://api.botframework.com"}) as decode: + result = await validator.validate_token("bot-token", "https://service.example") + + assert result == {"iss": "https://api.botframework.com"} + decode.assert_called_once_with("bot-token", algorithms=["RS256"], options={"verify_signature": False}) + validator._service_validator.validate_token.assert_called_once_with("bot-token", "https://service.example") + + @pytest.mark.asyncio + async def test_validate_token_uses_entra_validator_for_v2_issuer(self): + validator = InboundActivityTokenValidator("test-app-id") + validator._service_validator.validate_token = AsyncMock() + entra_validator = MagicMock() + entra_validator.validate_token = AsyncMock(return_value={"tid": "tenant-id"}) + + with patch.object(validator, "_get_entra_validator", return_value=entra_validator) as get_validator: + with patch( + "jwt.decode", + return_value={"iss": "https://login.microsoftonline.com/tenant-id/v2.0", "tid": "tenant-id"}, + ): + result = await validator.validate_token("entra-token", "https://service.example") + + assert result == {"tid": "tenant-id"} + get_validator.assert_called_once_with("tenant-id") + entra_validator.validate_token.assert_called_once_with("entra-token") + validator._service_validator.validate_token.assert_not_called() + + @pytest.mark.asyncio + async def test_validate_token_uses_entra_validator_for_v1_sts_issuer(self): + validator = InboundActivityTokenValidator("test-app-id") + entra_validator = MagicMock() + entra_validator.validate_token = AsyncMock(return_value={"tid": "tenant-id"}) + + with patch.object(validator, "_get_entra_validator", return_value=entra_validator) as get_validator: + with patch("jwt.decode", return_value={"iss": "https://sts.windows.net/tenant-id/", "tid": "tenant-id"}): + result = await validator.validate_token("entra-v1-token") + + assert result == {"tid": "tenant-id"} + get_validator.assert_called_once_with("tenant-id") + entra_validator.validate_token.assert_called_once_with("entra-v1-token") + + @pytest.mark.asyncio + async def test_validate_token_rejects_entra_token_without_tid(self): + validator = InboundActivityTokenValidator("test-app-id") + + with patch("jwt.decode", return_value={"iss": "https://login.microsoftonline.com/tenant-id/v2.0"}): + with pytest.raises(jwt.InvalidTokenError, match="missing tid"): + await validator.validate_token("entra-token") + + @pytest.mark.asyncio + async def test_validate_token_rejects_empty_token_before_routing_decode(self): + validator = InboundActivityTokenValidator("test-app-id") + + with patch("jwt.decode") as decode: + with pytest.raises(jwt.InvalidTokenError, match="No token provided"): + await validator.validate_token("") + + decode.assert_not_called() + + def test_get_entra_validator_caches_by_tenant(self): + validator = InboundActivityTokenValidator("test-app-id") + + with patch("microsoft_teams.apps.auth.token_validator.TokenValidator.for_entra") as for_entra: + for_entra.return_value = MagicMock() + + first = validator._get_entra_validator("tenant-id") + second = validator._get_entra_validator("tenant-id") + + assert first is second + for_entra.assert_called_once_with("test-app-id", "tenant-id", cloud=validator._cloud) + + def test_get_entra_validator_cache_is_bounded(self): + validator = InboundActivityTokenValidator("test-app-id") + + with patch("microsoft_teams.apps.auth.token_validator.TokenValidator.for_entra") as for_entra: + for_entra.side_effect = lambda _app_id, tenant_id, **_kwargs: MagicMock(name=tenant_id) + + for index in range(token_validator_module._MAX_ENTRA_VALIDATOR_CACHE_SIZE + 1): + validator._get_entra_validator(f"tenant-{index}") + + assert len(validator._entra_validators_by_tenant) == token_validator_module._MAX_ENTRA_VALIDATOR_CACHE_SIZE + assert "tenant-0" not in validator._entra_validators_by_tenant + last_tenant_id = f"tenant-{token_validator_module._MAX_ENTRA_VALIDATOR_CACHE_SIZE}" + assert last_tenant_id in validator._entra_validators_by_tenant diff --git a/packages/common/src/microsoft_teams/common/http/client.py b/packages/common/src/microsoft_teams/common/http/client.py index 5c9305130..868707963 100644 --- a/packages/common/src/microsoft_teams/common/http/client.py +++ b/packages/common/src/microsoft_teams/common/http/client.py @@ -89,7 +89,7 @@ class ClientOptions: headers: Dict[str, str] = field(default_factory=dict[str, str]) timeout: Optional[float] = None token: Optional[Token] = None - interceptors: Optional[List[Interceptor]] = field(default_factory=list[Interceptor]) + interceptors: Optional[List[Interceptor]] = None class Client: @@ -125,6 +125,11 @@ def __init__(self, options: Optional[ClientOptions] = None): ) self._update_event_hooks() + @property + def interceptors(self) -> tuple[Interceptor, ...]: + """Get the registered interceptors.""" + return tuple(self._interceptors) + async def _prepare_headers(self, headers: Optional[Dict[str, str]], token: Optional[Token]) -> Dict[str, str]: """ Merge default and per-request headers, resolve token, and inject Authorization header if needed. diff --git a/packages/common/tests/test_client.py b/packages/common/tests/test_client.py index 529f38bd8..14b6793c6 100644 --- a/packages/common/tests/test_client.py +++ b/packages/common/tests/test_client.py @@ -119,6 +119,36 @@ async def test_clone_merges_options_and_interceptors(mock_transport): assert interceptor2.request_called +def test_interceptors_returns_read_only_copy(): + interceptor1 = DummyAsyncInterceptor() + client = Client(ClientOptions(interceptors=[interceptor1])) + + assert client.interceptors == (interceptor1,) + + +def test_clone_copies_interceptor_list_independently(): + interceptor1 = DummyAsyncInterceptor() + client = Client(ClientOptions(interceptors=[interceptor1])) + + clone = client.clone() + interceptor2 = DummyAsyncInterceptor() + clone.use_interceptor(interceptor2) + + assert client.interceptors == (interceptor1,) + assert clone.interceptors == (interceptor1, interceptor2) + assert client.interceptors is not clone.interceptors + + +def test_clone_can_clear_interceptors_with_empty_override(): + interceptor1 = DummyAsyncInterceptor() + client = Client(ClientOptions(interceptors=[interceptor1])) + + clone = client.clone(ClientOptions(interceptors=[])) + + assert client.interceptors == (interceptor1,) + assert clone.interceptors == () + + @pytest.mark.parametrize( "token,expected", [ diff --git a/stubs/msal/__init__.pyi b/stubs/msal/__init__.pyi index 0ec244167..5e5272ced 100644 --- a/stubs/msal/__init__.pyi +++ b/stubs/msal/__init__.pyi @@ -1,8 +1,8 @@ """Type stubs for msal""" -from typing import Any, Callable, TypeAlias +from typing import Any, Callable, Optional, TypeAlias -ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str]] | None +ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str] | Callable[[dict[str, Any]], str]] | None class ConfidentialClientApplication: """MSAL Confidential Client Application""" @@ -16,7 +16,22 @@ class ConfidentialClientApplication: **kwargs: Any, ) -> None: ... def acquire_token_for_client( - self, scopes: list[str] | str, claims_challenge: str | None = None, **kwargs: Any + self, + scopes: list[str] | str, + claims_challenge: str | None = None, + *, + fmi_path: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: ... + def acquire_token_by_user_federated_identity_credential( + self, + scopes: list[str], + assertion: str | Callable[[], str], + *, + username: Optional[str] = None, + user_object_id: Optional[str] = None, + claims_challenge: Optional[None] = None, + **kwargs: Any, ) -> dict[str, Any]: ... class SystemAssignedManagedIdentity: diff --git a/uv.lock b/uv.lock index 3ef2ba7ac..061ce1f51 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,7 @@ resolution-markers = [ [manifest] members = [ "a2a", + "agent365", "ai-agentframework", "botbuilder", "cards", @@ -133,6 +134,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/58/1878b9ac4db703f40c687129c0bccdbf88c94d557b64f0172f3c4e954558/agent_framework_openai-1.1.0-py3-none-any.whl", hash = "sha256:8fbcdb87fbc3fb6aa6f3d781a61a2c48fbc308d2ee8165454f94e53e026a787b", size = 50280, upload-time = "2026-04-21T06:20:11.864Z" }, ] +[[package]] +name = "agent365" +version = "0.1.0" +source = { virtual = "examples/agent365" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "ai-agentframework" version = "0.1.0" @@ -1822,7 +1838,7 @@ requires-dist = [ { name = "microsoft-teams-api", editable = "packages/api" }, { name = "microsoft-teams-common", editable = "packages/common" }, { name = "microsoft-teams-graph", marker = "extra == 'graph'", editable = "packages/graph" }, - { name = "msal", specifier = ">=1.33.0" }, + { name = "msal", specifier = ">=1.37.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, @@ -1935,16 +1951,16 @@ dev = [ [[package]] name = "msal" -version = "1.34.0" +version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, ] [[package]]