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