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..ddd6c41f7 --- /dev/null +++ b/examples/agent365/README.md @@ -0,0 +1,36 @@ +# agent365 + +Demonstrates Agent 365 `AgentUserIdentity` support in reactive and proactive modes. + +## Reactive Echo + +`src/main.py` mimics the echo example. Incoming messages are handled normally, but when the inbound activity recipient has `role="agenticUser"`, `ctx.send()` and `ctx.reply()` send from that concrete `AgentUserIdentity` using the inbound activity's service URL. + +```bash +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/main.py +``` + +## Proactive AgentUserIdentity Send + +`src/proactive.py` mimics the proactive messaging example, but sends from a specific AgentUserIdentity. Supply the concrete agent identity app ID and agent user ID. + +```bash +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/proactive.py \ + \ + \ + +``` + +## Identity Model + +- `CLIENT_ID`: blueprint client/app ID. +- `agent_identity_app_id`: concrete agent identity app/client ID. +- `agent_user_id`: user-shaped account/persona object ID for the agent. 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..261d74acd --- /dev/null +++ b/examples/agent365/src/main.py @@ -0,0 +1,61 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +# Agent 365 Reactive Example +# ========================== +# This example echoes messages back from the concrete AgentUserIdentity in the inbound activity. + +import asyncio +import logging +import re + +from microsoft_teams.api import AdaptiveCardInvokeActivity, MessageActivity +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.api.models.adaptive_card import AdaptiveCardActionMessageResponse +from microsoft_teams.api.models.invoke_response import AdaptiveCardInvokeResponse +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 as the inbound agent user.""" + await ctx.reply("Hello! How can I assist you today?") + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + """Echo incoming messages as the inbound agent user.""" + logger.info(f"[Agent365 onMessage] Message received: {ctx.activity.text}") + logger.info(f"[Agent365 onMessage] From: {ctx.activity.from_}") + logger.info(f"[Agent365 onMessage] Agent user identity: {ctx.agent_user_identity}") + + await ctx.reply(TypingActivityInput()) + + 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}'") + + +@app.on_card_action_execute("ack_agent365_card") +async def handle_agent365_card_ack(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle the Action.Execute button from the proactive AgentUserIdentity card.""" + data = ctx.activity.value.action.data + logger.info(f"[Agent365 card] Acknowledged with data: {data}") + await ctx.send(f"Acknowledged Agent 365 card. Data: {data}") + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Acknowledged", + ) + + +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..d7812df56 --- /dev/null +++ b/examples/agent365/src/proactive.py @@ -0,0 +1,102 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +# Agent 365 Proactive Example +# =========================== +# This example sends proactive messages from a specific AgentUserIdentity. + +import argparse +import asyncio +import logging + +from microsoft_teams.apps import App +from microsoft_teams.cards import ActionSet, AdaptiveCard, ExecuteAction, SubmitData, TextBlock + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def send_proactive_message( + app: App, + conversation_id: str, + agent_identity_app_id: str, + agent_user_id: str, + message: str, +) -> None: + """Send a proactive message from an AgentUserIdentity.""" + agent_user_identity = app.get_agent_user_identity(agent_identity_app_id, agent_user_id) + logger.info(f"Sending proactive message as agent user: {agent_user_identity.id}") + logger.info(f"Message: {message}") + result = await app.send(conversation_id, agent_user_identity, message) + + logger.info(f"Message sent successfully. Activity ID: {result.id}") + + +async def send_proactive_card( + app: App, + conversation_id: str, + agent_identity_app_id: str, + agent_user_id: str, +) -> None: + """Send a proactive Adaptive Card from an AgentUserIdentity.""" + agent_user_identity = app.get_agent_user_identity(agent_identity_app_id, agent_user_id) + card = AdaptiveCard( + schema="http://adaptivecards.io/schemas/adaptive-card.json", + body=[ + TextBlock(text="Agent 365 Notification", size="Large", weight="Bolder"), + TextBlock(text="This message was sent proactively from an AgentUserIdentity.", wrap=True), + TextBlock(text=f"Agent user: {agent_user_identity.id}", wrap=True, is_subtle=True), + ActionSet( + actions=[ + ExecuteAction(title="Acknowledge") + .with_data(SubmitData("ack_agent365_card", {"agent_user_id": agent_user_identity.id})) + .with_associated_inputs("auto") + ] + ), + ], + ) + + logger.info(f"Sending proactive card as agent user: {agent_user_identity.id}") + + result = await app.send(conversation_id, agent_user_identity, card) + + logger.info(f"Card sent successfully. Activity ID: {result.id}") + + +async def main(): + parser = argparse.ArgumentParser(description="Send proactive messages from an Agent 365 AgentUserIdentity") + parser.add_argument("conversation_id", help="The Teams conversation ID to send messages to") + parser.add_argument("agent_identity_app_id", help="The concrete agent identity app/client ID") + parser.add_argument("agent_user_id", help="The agent user object ID") + args = parser.parse_args() + + app = App() + + logger.info("Initializing app without starting server...") + await app.initialize() + logger.info("App initialized") + + await send_proactive_message( + app, + args.conversation_id, + args.agent_identity_app_id, + args.agent_user_id, + "Hello! This is a proactive message sent from an AgentUserIdentity.", + ) + + await asyncio.sleep(2) + + await send_proactive_card( + app, + args.conversation_id, + args.agent_identity_app_id, + args.agent_user_id, + ) + + logger.info("All proactive AgentUserIdentity messages sent successfully") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/api/src/microsoft_teams/api/models/account.py b/packages/api/src/microsoft_teams/api/models/account.py index 9c58c8f2b..2489bd111 100644 --- a/packages/api/src/microsoft_teams/api/models/account.py +++ b/packages/api/src/microsoft_teams/api/models/account.py @@ -10,6 +10,7 @@ from .custom_base_model import CustomBaseModel AccountType = Literal["person", "tag", "channel", "team", "bot"] +AccountRole = Literal["agenticUser"] class Account(CustomBaseModel): @@ -44,6 +45,18 @@ 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.""" class TeamsChannelAccount(CustomBaseModel): diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml index c27b9db69..6a2b33c7f 100644 --- a/packages/apps/pyproject.toml +++ b/packages/apps/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "cryptography>=3.4.0", "pyjwt[crypto]>=2.12.0", "dependency-injector>=4.48.1", - "msal>=1.33.0", + "msal>=1.37.0", "python-dotenv>=1.0.0", "pydantic-settings>=2.11.0", ] diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 54ad5b922..8181a9df5 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -6,6 +6,7 @@ import logging from . import auth, contexts, events, plugins +from .agent_user import AgentUserIdentity from .app import App from .auth import * # noqa: F403 from .contexts import * # noqa: F403 @@ -23,6 +24,7 @@ __all__: list[str] = [ "App", "AppOptions", + "AgentUserIdentity", "HttpServer", "HttpServerAdapter", "FastAPIAdapter", diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py index 1ab97c96c..18c4835a1 100644 --- a/packages/apps/src/microsoft_teams/apps/activity_sender.py +++ b/packages/apps/src/microsoft_teams/apps/activity_sender.py @@ -36,13 +36,13 @@ def __init__(self, client: Client): """ self._client = client - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: + async def send(self, ref: ConversationReference, activity: ActivityParams) -> SentActivity: """ Send an activity to the Bot Framework. Args: - activity: The activity to send ref: The conversation reference + activity: The activity to send Returns: The sent activity with id and other server-populated fields diff --git a/packages/apps/src/microsoft_teams/apps/agent_user.py b/packages/apps/src/microsoft_teams/apps/agent_user.py new file mode 100644 index 000000000..41b62c387 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/agent_user.py @@ -0,0 +1,18 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgentUserIdentity: + """Identifies an Agent ID user-shaped identity and its backing agent app.""" + + id: str + agent_identity_app_id: str + tenant_id: str + + +__all__ = ["AgentUserIdentity"] diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..d913409a6 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -36,6 +36,7 @@ from msgraph.graph_service_client import GraphServiceClient from .activity_sender import ActivitySender +from .agent_user import AgentUserIdentity from .app_events import EventManager from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor @@ -290,7 +291,27 @@ 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): + @overload + async def send( + self, + conversation_id: str, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + @overload + async def send( + self, + conversation_id: str, + from_: AgentUserIdentity, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + async def send( # type: ignore[reportInconsistentOverload] + self, + conversation_id: str, + from_or_activity: AgentUserIdentity | str | ActivityParams | AdaptiveCard, + activity: str | ActivityParams | AdaptiveCard | None = None, + ) -> SentActivity: """Send an activity proactively to a conversation. Sends to the exact conversation ID provided. For channel threads, @@ -301,29 +322,113 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap if not self._initialized: raise ValueError("app not initialized - call app.initialize() or app.start() first") - if self.id is None: - raise ValueError("app credentials not configured") + if isinstance(from_or_activity, AgentUserIdentity): + from_ = from_or_activity + activity_params = activity + if activity_params is None: + raise TypeError("activity is required when sending as an agent user") + else: + from_ = None + activity_params = from_or_activity conversation_ref = ConversationReference( channel_id="msteams", service_url=self.api.service_url, - bot=Account(id=self.id), + bot=self._get_outbound_account(from_), conversation=ConversationAccount(id=conversation_id), ) - if isinstance(activity, str): - activity = MessageActivityInput(text=activity) - elif isinstance(activity, AdaptiveCard): - activity = MessageActivityInput().add_card(activity) + if isinstance(activity_params, str): + activity_params = MessageActivityInput(text=activity_params) + elif isinstance(activity_params, AdaptiveCard): + activity_params = MessageActivityInput().add_card(activity_params) else: - activity = activity + activity_params = activity_params + + return await self._get_activity_sender(from_).send(conversation_ref, activity_params) + + def get_agent_user_identity( + self, + agent_identity_app_id: str, + agent_user_id: str, + *, + tenant_id: Optional[str] = None, + ) -> AgentUserIdentity: + """Get an Agent ID user identity.""" + 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 agent user") + + return AgentUserIdentity( + id=agent_user_id, + agent_identity_app_id=agent_identity_app_id, + tenant_id=resolved_tenant_id, + ) + + def get_scoped_api(self, agent_user: AgentUserIdentity, service_url: Optional[str] = None) -> ApiClient: + """Get a Teams API client scoped to an Agent ID user identity.""" + return ApiClient( + service_url or self.api.service_url, + self.http_client.clone(ClientOptions(token=lambda: self._get_agent_bot_token(agent_user))), + self.options.api_client_settings, + cloud=self.cloud, + ) + + def get_scoped_graph(self, agent_user: AgentUserIdentity) -> "GraphServiceClient": + """Get a Graph client scoped to an Agent ID user identity.""" + return create_graph_client(lambda: self._get_agent_graph_token(agent_user), cloud=self.cloud) - return await self.activity_sender.send(activity, conversation_ref) + def _get_outbound_account(self, from_: AgentUserIdentity | None = None) -> Account: + if from_ is None: + if self.id is None: + raise ValueError("app credentials not configured") + return Account(id=self.id) + + return Account(id=from_.id if from_.id.startswith("8:") else f"8:orgid:{from_.id}") + + def _get_activity_sender(self, from_: AgentUserIdentity | None = None) -> ActivitySender: + if from_ is None: + return self.activity_sender + + return ActivitySender(self.http_client.clone(ClientOptions(token=lambda: self._get_agent_bot_token(from_)))) + + async def _get_agent_bot_token(self, agent_user: AgentUserIdentity) -> Optional[TokenProtocol]: + return await self._token_manager.get_agent_bot_token( + agent_user.tenant_id, + agent_user.agent_identity_app_id, + agent_user_id=agent_user.id, + caller_name="get_agent_bot_token", + ) + + async def _get_agent_graph_token(self, agent_user: AgentUserIdentity) -> Optional[TokenProtocol]: + return await self._token_manager.get_agent_user_token( + agent_user.tenant_id, + agent_user.agent_identity_app_id, + self.cloud.graph_scope, + agent_user_id=agent_user.id, + caller_name="get_agent_graph_token", + ) + + @overload + async def reply( + self, + conversation_id: str, + message_id: str, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... @overload async def reply( self, conversation_id: str, + message_id: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + @overload + async def reply( + self, + conversation_id: str, + from_: AgentUserIdentity, message_id: str, activity: str | ActivityParams | AdaptiveCard, ) -> SentActivity: ... @@ -332,13 +437,15 @@ async def reply( async def reply( self, conversation_id: str, + from_: AgentUserIdentity, message_id: str | ActivityParams | AdaptiveCard, ) -> SentActivity: ... async def reply( # type: ignore[reportInconsistentOverload] self, conversation_id: str, - message_id: str | ActivityParams | AdaptiveCard = "", + from_or_message_id: AgentUserIdentity | str | ActivityParams | AdaptiveCard = "", + message_id_or_activity: str | ActivityParams | AdaptiveCard | None = None, activity: str | ActivityParams | AdaptiveCard | None = None, ) -> SentActivity: """Send an activity proactively to a conversation, optionally as a threaded reply. @@ -357,12 +464,26 @@ async def reply( # type: ignore[reportInconsistentOverload] message_id: The thread root message ID (3-arg form) or the activity (2-arg form) activity: The activity to send (only in 3-arg form) """ - if activity is not None: - if not isinstance(message_id, str): + if isinstance(from_or_message_id, AgentUserIdentity): + if activity is not None: + if not isinstance(message_id_or_activity, 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_or_activity), from_or_message_id, activity + ) + + if message_id_or_activity is None: + raise TypeError("activity is required when replying as an agent user") + return await self.send(conversation_id, from_or_message_id, message_id_or_activity) + + if message_id_or_activity is not None: + if not isinstance(from_or_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, from_or_message_id), message_id_or_activity + ) - return await self.send(conversation_id, message_id) + return await self.send(conversation_id, from_or_message_id) def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None: """Add middleware to run on all activities.""" diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 1b2e9f27a..5f6d3a686 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -129,6 +129,7 @@ async def _build_context( self.default_connection_name, activity_sender=self.activity_sender, app_token=lambda: self.token_manager.get_graph_token(tenant_id), + token_manager=self.token_manager, 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..8105e2443 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -34,6 +34,10 @@ class JwtValidationOptions: """ Optional scope that must be present in the token """ clock_tolerance: int = JWT_LEEWAY_SECONDS """ Allowable clock skew when validating JWTs """ + app_id: Optional[str] = None + """ Optional app ID used to select per-token validators for inbound activities """ + cloud: CloudEnvironment = PUBLIC + """ Cloud environment used to select issuer and JWKS endpoints """ class TokenValidator: @@ -83,6 +87,8 @@ def for_service( valid_audiences=cls._default_audiences(app_id), jwks_uri=jwks_keys_uri, service_url=service_url, + app_id=app_id, + cloud=env, ) return cls(options) @@ -128,6 +134,8 @@ def for_entra( valid_audiences=valid_audiences, jwks_uri=f"{env.login_endpoint}/{tenant_id}/discovery/v2.0/keys", scope=scope, + app_id=app_id, + cloud=env, ) return cls(options) @@ -222,3 +230,54 @@ 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]: + unverified_payload = jwt.decode(raw_token, 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("https://sts.windows.net/") + + 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 + return validator 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..f34a2825c 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -79,7 +79,7 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[ else: activity = activity - return await self.activity_sender.send(activity, conversation_ref) + return await self.activity_sender.send(conversation_ref, activity) 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/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 6c70ceecd..10006d1b7 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -37,11 +37,13 @@ ) from microsoft_teams.api.models.oauth import OAuthCard from microsoft_teams.cards import AdaptiveCard -from microsoft_teams.common import Storage +from microsoft_teams.common import ClientOptions, Storage from microsoft_teams.common.experimental import ExperimentalWarning, experimental from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender +from ..agent_user import AgentUserIdentity +from ..token_manager import TokenManager from ..utils import create_graph_client if TYPE_CHECKING: @@ -88,6 +90,7 @@ def __init__( connection_name: str, activity_sender: ActivitySender, app_token: Token, + token_manager: Optional[TokenManager] = None, cloud: CloudEnvironment = PUBLIC, ): self.activity = activity @@ -102,6 +105,8 @@ def __init__( self.cloud = cloud self._activity_sender = activity_sender self._app_token = app_token + self._token_manager = token_manager + self.agent_user_identity = self._get_agent_user_identity() self.stream = activity_sender.create_stream(conversation_ref) self._next_handler: Optional[Callable[[], Awaitable[None]]] = None @@ -191,7 +196,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) + if self.agent_user_identity is not None: + ref = ref.model_copy(deep=True) + ref.bot = self._get_agent_user_account(self.agent_user_identity) + return await self._get_agent_user_sender(self.agent_user_identity).send(ref, activity) + + res = await self._activity_sender.send(ref, activity) return res async def reply(self, input: str | ActivityParams) -> SentActivity: @@ -246,6 +256,44 @@ def _incoming_targeted_sender(self) -> Optional[Account]: return self.activity.from_ + def _get_agent_user_identity(self) -> Optional[AgentUserIdentity]: + recipient = getattr(self.activity, "recipient", None) + if recipient is None or recipient.role != "agenticUser": + return None + + if not recipient.agentic_user_id: + raise ValueError("agenticUser recipient is missing agenticUserId") + if not recipient.agentic_app_id: + raise ValueError("agenticUser recipient is missing agenticAppId") + + tenant_id = recipient.tenant_id or getattr(self.activity.conversation, "tenant_id", None) + if not tenant_id: + raise ValueError("agenticUser recipient is missing tenantId") + + return AgentUserIdentity( + id=recipient.agentic_user_id, + agent_identity_app_id=recipient.agentic_app_id, + tenant_id=tenant_id, + ) + + def _get_agent_user_account(self, agent_user: AgentUserIdentity) -> Account: + return Account(id=agent_user.id if agent_user.id.startswith("8:") else f"8:orgid:{agent_user.id}") + + def _get_agent_user_sender(self, agent_user: AgentUserIdentity) -> ActivitySender: + if self._token_manager is None: + raise ValueError("token_manager is required for agenticUser activities") + token_manager = self._token_manager + + async def get_token(): + return await token_manager.get_agent_bot_token( + agent_user.tenant_id, + agent_user.agent_identity_app_id, + agent_user_id=agent_user.id, + caller_name="get_agent_bot_token", + ) + + return ActivitySender(self.api.http.clone(ClientOptions(token=get_token))) + def _should_outbound_be_auto_targeted( self, activity: ActivityParams, diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index e4c3525f1..feddfccb8 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -6,7 +6,7 @@ import asyncio import logging from inspect import isawaitable -from typing import Any, Optional +from typing import Any, Callable, Optional import requests from microsoft_teams.api import ( @@ -29,6 +29,8 @@ ) DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common" +TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default" +AGENT_BOT_API_SCOPE = "https://botapi.skype.com/.default" logger = logging.getLogger(__name__) @@ -45,6 +47,7 @@ def __init__( self._cloud = cloud or PUBLIC self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} self._federated_identity_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} + self._agent_identity_clients_by_tenant_and_app_id: dict[tuple[str, str], ConfidentialClientApplication] = {} self._managed_identity_client: Optional[ManagedIdentityClient] = None async def get_bot_token(self) -> Optional[TokenProtocol]: @@ -68,6 +71,89 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) ) + async def get_agent_bot_token( + self, + tenant_id: str, + agent_identity_app_id: str, + *, + agent_user_id: str | None = None, + agent_user_upn: str | None = None, + caller_name: str | None = None, + ) -> Optional[TokenProtocol]: + """Get a Teams bot API token for an agent identity acting through its agent user.""" + return await self.get_agent_user_token( + tenant_id, + agent_identity_app_id, + AGENT_BOT_API_SCOPE, + agent_user_id=agent_user_id, + agent_user_upn=agent_user_upn, + caller_name=caller_name, + ) + + async def get_agent_user_token( + self, + tenant_id: str, + agent_identity_app_id: str, + scope: str, + *, + agent_user_id: str | None = None, + agent_user_upn: str | None = None, + caller_name: str | None = None, + ) -> Optional[TokenProtocol]: + """Get a resource token for an agent identity acting through its agent user.""" + if not agent_user_id and not agent_user_upn: + raise ValueError("Either agent_user_id or agent_user_upn must be provided") + if agent_user_id and agent_user_upn: + raise ValueError("agent_user_id and agent_user_upn are mutually exclusive") + if self._credentials is None: + if caller_name: + logger.debug(f"No credentials provided for {caller_name}") + return None + + credentials = self._credentials + if not isinstance(credentials, ClientCredentials): + raise ValueError("Agent user tokens require ClientCredentials") + confidential_client = self._get_confidential_client(credentials, tenant_id) + + def get_t1_assertion(_context: dict[str, Any]) -> str: + t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client( + [TOKEN_EXCHANGE_SCOPE], fmi_path=agent_identity_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, + agent_identity_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=agent_user_id, + username=agent_user_upn, + data={"requested_token_use": "on_behalf_of"}, + ) + ) + return self._handle_token_response(t3_raw, caller_name or "get_agent_user_token") + + def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: str) -> str: + if token_res.get("access_token", None): + return token_res["access_token"] + + error_description = token_res.get("error_description") or token_res.get("error") or "Could not acquire token" + logger.error(f"{error_prefix}: {error_description}") + raise ValueError(f"{error_prefix}: {error_description}") + async def _get_token( self, scope: str, tenant_id: str, *, caller_name: str | None = None ) -> Optional[TokenProtocol]: @@ -220,6 +306,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, + agent_identity_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, agent_identity_app_id)) + if cached_client: + return cached_client + + client: ConfidentialClientApplication = ConfidentialClientApplication( + agent_identity_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, agent_identity_app_id)] = client + return client + def _get_managed_identity_client( self, credentials: ManagedIdentityCredentials | FederatedIdentityCredentials ) -> ManagedIdentityClient: 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 7a2693d06..44fe0682e 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,7 @@ resolution-markers = [ [manifest] members = [ "a2a", + "agent365", "ai-agentframework", "botbuilder", "cards", @@ -129,6 +130,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" @@ -1818,7 +1834,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" }, @@ -1931,16 +1947,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]]