From 24b0ffa9ccf9b61617328fbba20215ac20fec898 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 10 Jun 2026 21:46:00 -0700 Subject: [PATCH 1/7] Start Agent 365 support branch --- README.md | 4 ++++ 1 file changed, 4 insertions(+) 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. + From ecd265454fb24e80826b13b552658561c39e39e7 Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:36:30 -0700 Subject: [PATCH 2/7] [A365 support] Add Agent 365 token support (#457) Adds Agent 365 agent user token support to TokenManager. Why: This lets apps request resource tokens for an agent identity acting through its agent user, including Teams bot API tokens. Interesting bits: - Added generic get_agent_user_token(scope=...) plus get_agent_bot_token() convenience. - Uses MSAL's user FIC support and a client assertion callback for T1 so we don't sit on stale assertions. - Added an agent365 example that reads values from env vars and avoids printing the full token. tiny safety win. Reviewer tips: Start with packages/apps/src/microsoft_teams/apps/token_manager.py, then examples/agent365/src/main.py. Testing: - uv run pytest packages/apps/tests/test_token_manager.py - uv run ruff check packages/apps/src/microsoft_teams/apps/token_manager.py examples/agent365/src/main.py stubs/msal/__init__.pyi - pre-commit hooks on commit: ruff, ruff format, pyright, license header --- examples/agent365/README.md | 31 +++++++ examples/agent365/pyproject.toml | 13 +++ examples/agent365/src/main.py | 53 +++++++++++ packages/apps/pyproject.toml | 2 +- .../src/microsoft_teams/apps/token_manager.py | 87 ++++++++++++++++++- packages/apps/tests/test_token_manager.py | 84 +++++++++++++++++- stubs/msal/__init__.pyi | 21 ++++- uv.lock | 24 ++++- 8 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 examples/agent365/README.md create mode 100644 examples/agent365/pyproject.toml create mode 100644 examples/agent365/src/main.py diff --git a/examples/agent365/README.md b/examples/agent365/README.md new file mode 100644 index 000000000..c3164c58a --- /dev/null +++ b/examples/agent365/README.md @@ -0,0 +1,31 @@ +# agent365 + +Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user. + +## Run + +Set these environment variables or add them to `.env`: + +```bash +AGENT365_TENANT_ID= +AGENT365_BLUEPRINT_CLIENT_ID= +AGENT365_BLUEPRINT_CLIENT_SECRET= +AGENT365_AGENT_IDENTITY_APP_ID= +AGENT365_AGENT_USER_ID= +``` + +Then run: + +```bash +uv run --project examples/agent365 python src/main.py +``` + +By default this requests a Teams bot API token for `https://botapi.skype.com/.default`. + +To request another resource, set `AGENT365_SCOPE`, for example: + +```bash +AGENT365_SCOPE=https://graph.microsoft.com/.default +``` + +You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`. 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..358f108c4 --- /dev/null +++ b/examples/agent365/src/main.py @@ -0,0 +1,53 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio +import os + +from dotenv import load_dotenv +from microsoft_teams.api import ClientCredentials +from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager + + +def get_required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise ValueError(f"{name} must be set") + + return value + + +async def main(): + load_dotenv() + + tenant_id = get_required_env("AGENT365_TENANT_ID") + blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") + blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") + agentic_app_id = get_required_env("AGENT365_AGENTIC_APP_ID") + agentic_user_id = os.getenv("AGENT365_AGENTIC_USER_ID") + agentic_user_upn = os.getenv("AGENT365_AGENTIC_USER_UPN") + scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) + + credentials = ClientCredentials( + client_id=blueprint_client_id, + client_secret=blueprint_client_secret, + tenant_id=tenant_id, + ) + token_manager = TokenManager(credentials=credentials) + + token = await token_manager.get_agentic_token( + tenant_id, + agentic_app_id, + scope, + agentic_user_id=agentic_user_id, + agentic_user_upn=agentic_user_upn, + ) + + print(f"Acquired agent user token for {scope}") + print(f"Token preview: {str(token)[:20]}...") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml index c27b9db69..6a2b33c7f 100644 --- a/packages/apps/pyproject.toml +++ b/packages/apps/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "cryptography>=3.4.0", "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/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index e4c3525f1..18eb00f30 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -6,7 +6,7 @@ import asyncio import logging from inspect import isawaitable -from typing import Any, Optional +from typing import Any, Callable, Optional import requests from microsoft_teams.api import ( @@ -29,6 +29,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,6 +47,7 @@ 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]: @@ -68,6 +71,70 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) ) + async def get_agentic_token( + self, + tenant_id: str, + agentic_app_id: str, + scope: str, + *, + agentic_user_id: str | None = None, + agentic_user_upn: str | None = None, + caller_name: str | None = None, + ) -> Optional[TokenProtocol]: + """Get a resource token for an agentic identity acting through its agentic user.""" + if not agentic_user_id and not agentic_user_upn: + raise ValueError("Either agentic_user_id or agentic_user_upn must be provided") + if agentic_user_id and agentic_user_upn: + raise ValueError("agentic_user_id and agentic_user_upn are mutually exclusive") + if self._credentials is None: + if caller_name: + logger.debug(f"No credentials provided for {caller_name}") + return None + + credentials = self._credentials + 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_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_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_user_id, + username=agentic_user_upn, + 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]: @@ -220,6 +287,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: diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py index dd75d3601..a60e54030 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -16,7 +16,7 @@ ManagedIdentityCredentials, ) from microsoft_teams.api.auth.credentials import TokenCredentials -from microsoft_teams.apps.token_manager import TokenManager +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 +30,88 @@ 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( + "tenant-id", + "agentic-app-id", + AGENT_BOT_API_SCOPE, + agentic_user_id="agentic-user-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( + "tenant-id", "agentic-app-id", AGENT_BOT_API_SCOPE, agentic_user_id="agentic-user-id" + ) + await manager.get_agentic_token( + "tenant-id", "agentic-app-id", AGENT_BOT_API_SCOPE, agentic_user_id="agentic-user-id" + ) + + assert mock_confidential_app.call_count == 2 + @pytest.mark.asyncio async def test_get_bot_token_success(self): """Test successful bot token retrieval using MSAL.""" 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]] From 02c20914d395a1a027ccaf2531cdffccd1830e0e Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:21:07 -0700 Subject: [PATCH 3/7] Support Entra inbound Agent ID tokens (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds inbound validation for Agent ID activities using Entra tokens. Why: Agent 365 inbound activities can arrive with *Entra access tokens* whose audience is the agent identity blueprint app ID. Classic Bot Framework validation looks at the wrong JWKS for those tokens, so the request is rejected before handlers run. Interesting bits: - TokenValidator now owns the inbound activity token branching. - We do one untrusted JWT parse up front to choose the validation path. This is only routing info (`iss` / `tid`), not authentication. The token is accepted only after the chosen validator does full signature, issuer, audience, and scope validation. - Entra issuers validate with tenant Entra JWKS and blueprint audience. - Classic Bot Framework tokens keep the existing service token validation path. - The per-tenant Entra validator cache is bounded so random tenant IDs can’t grow it forever. Testing: - uv run pytest packages/apps/tests/test_token_validator.py::TestInboundActivityTokenValidator::test_validate_token_uses_service_validator_for_bot_framework_tokens packages/apps/tests/test_token_validator.py::TestInboundActivityTokenValidator::test_get_entra_validator_caches_by_tenant packages/apps/tests/test_token_validator.py::TestInboundActivityTokenValidator::test_get_entra_validator_cache_is_bounded - uv run ruff check packages/apps/src/microsoft_teams/apps/auth/token_validator.py packages/apps/tests/test_token_validator.py --- .../src/microsoft_teams/apps/auth/__init__.py | 4 +- .../apps/auth/token_validator.py | 62 +++++++++++- .../microsoft_teams/apps/http/http_server.py | 6 +- packages/apps/tests/test_token_validator.py | 96 ++++++++++++++++++- 4 files changed, 160 insertions(+), 8 deletions(-) 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/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/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 From 61985539d69b3f1ae1c7bad40cf092144625e1fc Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:25:34 -0700 Subject: [PATCH 4/7] Add agentic auth provider infrastructure (#485) Adds agentic auth provider plumbing to the API layer. `ApiClient` is powered by `HttpClient` to make requests. Normally, `HttpClient` can take a token directly, but Agent ID calls need the SDK to choose between a regular bot token and an agentic identity token per request. So this PR does the following: 1. Introduces the `AgenticIdentity` model. 2. Introduces an `AuthProvider` concept at the `ApiClient` layer. If provided, auth can happen through `ApiClient` if HttpClient does not have a token revolver. 3. Installs an HTTP interceptor from `ApiClient`. The interceptor skips requests that already have `Authorization`, otherwise it uses the `AuthProvider`. 4. Uses `httpx` request extensions to mark per-request `AgenticIdentity` metadata. This is local-only metadata, not a header, so it does not leak to Teams endpoints. With this, we are basically stamping a request that requires agentic identity. 5. The interceptor reads that extension, picks the bot vs Agent ID scope, and passes the scope + identity to the `AuthProvider`. 6. Updates token-provider typing so custom token providers can receive `AgenticIdentity` when needed. 7. Sovereign cloud Agent ID scopes are TODO-marked until platform values are confirmed. No sneaky magic there. --- examples/agent365/src/main.py | 10 +- .../src/microsoft_teams/api/auth/__init__.py | 4 + .../api/auth/cloud_environment.py | 9 + .../microsoft_teams/api/auth/credentials.py | 33 ++- .../api/clients/_auth_provider_interceptor.py | 54 +++++ .../microsoft_teams/api/clients/api_client.py | 58 +++-- .../api/clients/base_client.py | 2 +- .../microsoft_teams/api/clients/bot/client.py | 16 +- .../api/clients/bot/sign_in_client.py | 9 +- .../api/clients/bot/token_client.py | 49 +++- .../api/clients/user/client.py | 13 +- .../api/clients/user/token_client.py | 10 +- .../microsoft_teams/api/models/__init__.py | 2 + .../src/microsoft_teams/api/models/account.py | 26 ++ .../api/models/agentic_identity.py | 19 ++ packages/api/tests/unit/test_base_client.py | 224 ++++++++++++++++++ packages/api/tests/unit/test_bot_client.py | 84 ++++++- packages/api/tests/unit/test_user_client.py | 30 +++ packages/apps/pyproject.toml | 2 +- packages/apps/src/microsoft_teams/apps/app.py | 5 +- .../src/microsoft_teams/apps/auth_provider.py | 35 +++ .../apps/src/microsoft_teams/apps/options.py | 7 +- .../src/microsoft_teams/apps/token_manager.py | 101 ++++++-- packages/apps/tests/test_token_manager.py | 193 ++++++++++++++- .../src/microsoft_teams/common/http/client.py | 7 +- packages/common/tests/test_client.py | 30 +++ 26 files changed, 944 insertions(+), 88 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/clients/_auth_provider_interceptor.py create mode 100644 packages/api/src/microsoft_teams/api/models/agentic_identity.py create mode 100644 packages/api/tests/unit/test_base_client.py create mode 100644 packages/apps/src/microsoft_teams/apps/auth_provider.py diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 358f108c4..153e79f14 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -7,7 +7,7 @@ import os from dotenv import load_dotenv -from microsoft_teams.api import ClientCredentials +from microsoft_teams.api import AgenticIdentity, ClientCredentials from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager @@ -26,8 +26,7 @@ async def main(): blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") agentic_app_id = get_required_env("AGENT365_AGENTIC_APP_ID") - agentic_user_id = os.getenv("AGENT365_AGENTIC_USER_ID") - agentic_user_upn = os.getenv("AGENT365_AGENTIC_USER_UPN") + agentic_user_id = get_required_env("AGENT365_AGENTIC_USER_ID") scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) credentials = ClientCredentials( @@ -38,11 +37,8 @@ async def main(): token_manager = TokenManager(credentials=credentials) token = await token_manager.get_agentic_token( - tenant_id, - agentic_app_id, scope, - agentic_user_id=agentic_user_id, - agentic_user_upn=agentic_user_upn, + AgenticIdentity(agentic_app_id=agentic_app_id, agentic_user_id=agentic_user_id, tenant_id=tenant_id), ) print(f"Acquired agent user token for {scope}") 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/_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..4e52406fd 100644 --- a/packages/api/src/microsoft_teams/api/clients/base_client.py +++ b/packages/api/src/microsoft_teams/api/clients/base_client.py @@ -5,7 +5,7 @@ from typing import Optional, Union -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 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/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_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_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 6a2b33c7f..336c07c54 100644 --- a/packages/apps/pyproject.toml +++ b/packages/apps/pyproject.toml @@ -14,7 +14,7 @@ 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.37.0", diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..5da9a8a70 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -42,6 +42,7 @@ 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 +101,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 +112,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) 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/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/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index 18eb00f30..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, Callable, 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, @@ -52,8 +53,24 @@ def __init__( 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]: @@ -67,38 +84,42 @@ 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, - tenant_id: str, - agentic_app_id: str, scope: str, + agentic_identity: AgenticIdentity, *, - agentic_user_id: str | None = None, - agentic_user_upn: str | None = None, caller_name: str | None = None, ) -> Optional[TokenProtocol]: """Get a resource token for an agentic identity acting through its agentic user.""" - if not agentic_user_id and not agentic_user_upn: - raise ValueError("Either agentic_user_id or agentic_user_upn must be provided") - if agentic_user_id and agentic_user_upn: - raise ValueError("agentic_user_id and agentic_user_upn are mutually exclusive") + 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_app_id + [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") @@ -106,7 +127,7 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: # Identity assertion from step 1 as its client assertion for the next exchanges. t2_confidential_client = self._get_agent_identity_client( tenant_id, - agentic_app_id, + agentic_identity.agentic_app_id, get_t1_assertion, ) @@ -120,8 +141,8 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: lambda: t2_confidential_client.acquire_token_by_user_federated_identity_credential( [scope], assertion=t2, - user_object_id=agentic_user_id, - username=agentic_user_upn, + user_object_id=agentic_identity.agentic_user_id, + username=None, data={"requested_token_use": "on_behalf_of"}, ) ) @@ -227,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 @@ -238,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): @@ -332,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_token_manager.py b/packages/apps/tests/test_token_manager.py index a60e54030..ac24f7034 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -6,16 +6,19 @@ # 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.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] @@ -53,10 +56,8 @@ async def test_get_agentic_token_uses_agent_identity_flow(self): manager = TokenManager(credentials=mock_credentials) token = await manager.get_agentic_token( - "tenant-id", - "agentic-app-id", AGENT_BOT_API_SCOPE, - agentic_user_id="agentic-user-id", + AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id"), ) assert token is not None @@ -104,14 +105,194 @@ async def test_get_agentic_token_caches_agent_identity_client(self): manager = TokenManager(credentials=mock_credentials) await manager.get_agentic_token( - "tenant-id", "agentic-app-id", AGENT_BOT_API_SCOPE, agentic_user_id="agentic-user-id" + AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") ) await manager.get_agentic_token( - "tenant-id", "agentic-app-id", AGENT_BOT_API_SCOPE, agentic_user_id="agentic-user-id" + 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/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", [ From 2f8b767934c0e9654da290c222a19efe8aaf5094 Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:52:56 -0700 Subject: [PATCH 5/7] Add AgenticIdentity API surface (#478) This PR adds `AgenticIdentity` as a new auth context across the Teams API surface. Why: Agent ID calls need a user-shaped Agent ID token instead of the normal bot token. The API methods now accept `agentic_identity` where needed, and the shared auth interceptor uses local request metadata to pick the right auth path. Interesting bits: - Adds optional `agentic_identity` to conversation create, activity operations, conversation members, reactions, teams, and meetings. - Adds optional `service_url` overrides across the service-url based APIs touched here. - Agentic identity is passed through `httpx` request extensions, not headers. So it stays inside the client pipeline and does not leak to Teams endpoints. - The auth interceptor reads that extension and asks the auth provider for the right token. Reviewer tips: Start with the API method signatures, then the changed call sites in `ConversationActivityClient`, `ConversationMemberClient`, `ReactionClient`, `TeamClient`, and `MeetingClient`. The auth plumbing itself lives one PR down in #485. Testing: - Focused API unit tests for conversation, reaction, bot, user, team, and meeting clients. - Full test suite passed locally. Live smoke tested with Agent ID token: - conversation activity create/update/reply/get_members - conversation members get_all/get/get_paged - reactions add/delete - teams get_by_id/get_conversations - conversations.create - meetings get_by_id/get_participant Known limitation: - targeted activity create/update reached the service but returned 500. Leaving that as not proven until platform behavior is confirmed. --- .../microsoft_teams/api/clients/__init__.py | 2 + .../api/clients/base_client.py | 11 +- .../api/clients/conversation/activity.py | 98 +++++++-- .../api/clients/conversation/client.py | 153 ++++++++++--- .../api/clients/conversation/member.py | 48 ++++- .../api/clients/meeting/client.py | 41 +++- .../api/clients/reaction/client.py | 24 ++- .../api/clients/team/client.py | 23 +- packages/api/tests/unit/test_api_client.py | 24 +++ .../tests/unit/test_conversation_client.py | 203 +++++++++++++++++- .../api/tests/unit/test_meeting_client.py | 95 ++++++++ .../api/tests/unit/test_reaction_client.py | 98 ++++++++- packages/api/tests/unit/test_team_client.py | 67 +++++- 13 files changed, 811 insertions(+), 76 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py index 683d9c1ed..6d641f928 100644 --- a/packages/api/src/microsoft_teams/api/clients/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/__init__.py @@ -4,6 +4,7 @@ """ from . import bot, conversation, meeting, reaction, team, user +from ._auth_provider_interceptor import AuthProvider from .api_client import ApiClient from .api_client_settings import ApiClientSettings, merge_api_client_settings from .bot import * # noqa: F403 @@ -17,6 +18,7 @@ __all__: list[str] = [ "ApiClient", "ApiClientSettings", + "AuthProvider", "merge_api_client_settings", ] __all__.extend(bot.__all__) diff --git a/packages/api/src/microsoft_teams/api/clients/base_client.py b/packages/api/src/microsoft_teams/api/clients/base_client.py index 4e52406fd..41628e4ed 100644 --- a/packages/api/src/microsoft_teams/api/clients/base_client.py +++ b/packages/api/src/microsoft_teams/api/clients/base_client.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from typing import Optional, Union, cast from microsoft_teams.common import Client, ClientOptions @@ -21,7 +21,7 @@ def __init__( """Initialize the BaseClient. Args: - options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. + options: Optional Client or ClientOptions instance. If not provided, a default Client is created. api_client_settings: Optional API client settings. """ if options is None: @@ -42,3 +42,10 @@ def http(self) -> Client: def http(self, value: Client) -> None: """Set the HTTP client instance.""" self._http = value + + def _get_service_url(self, service_url: str | None = None) -> str: + current_service_url = cast(str | None, getattr(self, "service_url", None)) + resolved_service_url = service_url or current_service_url + if resolved_service_url is None: + raise ValueError("service_url is required") + return resolved_service_url.rstrip("/") diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py index a1ee86ef8..66c6928e7 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -9,7 +9,8 @@ from microsoft_teams.common.http import Client from ...activities import ActivityParams, SentActivity -from ...models import TeamsChannelAccount +from ...models import AgenticIdentity, TeamsChannelAccount +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -38,7 +39,14 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url.rstrip("/") - async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create( + self, + conversation_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Create a new activity in a conversation. @@ -51,8 +59,9 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc """ response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities", json=activity.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) # Note: Typing activities (non-streaming) always produce empty responses. @@ -61,7 +70,15 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) - async def update(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def update( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Update an existing activity in a conversation. @@ -74,13 +91,22 @@ async def update(self, conversation_id: str, activity_id: str, activity: Activit The updated activity """ response = await self.http.put( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}", json=activity.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def reply(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def reply( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Reply to an activity in a conversation. @@ -95,13 +121,21 @@ async def reply(self, conversation_id: str, activity_id: str, activity: Activity activity_json = activity.model_dump(by_alias=True, exclude_none=True) activity_json["replyToId"] = activity_id response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}", json=activity_json, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def delete(self, conversation_id: str, activity_id: str) -> None: + async def delete( + self, + conversation_id: str, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> None: """ Delete an activity from a conversation. @@ -109,9 +143,19 @@ async def delete(self, conversation_id: str, activity_id: str) -> None: conversation_id: The ID of the conversation activity_id: The ID of the activity to delete """ - await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}") + await self.http.delete( + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) - async def get_members(self, conversation_id: str, activity_id: str) -> List[TeamsChannelAccount]: + async def get_members( + self, + conversation_id: str, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> List[TeamsChannelAccount]: """ Get the members associated with an activity. @@ -123,12 +167,19 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Team List of TeamsChannelAccount objects representing the activity members """ response = await self.http.get( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/members" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}/members", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) return [TeamsChannelAccount.model_validate(member) for member in response.json()] @experimental("ExperimentalTeamsTargeted") - async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create_targeted( + self, + conversation_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + ) -> SentActivity: """ Create a new targeted activity in a conversation. @@ -146,14 +197,21 @@ async def create_targeted(self, conversation_id: str, activity: ActivityParams) The created activity """ response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true", json=activity.model_dump(by_alias=True, exclude_none=True), ) id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) @experimental("ExperimentalTeamsTargeted") - async def update_targeted(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def update_targeted( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + ) -> SentActivity: """ Update an existing targeted activity in a conversation. @@ -170,14 +228,20 @@ async def update_targeted(self, conversation_id: str, activity_id: str, activity The updated activity """ response = await self.http.put( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true", + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true", json=activity.model_dump(by_alias=True, exclude_none=True), ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @experimental("ExperimentalTeamsTargeted") - async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: + async def delete_targeted( + self, + conversation_id: str, + activity_id: str, + *, + service_url: str | None = None, + ) -> None: """ Delete a targeted activity from a conversation. @@ -190,5 +254,5 @@ async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: activity_id: The ID of the activity to delete """ await self.http.delete( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true" ) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/client.py b/packages/api/src/microsoft_teams/api/clients/conversation/client.py index cb9e491ef..97473eece 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -7,7 +7,8 @@ from microsoft_teams.common.http import Client, ClientOptions -from ...models import ConversationResource +from ...models import AgenticIdentity, ConversationResource +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient from .activity import ActivityParams, ConversationActivityClient @@ -26,45 +27,128 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None: class ActivityOperations(ConversationOperations): """Operations for managing activities in a conversation.""" - async def create(self, activity: ActivityParams): - return await self._client.activities_client.create(self._conversation_id, activity) + async def create( + self, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.create( + self._conversation_id, activity, service_url=service_url, agentic_identity=agentic_identity + ) - async def update(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.update(self._conversation_id, activity_id, activity) + async def update( + self, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.update( + self._conversation_id, activity_id, activity, service_url=service_url, agentic_identity=agentic_identity + ) - async def reply(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.reply(self._conversation_id, activity_id, activity) + async def reply( + self, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.reply( + self._conversation_id, activity_id, activity, service_url=service_url, agentic_identity=agentic_identity + ) - async def delete(self, activity_id: str): - await self._client.activities_client.delete(self._conversation_id, activity_id) + async def delete( + self, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + await self._client.activities_client.delete( + self._conversation_id, activity_id, service_url=service_url, agentic_identity=agentic_identity + ) - async def get_members(self, activity_id: str): - return await self._client.activities_client.get_members(self._conversation_id, activity_id) + async def get_members( + self, + activity_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.activities_client.get_members( + self._conversation_id, activity_id, service_url=service_url, agentic_identity=agentic_identity + ) - async def create_targeted(self, activity: ActivityParams): + async def create_targeted( + self, + activity: ActivityParams, + *, + service_url: str | None = None, + ): """Create a new targeted activity visible only to the specified recipient.""" - return await self._client.activities_client.create_targeted(self._conversation_id, activity) + return await self._client.activities_client.create_targeted( + self._conversation_id, activity, service_url=service_url + ) - async def update_targeted(self, activity_id: str, activity: ActivityParams): + async def update_targeted( + self, + activity_id: str, + activity: ActivityParams, + *, + service_url: str | None = None, + ): """Update an existing targeted activity.""" - return await self._client.activities_client.update_targeted(self._conversation_id, activity_id, activity) + return await self._client.activities_client.update_targeted( + self._conversation_id, activity_id, activity, service_url=service_url + ) - async def delete_targeted(self, activity_id: str): + async def delete_targeted( + self, + activity_id: str, + *, + service_url: str | None = None, + ): """Delete a targeted activity.""" - await self._client.activities_client.delete_targeted(self._conversation_id, activity_id) + await self._client.activities_client.delete_targeted( + self._conversation_id, activity_id, service_url=service_url + ) class MemberOperations(ConversationOperations): """Operations for managing members in a conversation.""" - async def get_all(self): - return await self._client.members_client.get(self._conversation_id) + async def get_all(self, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None): + return await self._client.members_client.get( + self._conversation_id, service_url=service_url, agentic_identity=agentic_identity + ) - async def get_paged(self, page_size: Optional[int] = None, continuation_token: Optional[str] = None): - return await self._client.members_client.get_paged(self._conversation_id, page_size, continuation_token) + async def get_paged( + self, + page_size: Optional[int] = None, + continuation_token: Optional[str] = None, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ): + return await self._client.members_client.get_paged( + self._conversation_id, + page_size, + continuation_token, + service_url=service_url, + agentic_identity=agentic_identity, + ) - async def get(self, member_id: str): - return await self._client.members_client.get_by_id(self._conversation_id, member_id) + async def get( + self, member_id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ): + return await self._client.members_client.get_by_id( + self._conversation_id, member_id, service_url=service_url, agentic_identity=agentic_identity + ) class ConversationClient(BaseClient): @@ -86,8 +170,16 @@ def __init__( super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") - self._activities_client = ConversationActivityClient(self.service_url, self.http, self._api_client_settings) - self._members_client = ConversationMemberClient(self.service_url, self.http, self._api_client_settings) + self._activities_client = ConversationActivityClient( + self.service_url, + self.http, + self._api_client_settings, + ) + self._members_client = ConversationMemberClient( + self.service_url, + self.http, + self._api_client_settings, + ) @property def http(self) -> Client: @@ -133,7 +225,13 @@ def members(self, conversation_id: str) -> MemberOperations: """ return MemberOperations(self, conversation_id) - async def create(self, params: CreateConversationParams) -> ConversationResource: + async def create( + self, + params: CreateConversationParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> ConversationResource: """Create a new conversation. Args: @@ -143,7 +241,8 @@ async def create(self, params: CreateConversationParams) -> ConversationResource The created conversation resource. """ response = await self.http.post( - f"{self.service_url}/v3/conversations", + f"{self._get_service_url(service_url)}/v3/conversations", json=params.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) return ConversationResource.model_validate(response.json()) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/member.py b/packages/api/src/microsoft_teams/api/clients/conversation/member.py index 78a7a958a..5c662f89e 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/member.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/member.py @@ -3,12 +3,15 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import List, Optional from microsoft_teams.common.http import Client -from ...models import TeamsChannelAccount +from ...models import AgenticIdentity, TeamsChannelAccount from ...models.conversation import PagedMembersResult +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -35,7 +38,13 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url.rstrip("/") - async def get(self, conversation_id: str) -> List[TeamsChannelAccount]: + async def get( + self, + conversation_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> List[TeamsChannelAccount]: """ Get all members in a conversation. @@ -45,7 +54,10 @@ async def get(self, conversation_id: str) -> List[TeamsChannelAccount]: Returns: List of TeamsChannelAccount objects representing the conversation members """ - response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/members", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return [TeamsChannelAccount.model_validate(member) for member in response.json()] async def get_paged( @@ -53,6 +65,9 @@ async def get_paged( conversation_id: str, page_size: Optional[int] = None, continuation_token: Optional[str] = None, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> PagedMembersResult: """ Get a page of members in a conversation. @@ -66,11 +81,27 @@ async def get_paged( PagedMembersResult containing the members and an optional continuation token for fetching subsequent pages. """ - url = f"{self.service_url}/v3/conversations/{conversation_id}/pagedMembers" - response = await self.http.get(url, params={"pageSize": page_size, "continuationToken": continuation_token}) + url = f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/pagedMembers" + params: dict[str, int | str] = {} + if page_size is not None: + params["pageSize"] = page_size + if continuation_token is not None: + params["continuationToken"] = continuation_token + response = await self.http.get( + url, + params=params, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return PagedMembersResult.model_validate(response.json()) - async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelAccount: + async def get_by_id( + self, + conversation_id: str, + member_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> TeamsChannelAccount: """ Get a specific member in a conversation. @@ -81,5 +112,8 @@ async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelA Returns: TeamsChannelAccount object representing the conversation member """ - response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members/{member_id}") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}/members/{member_id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return TeamsChannelAccount.model_validate(response.json()) diff --git a/packages/api/src/microsoft_teams/api/clients/meeting/client.py b/packages/api/src/microsoft_teams/api/clients/meeting/client.py index cea7277b9..b9ae3e417 100644 --- a/packages/api/src/microsoft_teams/api/clients/meeting/client.py +++ b/packages/api/src/microsoft_teams/api/clients/meeting/client.py @@ -3,12 +3,15 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ...models import MeetingInfo, MeetingParticipant +from ...models import AgenticIdentity, MeetingInfo, MeetingParticipant from ...models.meetings.meeting_notification import MeetingNotificationParams, MeetingNotificationResponse +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -33,7 +36,9 @@ def __init__( super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") - async def get_by_id(self, id: str) -> MeetingInfo: + async def get_by_id( + self, id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> MeetingInfo: """ Retrieves meeting information including details, organizer, and conversation. @@ -43,10 +48,21 @@ async def get_by_id(self, id: str) -> MeetingInfo: Returns: The meeting information. """ - response = await self.http.get(f"{self.service_url}/v1/meetings/{id}") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v1/meetings/{id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return MeetingInfo.model_validate(response.json()) - async def get_participant(self, meeting_id: str, id: str, tenant_id: str) -> MeetingParticipant: + async def get_participant( + self, + meeting_id: str, + id: str, + tenant_id: str, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> MeetingParticipant: """ Retrieves information about a specific participant in a meeting. @@ -58,12 +74,20 @@ async def get_participant(self, meeting_id: str, id: str, tenant_id: str) -> Mee Returns: MeetingParticipant: The meeting participant information. """ - url = f"{self.service_url}/v1/meetings/{meeting_id}/participants/{id}?tenantId={tenant_id}" - response = await self.http.get(url) + url = f"{self._get_service_url(service_url)}/v1/meetings/{meeting_id}/participants/{id}?tenantId={tenant_id}" + response = await self.http.get( + url, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return MeetingParticipant.model_validate(response.json()) async def send_notification( - self, meeting_id: str, params: MeetingNotificationParams + self, + meeting_id: str, + params: MeetingNotificationParams, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> Optional[MeetingNotificationResponse]: """ Send a targeted meeting notification to participants. @@ -80,8 +104,9 @@ async def send_notification( with per-recipient failure details on partial success. """ response = await self.http.post( - f"{self.service_url}/v1/meetings/{meeting_id}/notification", + f"{self._get_service_url(service_url)}/v1/meetings/{meeting_id}/notification", json=params.model_dump(by_alias=True, exclude_none=True), + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) if not response.text: return None diff --git a/packages/api/src/microsoft_teams/api/clients/reaction/client.py b/packages/api/src/microsoft_teams/api/clients/reaction/client.py index ac9d68c45..3a6296168 100644 --- a/packages/api/src/microsoft_teams/api/clients/reaction/client.py +++ b/packages/api/src/microsoft_teams/api/clients/reaction/client.py @@ -7,7 +7,9 @@ from microsoft_teams.common.http import Client +from ...models import AgenticIdentity from ...models.message import MessageReactionType +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient @@ -39,6 +41,9 @@ async def add( conversation_id: str, activity_id: str, reaction_type: MessageReactionType, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> None: """ Adds a reaction on an activity in a conversation. @@ -49,15 +54,22 @@ async def add( reaction_type: The reaction type (for example: "like", "heart", "laugh", etc.). """ url = ( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}" + f"/activities/{activity_id}/reactions/{reaction_type}" + ) + await self.http.put( + url, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) - await self.http.put(url) async def delete( self, conversation_id: str, activity_id: str, reaction_type: MessageReactionType, + *, + service_url: str | None = None, + agentic_identity: AgenticIdentity | None = None, ) -> None: """ Removes a reaction from an activity in a conversation. @@ -68,6 +80,10 @@ async def delete( reaction_type: The reaction type to remove (for example: "like", "heart", "laugh", etc.). """ url = ( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" + f"{self._get_service_url(service_url)}/v3/conversations/{conversation_id}" + f"/activities/{activity_id}/reactions/{reaction_type}" + ) + await self.http.delete( + url, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, ) - await self.http.delete(url) diff --git a/packages/api/src/microsoft_teams/api/clients/team/client.py b/packages/api/src/microsoft_teams/api/clients/team/client.py index 7ffca2e92..d3dabb49b 100644 --- a/packages/api/src/microsoft_teams/api/clients/team/client.py +++ b/packages/api/src/microsoft_teams/api/clients/team/client.py @@ -3,11 +3,14 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import List, Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ...models import ChannelInfo, TeamDetails +from ...models import AgenticIdentity, ChannelInfo, TeamDetails +from .._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION from ..api_client_settings import ApiClientSettings from ..base_client import BaseClient from .params import GetTeamConversationsResponse @@ -33,7 +36,9 @@ def __init__( super().__init__(options, api_client_settings) self.service_url = service_url.rstrip("/") - async def get_by_id(self, id: str) -> TeamDetails: + async def get_by_id( + self, id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> TeamDetails: """ Get team details by ID. @@ -43,10 +48,15 @@ async def get_by_id(self, id: str) -> TeamDetails: Returns: The team details. """ - response = await self.http.get(f"{self.service_url}/v3/teams/{id}") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/teams/{id}", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return TeamDetails.model_validate(response.json()) - async def get_conversations(self, id: str) -> List[ChannelInfo]: + async def get_conversations( + self, id: str, *, service_url: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> List[ChannelInfo]: """ Get team conversations (channels). @@ -56,5 +66,8 @@ async def get_conversations(self, id: str) -> List[ChannelInfo]: Returns: List of channel information. """ - response = await self.http.get(f"{self.service_url}/v3/teams/{id}/conversations") + response = await self.http.get( + f"{self._get_service_url(service_url)}/v3/teams/{id}/conversations", + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) return GetTeamConversationsResponse.model_validate(response.json()).conversations diff --git a/packages/api/tests/unit/test_api_client.py b/packages/api/tests/unit/test_api_client.py index b1b439e45..1181da6b6 100644 --- a/packages/api/tests/unit/test_api_client.py +++ b/packages/api/tests/unit/test_api_client.py @@ -6,6 +6,8 @@ import pytest from microsoft_teams.api.clients import ApiClient, ReactionClient +from microsoft_teams.api.clients._auth_provider_interceptor import AuthProviderInterceptor +from microsoft_teams.api.models import AgenticIdentity from microsoft_teams.common.http import Client, ClientOptions @@ -23,6 +25,28 @@ def test_reactions_first_access_creates_reaction_client(self, mock_http_client): assert reactions is not None assert isinstance(reactions, ReactionClient) + def test_reactions_inherits_agentic_auth_defaults(self, mock_http_client): + """Test reactions inherits agentic auth defaults from ApiClient.""" + auth_provider = object() + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient( + "https://mock.service.url", + mock_http_client, + auth_provider=auth_provider, + agentic_identity=identity, + ) + + reactions = client.reactions + + assert not hasattr(reactions, "_auth_provider") + assert not hasattr(reactions, "_agentic_identity") + interceptor = next( + interceptor + for interceptor in mock_http_client.interceptors + if isinstance(interceptor, AuthProviderInterceptor) + ) + assert interceptor._default_agentic_identity is identity + def test_reactions_second_access_returns_cached_client(self, mock_http_client): """Test that the reactions property returns the same instance on subsequent accesses.""" client = ApiClient("https://mock.service.url", mock_http_client) diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index cc309015d..d8a701af4 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -9,9 +9,11 @@ import httpx import pytest +from microsoft_teams.api.auth.cloud_environment import PUBLIC, with_overrides +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.conversation import ConversationClient from microsoft_teams.api.clients.conversation.params import CreateConversationParams -from microsoft_teams.api.models import ConversationResource, PagedMembersResult, TeamsChannelAccount +from microsoft_teams.api.models import AgenticIdentity, ConversationResource, PagedMembersResult, TeamsChannelAccount from microsoft_teams.common.http import Client, ClientOptions @@ -100,6 +102,54 @@ async def test_create_conversation_without_activity(self, request_capture, mock_ assert last_request.method == "POST" assert str(last_request.url) == "https://test.service.url/v3/conversations" + @pytest.mark.asyncio + async def test_create_conversation_uses_service_url_override(self, request_capture, mock_account): + client = ConversationClient("https://test.service.url", request_capture) + params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id") + + await client.create(params, service_url="https://override.service.url/") + + request = request_capture._capture.last_request + assert request.method == "POST" + assert str(request.url) == "https://override.service.url/v3/conversations" + + @pytest.mark.asyncio + async def test_create_conversation_uses_auth_provider_for_bot_token(self, request_capture, mock_account): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id") + + await client.create(params) + + assert calls == [(None, None)] + request = request_capture._capture.last_request + assert request.headers["authorization"] == "Bearer bot-token" + + @pytest.mark.asyncio + async def test_create_conversation_uses_agentic_identity(self, request_capture, mock_account): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id") + + await client.create(params, agentic_identity=identity) + + assert calls == [(None, identity)] + request = request_capture._capture.last_request + assert request.headers["authorization"] == "Bearer agentic-token" + def test_conversation_resource_with_all_fields(self): """Test that ConversationResource correctly handles all fields present.""" resource = ConversationResource.model_validate( @@ -199,6 +249,95 @@ async def test_activity_create(self, request_capture, mock_activity): payload = json.loads(last_request.content) assert payload["type"] == "message" + async def test_activity_create_with_service_url_override(self, request_capture, mock_activity): + """Test creating an activity with a per-request service URL override.""" + client = ConversationClient("https://default.service.url", request_capture) + + await client.activities("test_conversation_id").create( + mock_activity, service_url="https://override.service.url/" + ) + + last_request = request_capture._capture.last_request + assert str(last_request.url) == "https://override.service.url/v3/conversations/test_conversation_id/activities" + + async def test_activity_create_uses_auth_provider_for_bot_token(self, request_capture, mock_activity): + """Test creating an activity with an auth provider but no agentic identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + + await client.activities("test_conversation_id").create(mock_activity) + + assert calls == [(None, None)] + last_request = request_capture._capture.last_request + assert last_request.headers["authorization"] == "Bearer bot-token" + + async def test_activity_create_uses_client_agentic_identity(self, request_capture, mock_activity): + """Test creating an activity with the client's default agentic identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + cloud = with_overrides(PUBLIC, agentic_bot_scope="agentic-scope") + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient( + "https://test.service.url", + request_capture, + auth_provider=TestAuthProvider(), + agentic_identity=identity, + cloud=cloud, + ).conversations + + await client.activities("test_conversation_id").create(mock_activity) + + assert calls == [(None, identity)] + last_request = request_capture._capture.last_request + assert last_request.headers["authorization"] == "Bearer agentic-token" + + async def test_activity_create_agentic_identity_overrides_client_default(self, request_capture, mock_activity): + """Test per-request agentic identity overrides the client's default identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "override-token" + + default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id") + override_identity = AgenticIdentity("override-app-id", "override-user-id", tenant_id="override-tenant-id") + client = ApiClient( + "https://test.service.url", + request_capture, + auth_provider=TestAuthProvider(), + agentic_identity=default_identity, + ).conversations + + await client.activities("test_conversation_id").create(mock_activity, agentic_identity=override_identity) + + assert calls == [(None, override_identity)] + last_request = request_capture._capture.last_request + assert last_request.headers["authorization"] == "Bearer override-token" + + async def test_activity_create_agentic_identity_without_auth_provider_uses_http_client_auth( + self, request_capture, mock_activity + ): + """Test agentic identity without an auth provider leaves auth resolution to the HTTP client.""" + client = ConversationClient("https://test.service.url", request_capture) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.activities("test_conversation_id").create(mock_activity, agentic_identity=identity) + + last_request = request_capture._capture.last_request + assert "authorization" not in last_request.headers + async def test_activity_update(self, request_capture, mock_activity): """Test updating an activity.""" service_url = "https://test.service.url" @@ -365,6 +504,68 @@ async def test_member_get(self, request_capture): str(last_request.url) == f"https://test.service.url/v3/conversations/{conversation_id}/members/{member_id}" ) + async def test_member_operations_use_service_url_override(self, request_capture): + client = ConversationClient("https://test.service.url", request_capture) + members = client.members("test_conversation_id") + + await members.get_all(service_url="https://override.service.url/") + await members.get("test_member_id", service_url="https://override.service.url/") + await members.get_paged(page_size=10, service_url="https://override.service.url/") + + urls = [str(request.url) for request in request_capture._capture.requests[-3:]] + assert urls == [ + "https://override.service.url/v3/conversations/test_conversation_id/members", + "https://override.service.url/v3/conversations/test_conversation_id/members/test_member_id", + "https://override.service.url/v3/conversations/test_conversation_id/pagedMembers?pageSize=10", + ] + + async def test_member_operations_use_auth_provider_for_bot_token(self, request_capture): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + members = client.members("test_conversation_id") + + await members.get_all() + await members.get("test_member_id") + await members.get_paged(page_size=10) + + assert calls == [ + (None, None), + (None, None), + (None, None), + ] + for request in request_capture._capture.requests[-3:]: + assert request.headers["authorization"] == "Bearer bot-token" + + async def test_member_operations_use_agentic_identity(self, request_capture): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations + members = client.members("test_conversation_id") + + await members.get_all(agentic_identity=identity) + await members.get("test_member_id", agentic_identity=identity) + await members.get_paged(page_size=10, agentic_identity=identity) + + assert calls == [ + (None, identity), + (None, identity), + (None, identity), + ] + for request in request_capture._capture.requests[-3:]: + assert request.headers["authorization"] == "Bearer agentic-token" + async def test_member_get_paged(self, mock_http_client): """Test getting a page of members returns PagedMembersResult.""" diff --git a/packages/api/tests/unit/test_meeting_client.py b/packages/api/tests/unit/test_meeting_client.py index 4f4e7d183..5a6ff7619 100644 --- a/packages/api/tests/unit/test_meeting_client.py +++ b/packages/api/tests/unit/test_meeting_client.py @@ -8,8 +8,10 @@ import httpx import pytest +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.meeting import MeetingClient from microsoft_teams.api.models import ( + AgenticIdentity, MeetingInfo, MeetingNotificationParams, MeetingNotificationResponse, @@ -48,6 +50,99 @@ async def test_get_participant(self, mock_http_client): assert isinstance(result, MeetingParticipant) + @pytest.mark.asyncio + async def test_meeting_operations_use_service_url_override(self, mock_http_client): + client = MeetingClient("https://test.service.url", mock_http_client) + + meeting_response = httpx.Response( + 200, + json={ + "id": "meeting-id", + "details": { + "id": "meeting-id", + "type": "meetingChat", + "joinUrl": "https://teams.microsoft.com/l/meetup-join/meeting-id", + "title": "Meeting", + "msGraphResourceId": "graph-resource-id", + }, + }, + headers={"content-type": "application/json"}, + ) + with patch.object(mock_http_client, "get", new_callable=AsyncMock, return_value=meeting_response) as mock_get: + await client.get_by_id("meeting-id", service_url="https://override.service.url/") + + mock_get.assert_called_once_with( + "https://override.service.url/v1/meetings/meeting-id", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + participant_response = httpx.Response( + 200, + json={"user": {"id": "participant-id"}}, + headers={"content-type": "application/json"}, + ) + with patch.object( + mock_http_client, "get", new_callable=AsyncMock, return_value=participant_response + ) as mock_get_participant: + await client.get_participant( + "meeting-id", + "participant-id", + "tenant-id", + service_url="https://override.service.url/", + ) + + mock_get_participant.assert_called_once_with( + "https://override.service.url/v1/meetings/meeting-id/participants/participant-id?tenantId=tenant-id", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + params = MeetingNotificationParams( + value=MeetingNotificationValue( + recipients=["mock_aad_oid"], + surfaces=[MeetingNotificationSurface(surface="meetingTabIcon", tab_entity_id="test")], + ) + ) + notification_response = httpx.Response(202, content=b"", headers={"content-type": "application/json"}) + with patch.object( + mock_http_client, "post", new_callable=AsyncMock, return_value=notification_response + ) as mock_post: + await client.send_notification("meeting-id", params, service_url="https://override.service.url/") + + mock_post.assert_called_once_with( + "https://override.service.url/v1/meetings/meeting-id/notification", + json=params.model_dump(by_alias=True, exclude_none=True), + extensions={"microsoft_teams.agentic_identity": None}, + ) + + @pytest.mark.asyncio + async def test_get_by_id_uses_auth_provider_for_bot_token(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).meetings + await client.get_by_id("meeting-id") + + assert calls == [(None, None)] + + @pytest.mark.asyncio + async def test_get_participant_uses_agentic_identity(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).meetings + await client.get_participant("meeting-id", "participant-id", "tenant-id", agentic_identity=identity) + + assert calls == [(None, identity)] + def test_http_client_property(self, mock_http_client): """Test HTTP client property getter and setter.""" service_url = "https://test.service.url" diff --git a/packages/api/tests/unit/test_reaction_client.py b/packages/api/tests/unit/test_reaction_client.py index f17868847..72eeecbd0 100644 --- a/packages/api/tests/unit/test_reaction_client.py +++ b/packages/api/tests/unit/test_reaction_client.py @@ -7,7 +7,10 @@ from unittest.mock import AsyncMock, patch import pytest +from microsoft_teams.api.auth.cloud_environment import PUBLIC, with_overrides +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.reaction import ReactionClient +from microsoft_teams.api.models import AgenticIdentity @pytest.mark.unit @@ -53,7 +56,77 @@ async def test_add_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_put.assert_called_once_with(expected_url) + mock_put.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) + + @pytest.mark.asyncio + async def test_reaction_operations_use_service_url_override(self, mock_http_client): + client = ReactionClient("https://test.service.url", mock_http_client) + + with patch.object(mock_http_client, "put", new_callable=AsyncMock) as mock_put: + await client.add( + "test_conversation_id", + "test_activity_id", + "like", + service_url="https://override.service.url/", + ) + + mock_put.assert_called_once_with( + "https://override.service.url/v3/conversations/test_conversation_id/activities/test_activity_id/reactions/like", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + with patch.object(mock_http_client, "delete", new_callable=AsyncMock) as mock_delete: + await client.delete( + "test_conversation_id", + "test_activity_id", + "like", + service_url="https://override.service.url/", + ) + + mock_delete.assert_called_once_with( + "https://override.service.url/v3/conversations/test_conversation_id/activities/test_activity_id/reactions/like", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + @pytest.mark.asyncio + async def test_add_reaction_uses_agentic_identity(self, mock_http_client): + """Test adding a reaction with an agentic token.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + cloud = with_overrides(PUBLIC, agentic_bot_scope="agentic-scope") + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient( + "https://test.service.url", + mock_http_client, + auth_provider=TestAuthProvider(), + agentic_identity=identity, + cloud=cloud, + ).reactions + + await client.add("test_conversation_id", "test_activity_id", "like") + + assert calls == [(None, identity)] + + @pytest.mark.asyncio + async def test_add_reaction_uses_auth_provider_for_bot_token(self, mock_http_client): + """Test adding a reaction with an auth provider but no agentic identity.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).reactions + + await client.add("test_conversation_id", "test_activity_id", "like") + + assert calls == [(None, None)] @pytest.mark.asyncio async def test_add_heart_reaction(self, mock_http_client): @@ -71,7 +144,7 @@ async def test_add_heart_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_put.assert_called_once_with(expected_url) + mock_put.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) @pytest.mark.asyncio async def test_delete_reaction(self, mock_http_client): @@ -89,7 +162,24 @@ async def test_delete_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_delete.assert_called_once_with(expected_url) + mock_delete.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) + + @pytest.mark.asyncio + async def test_delete_reaction_uses_method_agentic_identity(self, mock_http_client): + """Test removing a reaction with a per-call agentic token.""" + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).reactions + + await client.delete("test_conversation_id", "test_activity_id", "like", agentic_identity=identity) + + assert calls == [(None, identity)] @pytest.mark.asyncio async def test_delete_laugh_reaction(self, mock_http_client): @@ -107,4 +197,4 @@ async def test_delete_laugh_reaction(self, mock_http_client): expected_url = ( f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}" ) - mock_delete.assert_called_once_with(expected_url) + mock_delete.assert_called_once_with(expected_url, extensions={"microsoft_teams.agentic_identity": None}) diff --git a/packages/api/tests/unit/test_team_client.py b/packages/api/tests/unit/test_team_client.py index a47b5edb1..2f68013eb 100644 --- a/packages/api/tests/unit/test_team_client.py +++ b/packages/api/tests/unit/test_team_client.py @@ -4,9 +4,13 @@ """ # pyright: basic +from unittest.mock import AsyncMock, patch + +import httpx import pytest +from microsoft_teams.api.clients import ApiClient from microsoft_teams.api.clients.team import TeamClient -from microsoft_teams.api.models import ChannelInfo, TeamDetails +from microsoft_teams.api.models import AgenticIdentity, ChannelInfo, TeamDetails from microsoft_teams.common.http import Client, ClientOptions @@ -37,6 +41,67 @@ async def test_get_conversations(self, mock_http_client): assert isinstance(result, list) assert all(isinstance(channel, ChannelInfo) for channel in result) + @pytest.mark.asyncio + async def test_team_operations_use_service_url_override(self, mock_http_client): + client = TeamClient("https://test.service.url", mock_http_client) + + team_response = httpx.Response( + 200, + json={"id": "team-id", "name": "Team"}, + headers={"content-type": "application/json"}, + ) + with patch.object(mock_http_client, "get", new_callable=AsyncMock, return_value=team_response) as mock_get: + await client.get_by_id("team-id", service_url="https://override.service.url/") + + mock_get.assert_called_once_with( + "https://override.service.url/v3/teams/team-id", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + conversations_response = httpx.Response( + 200, + json={"conversations": []}, + headers={"content-type": "application/json"}, + ) + with patch.object( + mock_http_client, "get", new_callable=AsyncMock, return_value=conversations_response + ) as mock_get_conversations: + await client.get_conversations("team-id", service_url="https://override.service.url/") + + mock_get_conversations.assert_called_once_with( + "https://override.service.url/v3/teams/team-id/conversations", + extensions={"microsoft_teams.agentic_identity": None}, + ) + + @pytest.mark.asyncio + async def test_get_by_id_uses_auth_provider_for_bot_token(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).teams + await client.get_by_id("team-id") + + assert calls == [(None, None)] + + @pytest.mark.asyncio + async def test_get_conversations_uses_agentic_identity(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "agentic-token" + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).teams + await client.get_conversations("team-id", agentic_identity=identity) + + assert calls == [(None, identity)] + def test_http_client_property(self, mock_http_client): """Test HTTP client property getter and setter.""" service_url = "https://test.service.url" From 5ce920a2b671f6032690030e1578537a2a4ec70e Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:02:12 -0700 Subject: [PATCH 6/7] Remove ActivitySender send abstraction (#479) Removes the separate `ActivitySender` class while preserving its send behavior. Why: ActivitySender had become a second send path. With AgenticIdentity support, the API client owns service URL resolution and request auth, so activity sends should route through the same API client path. Interesting bits: - `send_or_update_activity` keeps the old ActivitySender contract without keeping the class. - Preserves create vs update behavior when `activity.id` is set. - Preserves targeted create/update routing through `create_targeted` / `update_targeted`. - Preserves `activity.from_ = ref.bot` and `activity.conversation = ref.conversation` stamping. - Preserves targeted-in-personal-chat validation. - ActivityContext still creates `HttpStream` directly from its scoped API client. Reviewer tips: Start with `activity_send.py`, then check `ActivityContext.send`, `App.send`, and `FunctionContext.send`. Testing: - Focused app send/reply/function/quoted-reply tests covering the old ActivitySender behavior. - Ruff on touched app send files and tests. --- .../src/microsoft_teams/apps/activity_send.py | 58 +++++ .../microsoft_teams/apps/activity_sender.py | 95 -------- packages/apps/src/microsoft_teams/apps/app.py | 9 +- .../src/microsoft_teams/apps/app_process.py | 4 - .../apps/contexts/function_context.py | 20 +- .../apps/routing/activity_context.py | 17 +- packages/apps/tests/test_activity_context.py | 167 +++++++++----- packages/apps/tests/test_activity_sender.py | 211 ------------------ packages/apps/tests/test_app.py | 53 +++-- packages/apps/tests/test_app_oauth.py | 2 - packages/apps/tests/test_app_process.py | 72 +++--- packages/apps/tests/test_function_context.py | 73 +++--- .../tests/test_optional_graph_dependencies.py | 6 - packages/apps/tests/test_quoted_reply.py | 60 +++-- 14 files changed, 345 insertions(+), 502 deletions(-) create mode 100644 packages/apps/src/microsoft_teams/apps/activity_send.py delete mode 100644 packages/apps/src/microsoft_teams/apps/activity_sender.py delete mode 100644 packages/apps/tests/test_activity_sender.py 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 5da9a8a70..82c2ec6c7 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -35,7 +35,7 @@ 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 @@ -128,9 +128,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, @@ -140,7 +137,6 @@ def __init__(self, **options: Unpack[AppOptions]): self.http_client, self._token_manager, self.options.api_client_settings, - self.activity_sender, self.cloud, ) self.event_manager = EventManager(self._events) @@ -321,7 +317,7 @@ 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) @overload async def reply( @@ -574,7 +570,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..fc9b69d39 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -26,7 +26,6 @@ if TYPE_CHECKING: from .app_events import EventManager -from .activity_sender import ActivitySender from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError from .routing.activity_context import ActivityContext @@ -49,7 +48,6 @@ def __init__( http_client: Client, token_manager: TokenManager, api_client_settings: Optional[ApiClientSettings], - activity_sender: ActivitySender, cloud: CloudEnvironment = PUBLIC, ) -> None: self.router = router @@ -59,7 +57,6 @@ def __init__( self.http_client = http_client self.token_manager = token_manager 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 @@ -127,7 +124,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/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/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 58365143b..ee9e372cd 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,7 @@ 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) 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/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 7f1eeb5da..fc0c856e8 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 @@ -504,17 +556,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 +580,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 +591,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 +607,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 +620,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 +634,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 +664,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 +740,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 +769,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 +785,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..4c6d57fac 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -778,9 +778,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,8 +795,12 @@ 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" class TestAppInitialize: @@ -800,9 +810,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 +825,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 +864,34 @@ 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_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_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..c685a40d8 100644 --- a/packages/apps/tests/test_app_oauth.py +++ b/packages/apps/tests/test_app_oauth.py @@ -446,7 +446,6 @@ def processor(self, router): http_client=MagicMock(), token_manager=MagicMock(), api_client_settings=None, - activity_sender=MagicMock(), cloud=PUBLIC, ) @@ -462,7 +461,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..f946b00f2 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,7 +17,6 @@ ) 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.events import CoreActivity @@ -43,11 +42,6 @@ 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 return ActivityProcessor( mock_activity_router, "id", @@ -56,7 +50,6 @@ def activity_processor(self, mock_http_client): mock_http_client, mock_token_manager, None, - mock_activity_sender, PUBLIC, ) @@ -72,18 +65,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 +200,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 +246,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() + + await activity_processor.process_activity([], mock_activity_event) - # 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] + # 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) + 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 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 From b65a9976d5a6f15a5f6c4130ce9f0665824e6a40 Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:57:28 -0700 Subject: [PATCH 7/7] Add app-level agentic send helpers (#480) Adds app-level reactive and proactive helpers on top of the AgenticIdentity API surface. Why: The lower branches teach the API client how to send with `agentic_identity`. This branch wires that into the developer-facing app flows. Reactive flow: ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): 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 await ctx.send(f"You said '{ctx.activity.text}'") ``` Proactive app helper: ```python agentic_identity = app.get_agentic_identity(agentic_app_id, agentic_user_id) sent = await app.send( conversation_id, "Hello from app.send with an AgenticIdentity.", agentic_identity=agentic_identity, ) ``` Interesting bits: - Reactive `ctx.api` is scoped with the inbound Agent ID identity when present. - Reactive `ctx.send` / `ctx.reply` use the inbound service URL and identity through the API layer. - `ActivityProcessor` now receives the app's existing auth provider instead of rebuilding one without credentials. - Proactive `app.send(..., agentic_identity=...)` is a convenience over the same API operation path. Reviewer tips: Start with `examples/agent365/src/main.py`, then `ActivityProcessor` and `ActivityContext.send`. Testing: - Focused app helper, reactive context, and app-process auth-provider tests. - Ruff on touched app/example files. Live smoke tested with Agent ID token: - reactive message send/reply flow - proactive conversation activity create/update/reply/get_members - reactions add/delete using canary service URL - conversation members get_all/get/get_paged - teams get_by_id/get_conversations - conversations.create - meetings get_by_id/get_participant Known limitation: - targeted activity create/update reached the service but returned 500. Leaving that as not proven until platform behavior is confirmed. --- examples/agent365/README.md | 33 ++++---- examples/agent365/src/main.py | 62 +++++++-------- examples/agent365/src/proactive.py | 43 +++++++++++ packages/apps/src/microsoft_teams/apps/app.py | 63 ++++++++++++++-- .../src/microsoft_teams/apps/app_process.py | 9 ++- .../apps/routing/activity_context.py | 7 +- packages/apps/tests/test_activity_context.py | 48 ++++++++++++ packages/apps/tests/test_app.py | 75 +++++++++++++++++++ packages/apps/tests/test_app_oauth.py | 2 + packages/apps/tests/test_app_process.py | 43 +++++++++++ 10 files changed, 330 insertions(+), 55 deletions(-) create mode 100644 examples/agent365/src/proactive.py diff --git a/examples/agent365/README.md b/examples/agent365/README.md index c3164c58a..21bf73109 100644 --- a/examples/agent365/README.md +++ b/examples/agent365/README.md @@ -1,31 +1,30 @@ # agent365 -Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user. +Demonstrates passing `AgenticIdentity` directly to Teams API surfaces. -## Run +## Reactive Echo -Set these environment variables or add them to `.env`: +`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 -AGENT365_TENANT_ID= -AGENT365_BLUEPRINT_CLIENT_ID= -AGENT365_BLUEPRINT_CLIENT_SECRET= -AGENT365_AGENT_IDENTITY_APP_ID= -AGENT365_AGENT_USER_ID= -``` - -Then run: +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= -```bash uv run --project examples/agent365 python src/main.py ``` -By default this requests a Teams bot API token for `https://botapi.skype.com/.default`. +## Proactive API Send -To request another resource, set `AGENT365_SCOPE`, for example: +`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 -AGENT365_SCOPE=https://graph.microsoft.com/.default +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/proactive.py \ + \ + \ + ``` - -You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`. diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 153e79f14..355b3d1f8 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -4,46 +4,48 @@ """ import asyncio -import os +import logging +import re -from dotenv import load_dotenv -from microsoft_teams.api import AgenticIdentity, ClientCredentials -from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager +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__) -def get_required_env(name: str) -> str: - value = os.getenv(name) - if not value: - raise ValueError(f"{name} must be set") +app = App() - return value +@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?") -async def main(): - load_dotenv() - tenant_id = get_required_env("AGENT365_TENANT_ID") - blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") - blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") - agentic_app_id = get_required_env("AGENT365_AGENTIC_APP_ID") - agentic_user_id = get_required_env("AGENT365_AGENTIC_USER_ID") - scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) +@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) - credentials = ClientCredentials( - client_id=blueprint_client_id, - client_secret=blueprint_client_secret, - tenant_id=tenant_id, - ) - token_manager = TokenManager(credentials=credentials) + await ctx.reply(TypingActivityInput()) - token = await token_manager.get_agentic_token( - scope, - AgenticIdentity(agentic_app_id=agentic_app_id, agentic_user_id=agentic_user_id, tenant_id=tenant_id), - ) + 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 - print(f"Acquired agent user token for {scope}") - print(f"Token preview: {str(token)[:20]}...") + 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(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/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 82c2ec6c7..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, @@ -136,6 +137,7 @@ 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.cloud, ) @@ -289,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, @@ -305,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), ) @@ -317,7 +326,32 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap else: activity = activity - return await send_or_update_activity(self.api, 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( @@ -325,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 @@ -332,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] @@ -339,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. @@ -359,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.""" diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index fc9b69d39..aad80d1e0 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -21,11 +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 .auth_provider import AppAuthProvider from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError from .routing.activity_context import ActivityContext @@ -47,6 +48,7 @@ def __init__( default_connection_name: str, http_client: Client, token_manager: TokenManager, + auth_provider: AppAuthProvider, api_client_settings: Optional[ApiClientSettings], cloud: CloudEnvironment = PUBLIC, ) -> None: @@ -56,6 +58,7 @@ 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.cloud = cloud @@ -90,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 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 ee9e372cd..9c05bc0eb 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -197,7 +197,12 @@ async def send( self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref - return await send_or_update_activity(self.api, activity, ref) + 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/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index fc0c856e8..db18e431f 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -380,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().""" @@ -424,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.""" diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index 4c6d57fac..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, @@ -802,6 +803,37 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st 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" + class TestAppInitialize: """Test cases for App.initialize() method.""" @@ -881,12 +913,55 @@ async def test_reply_with_three_args_constructs_threaded_id(self, started_app): 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.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") diff --git a/packages/apps/tests/test_app_oauth.py b/packages/apps/tests/test_app_oauth.py index c685a40d8..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,6 +446,7 @@ def processor(self, router): default_connection_name="graph", http_client=MagicMock(), token_manager=MagicMock(), + auth_provider=MagicMock(spec=AppAuthProvider), api_client_settings=None, cloud=PUBLIC, ) diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py index f946b00f2..fb01ccbc6 100644 --- a/packages/apps/tests/test_app_process.py +++ b/packages/apps/tests/test_app_process.py @@ -19,6 +19,7 @@ from microsoft_teams.apps import ActivityContext, ActivityEvent 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 @@ -42,6 +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_auth_provider = MagicMock(spec=AppAuthProvider) return ActivityProcessor( mock_activity_router, "id", @@ -49,6 +51,7 @@ def activity_processor(self, mock_http_client): "default_connection", mock_http_client, mock_token_manager, + mock_auth_provider, None, PUBLIC, ) @@ -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."""