From 5eeaa31720737b21e7421e13cf3b9908ceb3e871 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 10 Jun 2026 18:52:39 -0700 Subject: [PATCH 1/2] Add AgentUser app model --- examples/agent365/README.md | 37 +++--- examples/agent365/src/main.py | 78 ++++++------ examples/agent365/src/proactive.py | 103 ++++++++++++++++ .../src/microsoft_teams/api/models/account.py | 13 ++ .../apps/src/microsoft_teams/apps/__init__.py | 3 + .../microsoft_teams/apps/activity_sender.py | 4 +- .../src/microsoft_teams/apps/agent_user.py | 113 ++++++++++++++++++ packages/apps/src/microsoft_teams/apps/app.py | 26 +++- .../src/microsoft_teams/apps/app_process.py | 1 + .../apps/contexts/function_context.py | 2 +- .../apps/routing/activity_context.py | 37 +++++- 11 files changed, 361 insertions(+), 56 deletions(-) create mode 100644 examples/agent365/src/proactive.py create mode 100644 packages/apps/src/microsoft_teams/apps/agent_user.py diff --git a/examples/agent365/README.md b/examples/agent365/README.md index c3164c58a..6a8e57982 100644 --- a/examples/agent365/README.md +++ b/examples/agent365/README.md @@ -1,31 +1,36 @@ # agent365 -Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user. +Demonstrates Agent 365 `AgentUser` support in reactive and proactive modes. -## Run +## Reactive Echo -Set these environment variables or add them to `.env`: +`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 as that concrete `AgentUser` using the inbound activity's service URL. ```bash -AGENT365_TENANT_ID= -AGENT365_BLUEPRINT_CLIENT_ID= -AGENT365_BLUEPRINT_CLIENT_SECRET= -AGENT365_AGENT_IDENTITY_APP_ID= -AGENT365_AGENT_USER_ID= -``` - -Then run: +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= -```bash uv run --project examples/agent365 python src/main.py ``` -By default this requests a Teams bot API token for `https://botapi.skype.com/.default`. +## Proactive AgentUser Send -To request another resource, set `AGENT365_SCOPE`, for example: +`src/proactive.py` mimics the proactive messaging example, but sends as a specific AgentUser. Supply the concrete agent identity app ID and agent user ID. ```bash -AGENT365_SCOPE=https://graph.microsoft.com/.default +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/proactive.py \ + \ + \ + ``` -You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`. +## 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/src/main.py b/examples/agent365/src/main.py index 98ddca0d2..81fae9b0f 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -3,51 +3,59 @@ Licensed under the MIT License. """ +# Agent 365 Reactive Example +# ========================== +# This example echoes messages back as the concrete AgentUser from the inbound activity. + import asyncio -import os +import logging +import re -from dotenv import load_dotenv -from microsoft_teams.api import ClientCredentials -from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager +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__) -def get_required_env(name: str) -> str: - value = os.getenv(name) - if not value: - raise ValueError(f"{name} must be set") +app = App() - return value +@app.on_message_pattern(re.compile(r"hello|hi|greetings")) +async def handle_greeting(ctx: ActivityContext[MessageActivity]) -> None: + """Handle greeting messages as the inbound agent user.""" + await ctx.reply("Hello! How can I assist you today?") -async def main(): - load_dotenv() - tenant_id = get_required_env("AGENT365_TENANT_ID") - blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") - blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") - agent_identity_app_id = get_required_env("AGENT365_AGENT_IDENTITY_APP_ID") - agent_user_id = os.getenv("AGENT365_AGENT_USER_ID") - agent_user_upn = os.getenv("AGENT365_AGENT_USER_UPN") - scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) +@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: {ctx.agent_user}") - credentials = ClientCredentials( - client_id=blueprint_client_id, - client_secret=blueprint_client_secret, - tenant_id=tenant_id, - ) - token_manager = TokenManager(credentials=credentials) - - token = await token_manager.get_agent_user_token( - tenant_id, - agent_identity_app_id, - scope, - agent_user_id=agent_user_id, - agent_user_upn=agent_user_upn, - ) + await ctx.reply(TypingActivityInput()) - print(f"Acquired agent user token for {scope}") - print(f"Token preview: {str(token)[:20]}...") + if "reply" in ctx.activity.text.lower(): + await ctx.reply("Hello! How can I assist you today?") + else: + await ctx.send(f"You said '{ctx.activity.text}'") + + +@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 AgentUser 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(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..e1201bb8a --- /dev/null +++ b/examples/agent365/src/proactive.py @@ -0,0 +1,103 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +# Agent 365 Proactive Example +# =========================== +# This example sends proactive messages as a specific AgentUser. + +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 as an AgentUser.""" + agent_user = app.get_agent_user(agent_identity_app_id, agent_user_id) + logger.info(f"Sending proactive message as agent user: {agent_user.id}") + logger.info(f"Message: {message}") + + result = await agent_user.send(conversation_id, 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 as an AgentUser.""" + agent_user = app.get_agent_user(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 as an AgentUser.", wrap=True), + TextBlock(text=f"Agent user: {agent_user.id}", wrap=True, is_subtle=True), + ActionSet( + actions=[ + ExecuteAction(title="Acknowledge") + .with_data(SubmitData("ack_agent365_card", {"agent_user_id": agent_user.id})) + .with_associated_inputs("auto") + ] + ), + ], + ) + + logger.info(f"Sending proactive card as agent user: {agent_user.id}") + + result = await agent_user.send(conversation_id, card) + + logger.info(f"Card sent successfully. Activity ID: {result.id}") + + +async def main(): + parser = argparse.ArgumentParser(description="Send proactive messages as an Agent 365 AgentUser") + 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 as an AgentUser.", + ) + + 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 AgentUser 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/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 54ad5b922..90a387419 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 AgentUser, AgentUserIdentity from .app import App from .auth import * # noqa: F403 from .contexts import * # noqa: F403 @@ -23,6 +24,8 @@ __all__: list[str] = [ "App", "AppOptions", + "AgentUser", + "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..9d73b4ad7 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/agent_user.py @@ -0,0 +1,113 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass + +from microsoft_teams.api import ( + Account, + ActivityParams, + ConversationAccount, + ConversationReference, + MessageActivityInput, + SentActivity, +) +from microsoft_teams.cards import AdaptiveCard +from microsoft_teams.common import Client, ClientOptions + +from .activity_sender import ActivitySender +from .token_manager import TokenManager +from .utils.thread import to_threaded_conversation_id + + +@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 + + +class AgentUser: + """A sendable Agent ID user identity.""" + + def __init__( + self, + identity: AgentUserIdentity, + *, + service_url: str, + http_client: Client, + token_manager: TokenManager, + ): + self.identity = identity + self._service_url = service_url.rstrip("/") + self._activity_sender = ActivitySender(http_client.clone(ClientOptions(token=self._get_bot_token))) + self._token_manager = token_manager + + @property + def id(self) -> str: + return self.identity.id + + @property + def agent_identity_app_id(self) -> str: + return self.identity.agent_identity_app_id + + @property + def tenant_id(self) -> str: + return self.identity.tenant_id + + async def send( + self, + conversation: str | ConversationReference, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: + """Send an activity as this agent user.""" + if isinstance(conversation, str): + ref = ConversationReference( + channel_id="msteams", + service_url=self._service_url, + bot=Account(id=self._channel_account_id), + conversation=ConversationAccount(id=conversation), + ) + else: + ref = conversation.model_copy(deep=True) + + ref.bot = Account(id=self._channel_account_id) + return await self._activity_sender.send(ref, self._coerce_activity(activity)) + + @property + def _channel_account_id(self) -> str: + return self.id if self.id.startswith("8:") else f"8:orgid:{self.id}" + + async def _get_bot_token(self): + return await self._token_manager.get_agent_bot_token( + self.identity.tenant_id, + self.identity.agent_identity_app_id, + agent_user_id=self.identity.id, + ) + + async def reply( + self, + conversation_id: str, + message_id: str | ActivityParams | AdaptiveCard = "", + activity: str | ActivityParams | AdaptiveCard | None = None, + ) -> SentActivity: + """Send as this agent user, optionally to a threaded reply target.""" + 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(conversation_id, message_id) + + def _coerce_activity(self, activity: str | ActivityParams | AdaptiveCard) -> ActivityParams: + if isinstance(activity, str): + return MessageActivityInput(text=activity) + if isinstance(activity, AdaptiveCard): + return MessageActivityInput().add_card(activity) + return activity + + +__all__ = ["AgentUser", "AgentUserIdentity"] diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..51667062a 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 AgentUser, AgentUserIdentity from .app_events import EventManager from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor @@ -318,7 +319,30 @@ 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 self.activity_sender.send(conversation_ref, activity) + + def get_agent_user( + self, + agent_identity_app_id: str, + agent_user_id: str, + *, + tenant_id: Optional[str] = None, + ) -> AgentUser: + """Get a sendable handle for 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 AgentUser( + AgentUserIdentity( + id=agent_user_id, + agent_identity_app_id=agent_identity_app_id, + tenant_id=resolved_tenant_id, + ), + service_url=self.api.service_url, + http_client=self.http_client, + token_manager=self._token_manager, + ) @overload async def reply( 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/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/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 6c70ceecd..49cec0f27 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -42,6 +42,8 @@ from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender +from ..agent_user import AgentUser, 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 = self._get_agent_user() self.stream = activity_sender.create_stream(conversation_ref) self._next_handler: Optional[Callable[[], Awaitable[None]]] = None @@ -191,7 +196,10 @@ 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 is not None: + return await self.agent_user.send(ref, activity) + + res = await self._activity_sender.send(ref, activity) return res async def reply(self, input: str | ActivityParams) -> SentActivity: @@ -246,6 +254,33 @@ def _incoming_targeted_sender(self) -> Optional[Account]: return self.activity.from_ + def _get_agent_user(self) -> Optional[AgentUser]: + recipient = getattr(self.activity, "recipient", None) + if recipient is None or recipient.role != "agenticUser": + return None + + if self._token_manager is None: + raise ValueError("token_manager is required for agenticUser activities") + 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 AgentUser( + AgentUserIdentity( + id=recipient.agentic_user_id, + agent_identity_app_id=recipient.agentic_app_id, + tenant_id=tenant_id, + ), + service_url=self.conversation_ref.service_url, + http_client=self.api.http, + token_manager=self._token_manager, + ) + def _should_outbound_be_auto_targeted( self, activity: ActivityParams, From 82c6095e3ac7501a97ebf7167982eb00e3794b6b Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 12 Jun 2026 14:58:42 -0700 Subject: [PATCH 2/2] Use AgentUserIdentity for scoped sends --- examples/agent365/README.md | 8 +- examples/agent365/src/main.py | 6 +- examples/agent365/src/proactive.py | 31 ++-- .../apps/src/microsoft_teams/apps/__init__.py | 3 +- .../src/microsoft_teams/apps/agent_user.py | 97 +---------- packages/apps/src/microsoft_teams/apps/app.py | 153 ++++++++++++++---- .../apps/routing/activity_context.py | 47 ++++-- 7 files changed, 179 insertions(+), 166 deletions(-) diff --git a/examples/agent365/README.md b/examples/agent365/README.md index 6a8e57982..ddd6c41f7 100644 --- a/examples/agent365/README.md +++ b/examples/agent365/README.md @@ -1,10 +1,10 @@ # agent365 -Demonstrates Agent 365 `AgentUser` support in reactive and proactive modes. +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 as that concrete `AgentUser` using the inbound activity's service URL. +`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= @@ -14,9 +14,9 @@ export TENANT_ID= uv run --project examples/agent365 python src/main.py ``` -## Proactive AgentUser Send +## Proactive AgentUserIdentity Send -`src/proactive.py` mimics the proactive messaging example, but sends as a specific AgentUser. Supply the concrete agent identity app ID and agent user ID. +`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= diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 81fae9b0f..261d74acd 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -5,7 +5,7 @@ # Agent 365 Reactive Example # ========================== -# This example echoes messages back as the concrete AgentUser from the inbound activity. +# This example echoes messages back from the concrete AgentUserIdentity in the inbound activity. import asyncio import logging @@ -34,7 +34,7 @@ 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: {ctx.agent_user}") + logger.info(f"[Agent365 onMessage] Agent user identity: {ctx.agent_user_identity}") await ctx.reply(TypingActivityInput()) @@ -46,7 +46,7 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): @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 AgentUser card.""" + """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}") diff --git a/examples/agent365/src/proactive.py b/examples/agent365/src/proactive.py index e1201bb8a..d7812df56 100644 --- a/examples/agent365/src/proactive.py +++ b/examples/agent365/src/proactive.py @@ -5,7 +5,7 @@ # Agent 365 Proactive Example # =========================== -# This example sends proactive messages as a specific AgentUser. +# This example sends proactive messages from a specific AgentUserIdentity. import argparse import asyncio @@ -25,12 +25,11 @@ async def send_proactive_message( agent_user_id: str, message: str, ) -> None: - """Send a proactive message as an AgentUser.""" - agent_user = app.get_agent_user(agent_identity_app_id, agent_user_id) - logger.info(f"Sending proactive message as agent user: {agent_user.id}") + """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 agent_user.send(conversation_id, message) + result = await app.send(conversation_id, agent_user_identity, message) logger.info(f"Message sent successfully. Activity ID: {result.id}") @@ -41,33 +40,33 @@ async def send_proactive_card( agent_identity_app_id: str, agent_user_id: str, ) -> None: - """Send a proactive Adaptive Card as an AgentUser.""" - agent_user = app.get_agent_user(agent_identity_app_id, agent_user_id) + """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 as an AgentUser.", wrap=True), - TextBlock(text=f"Agent user: {agent_user.id}", wrap=True, is_subtle=True), + 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.id})) + .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.id}") + logger.info(f"Sending proactive card as agent user: {agent_user_identity.id}") - result = await agent_user.send(conversation_id, card) + 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 as an Agent 365 AgentUser") + 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") @@ -84,7 +83,7 @@ async def main(): args.conversation_id, args.agent_identity_app_id, args.agent_user_id, - "Hello! This is a proactive message sent as an AgentUser.", + "Hello! This is a proactive message sent from an AgentUserIdentity.", ) await asyncio.sleep(2) @@ -96,7 +95,7 @@ async def main(): args.agent_user_id, ) - logger.info("All proactive AgentUser messages sent successfully") + logger.info("All proactive AgentUserIdentity messages sent successfully") if __name__ == "__main__": diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 90a387419..8181a9df5 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -6,7 +6,7 @@ import logging from . import auth, contexts, events, plugins -from .agent_user import AgentUser, AgentUserIdentity +from .agent_user import AgentUserIdentity from .app import App from .auth import * # noqa: F403 from .contexts import * # noqa: F403 @@ -24,7 +24,6 @@ __all__: list[str] = [ "App", "AppOptions", - "AgentUser", "AgentUserIdentity", "HttpServer", "HttpServerAdapter", diff --git a/packages/apps/src/microsoft_teams/apps/agent_user.py b/packages/apps/src/microsoft_teams/apps/agent_user.py index 9d73b4ad7..41b62c387 100644 --- a/packages/apps/src/microsoft_teams/apps/agent_user.py +++ b/packages/apps/src/microsoft_teams/apps/agent_user.py @@ -5,21 +5,6 @@ from dataclasses import dataclass -from microsoft_teams.api import ( - Account, - ActivityParams, - ConversationAccount, - ConversationReference, - MessageActivityInput, - SentActivity, -) -from microsoft_teams.cards import AdaptiveCard -from microsoft_teams.common import Client, ClientOptions - -from .activity_sender import ActivitySender -from .token_manager import TokenManager -from .utils.thread import to_threaded_conversation_id - @dataclass(frozen=True) class AgentUserIdentity: @@ -30,84 +15,4 @@ class AgentUserIdentity: tenant_id: str -class AgentUser: - """A sendable Agent ID user identity.""" - - def __init__( - self, - identity: AgentUserIdentity, - *, - service_url: str, - http_client: Client, - token_manager: TokenManager, - ): - self.identity = identity - self._service_url = service_url.rstrip("/") - self._activity_sender = ActivitySender(http_client.clone(ClientOptions(token=self._get_bot_token))) - self._token_manager = token_manager - - @property - def id(self) -> str: - return self.identity.id - - @property - def agent_identity_app_id(self) -> str: - return self.identity.agent_identity_app_id - - @property - def tenant_id(self) -> str: - return self.identity.tenant_id - - async def send( - self, - conversation: str | ConversationReference, - activity: str | ActivityParams | AdaptiveCard, - ) -> SentActivity: - """Send an activity as this agent user.""" - if isinstance(conversation, str): - ref = ConversationReference( - channel_id="msteams", - service_url=self._service_url, - bot=Account(id=self._channel_account_id), - conversation=ConversationAccount(id=conversation), - ) - else: - ref = conversation.model_copy(deep=True) - - ref.bot = Account(id=self._channel_account_id) - return await self._activity_sender.send(ref, self._coerce_activity(activity)) - - @property - def _channel_account_id(self) -> str: - return self.id if self.id.startswith("8:") else f"8:orgid:{self.id}" - - async def _get_bot_token(self): - return await self._token_manager.get_agent_bot_token( - self.identity.tenant_id, - self.identity.agent_identity_app_id, - agent_user_id=self.identity.id, - ) - - async def reply( - self, - conversation_id: str, - message_id: str | ActivityParams | AdaptiveCard = "", - activity: str | ActivityParams | AdaptiveCard | None = None, - ) -> SentActivity: - """Send as this agent user, optionally to a threaded reply target.""" - 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(conversation_id, message_id) - - def _coerce_activity(self, activity: str | ActivityParams | AdaptiveCard) -> ActivityParams: - if isinstance(activity, str): - return MessageActivityInput(text=activity) - if isinstance(activity, AdaptiveCard): - return MessageActivityInput().add_card(activity) - return activity - - -__all__ = ["AgentUser", "AgentUserIdentity"] +__all__ = ["AgentUserIdentity"] diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 51667062a..d913409a6 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -36,7 +36,7 @@ from msgraph.graph_service_client import GraphServiceClient from .activity_sender import ActivitySender -from .agent_user import AgentUser, AgentUserIdentity +from .agent_user import AgentUserIdentity from .app_events import EventManager from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor @@ -291,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, @@ -302,46 +322,91 @@ 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.activity_sender.send(conversation_ref, activity) + return await self._get_activity_sender(from_).send(conversation_ref, activity_params) - def get_agent_user( + def get_agent_user_identity( self, agent_identity_app_id: str, agent_user_id: str, *, tenant_id: Optional[str] = None, - ) -> AgentUser: - """Get a sendable handle for an Agent ID user identity.""" + ) -> 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 AgentUser( - AgentUserIdentity( - id=agent_user_id, - agent_identity_app_id=agent_identity_app_id, - tenant_id=resolved_tenant_id, - ), - service_url=self.api.service_url, - http_client=self.http_client, - token_manager=self._token_manager, + 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) + + 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 @@ -359,10 +424,28 @@ async def reply( message_id: str | ActivityParams | AdaptiveCard, ) -> SentActivity: ... + @overload + async def reply( + self, + conversation_id: str, + from_: AgentUserIdentity, + message_id: str, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + @overload + 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. @@ -381,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/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 49cec0f27..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,12 +37,12 @@ ) 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 AgentUser, AgentUserIdentity +from ..agent_user import AgentUserIdentity from ..token_manager import TokenManager from ..utils import create_graph_client @@ -106,7 +106,7 @@ def __init__( self._activity_sender = activity_sender self._app_token = app_token self._token_manager = token_manager - self.agent_user = self._get_agent_user() + 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 @@ -196,8 +196,10 @@ async def send( self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref - if self.agent_user is not None: - return await self.agent_user.send(ref, activity) + 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 @@ -254,13 +256,11 @@ def _incoming_targeted_sender(self) -> Optional[Account]: return self.activity.from_ - def _get_agent_user(self) -> Optional[AgentUser]: + 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 self._token_manager is None: - raise ValueError("token_manager is required for agenticUser activities") if not recipient.agentic_user_id: raise ValueError("agenticUser recipient is missing agenticUserId") if not recipient.agentic_app_id: @@ -270,17 +270,30 @@ def _get_agent_user(self) -> Optional[AgentUser]: if not tenant_id: raise ValueError("agenticUser recipient is missing tenantId") - return AgentUser( - AgentUserIdentity( - id=recipient.agentic_user_id, - agent_identity_app_id=recipient.agentic_app_id, - tenant_id=tenant_id, - ), - service_url=self.conversation_ref.service_url, - http_client=self.api.http, - token_manager=self._token_manager, + 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,