diff --git a/examples/agent365/README.md b/examples/agent365/README.md index c3164c58..21bf7310 100644 --- a/examples/agent365/README.md +++ b/examples/agent365/README.md @@ -1,31 +1,30 @@ # agent365 -Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user. +Demonstrates passing `AgenticIdentity` directly to Teams API surfaces. -## Run +## Reactive Echo -Set these environment variables or add them to `.env`: +`src/main.py` mimics the echo example. Incoming messages are handled normally; the inbound service URL and agentic identity are carried by the context/API layer. ```bash -AGENT365_TENANT_ID= -AGENT365_BLUEPRINT_CLIENT_ID= -AGENT365_BLUEPRINT_CLIENT_SECRET= -AGENT365_AGENT_IDENTITY_APP_ID= -AGENT365_AGENT_USER_ID= -``` - -Then run: +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= -```bash uv run --project examples/agent365 python src/main.py ``` -By default this requests a Teams bot API token for `https://botapi.skype.com/.default`. +## Proactive API Send -To request another resource, set `AGENT365_SCOPE`, for example: +`src/proactive.py` shows both `app.send(..., agentic_identity=...)` and the lower-level conversation activity API. In both cases the API layer asks the auth provider for the right Agent ID token and uses it in the request header. ```bash -AGENT365_SCOPE=https://graph.microsoft.com/.default +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/proactive.py \ + \ + \ + ``` - -You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`. diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 153e79f1..355b3d1f 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -4,46 +4,48 @@ """ import asyncio -import os +import logging +import re -from dotenv import load_dotenv -from microsoft_teams.api import AgenticIdentity, ClientCredentials -from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager +from microsoft_teams.api import MessageActivity +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.apps import ActivityContext, App +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) -def get_required_env(name: str) -> str: - value = os.getenv(name) - if not value: - raise ValueError(f"{name} must be set") +app = App() - return value +@app.on_message_pattern(re.compile(r"hello|hi|greetings")) +async def handle_greeting(ctx: ActivityContext[MessageActivity]) -> None: + """Handle greeting messages using the inbound AgenticIdentity when present.""" + await ctx.reply("Hello! How can I assist you today?") -async def main(): - load_dotenv() - tenant_id = get_required_env("AGENT365_TENANT_ID") - blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") - blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") - agentic_app_id = get_required_env("AGENT365_AGENTIC_APP_ID") - agentic_user_id = get_required_env("AGENT365_AGENTIC_USER_ID") - scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + """Echo incoming messages using the inbound AgenticIdentity when present.""" + logger.info("[Agent365 reactive] Message received: %s", ctx.activity.text) + logger.info("[Agent365 reactive] From: %s", ctx.activity.from_) + logger.info("[Agent365 reactive] Agentic identity: %s", ctx.activity.recipient.agentic_identity) - credentials = ClientCredentials( - client_id=blueprint_client_id, - client_secret=blueprint_client_secret, - tenant_id=tenant_id, - ) - token_manager = TokenManager(credentials=credentials) + await ctx.reply(TypingActivityInput()) - token = await token_manager.get_agentic_token( - scope, - AgenticIdentity(agentic_app_id=agentic_app_id, agentic_user_id=agentic_user_id, tenant_id=tenant_id), - ) + if "react" in ctx.activity.text.lower(): + await ctx.api.reactions.add( + conversation_id=ctx.activity.conversation.id, + activity_id=ctx.activity.id, + reaction_type="like", + ) + await ctx.reply("Added a like reaction to your message.") + return - print(f"Acquired agent user token for {scope}") - print(f"Token preview: {str(token)[:20]}...") + if "reply" in ctx.activity.text.lower(): + await ctx.reply("Hello! How can I assist you today?") + else: + await ctx.send(f"You said '{ctx.activity.text}'") if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(app.start()) diff --git a/examples/agent365/src/proactive.py b/examples/agent365/src/proactive.py new file mode 100644 index 00000000..4736bdab --- /dev/null +++ b/examples/agent365/src/proactive.py @@ -0,0 +1,43 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import argparse +import asyncio +import logging + +from microsoft_teams.api import MessageActivityInput +from microsoft_teams.apps import App + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + parser = argparse.ArgumentParser(description="Send proactive messages using AgenticIdentity") + parser.add_argument("conversation_id", help="The Teams conversation ID to send messages to") + parser.add_argument("agentic_app_id", help="The concrete agent identity app/client ID") + parser.add_argument("agentic_user_id", help="The agent user object ID") + args = parser.parse_args() + + app = App() + await app.initialize() + + agentic_identity = app.get_agentic_identity(args.agentic_app_id, args.agentic_user_id) + sent = await app.send( + args.conversation_id, + "Hello from app.send with an AgenticIdentity.", + agentic_identity=agentic_identity, + ) + logger.info("Sent activity through app.send. Activity ID: %s", sent.id) + + api_sent = await app.api.conversations.activities(args.conversation_id).create( + MessageActivityInput(text="Hello from the conversation activity API with an AgenticIdentity."), + agentic_identity=agentic_identity, + ) + logger.info("Sent activity through app.api. Activity ID: %s", api_sent.id) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 82c2ec6c..1af304b1 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -15,6 +15,7 @@ Account, ActivityBase, ActivityParams, + AgenticIdentity, ApiClient, ClientCredentials, ConversationAccount, @@ -136,6 +137,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.options.default_connection_name, self.http_client, self._token_manager, + self._auth_provider, self.options.api_client_settings, self.cloud, ) @@ -289,7 +291,14 @@ async def stop(self) -> None: self._events.emit("error", ErrorEvent(error, context={"method": "stop"})) raise - async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard): + async def send( + self, + conversation_id: str, + activity: str | ActivityParams | AdaptiveCard, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, + ) -> SentActivity: """Send an activity proactively to a conversation. Sends to the exact conversation ID provided. For channel threads, @@ -305,7 +314,7 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap conversation_ref = ConversationReference( channel_id="msteams", - service_url=self.api.service_url, + service_url=service_url or self.api.service_url, bot=Account(id=self.id), conversation=ConversationAccount(id=conversation_id), ) @@ -317,7 +326,32 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap else: activity = activity - return await send_or_update_activity(self.api, activity, conversation_ref) + return await send_or_update_activity( + self.api, + activity, + conversation_ref, + agentic_identity=agentic_identity, + ) + + def get_agentic_identity( + self, + agentic_app_id: str, + agentic_user_id: str, + *, + tenant_id: Optional[str] = None, + agentic_app_blueprint_id: Optional[str] = None, + ) -> AgenticIdentity: + """Get an Agent ID identity for API calls.""" + resolved_tenant_id = tenant_id or (self.credentials.tenant_id if self.credentials else None) + if resolved_tenant_id is None: + raise ValueError("tenant_id is required to get an agentic identity") + + return AgenticIdentity( + agentic_app_id=agentic_app_id, + agentic_user_id=agentic_user_id, + tenant_id=resolved_tenant_id, + agentic_app_blueprint_id=agentic_app_blueprint_id, + ) @overload async def reply( @@ -325,6 +359,9 @@ async def reply( conversation_id: str, message_id: str, activity: str | ActivityParams | AdaptiveCard, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> SentActivity: ... @overload @@ -332,6 +369,9 @@ async def reply( self, conversation_id: str, message_id: str | ActivityParams | AdaptiveCard, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> SentActivity: ... async def reply( # type: ignore[reportInconsistentOverload] @@ -339,6 +379,9 @@ async def reply( # type: ignore[reportInconsistentOverload] conversation_id: str, message_id: str | ActivityParams | AdaptiveCard = "", activity: str | ActivityParams | AdaptiveCard | None = None, + *, + service_url: Optional[str] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> SentActivity: """Send an activity proactively to a conversation, optionally as a threaded reply. @@ -359,9 +402,19 @@ async def reply( # type: ignore[reportInconsistentOverload] if activity is not None: if not isinstance(message_id, str): raise TypeError("message_id must be a string when activity is provided") - return await self.send(to_threaded_conversation_id(conversation_id, message_id), activity) + return await self.send( + to_threaded_conversation_id(conversation_id, message_id), + activity, + service_url=service_url, + agentic_identity=agentic_identity, + ) - return await self.send(conversation_id, message_id) + return await self.send( + conversation_id, + message_id, + service_url=service_url, + agentic_identity=agentic_identity, + ) def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None: """Add middleware to run on all activities.""" diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index fc9b69d3..aad80d1e 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -21,11 +21,12 @@ from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment from microsoft_teams.api.clients.user.params import GetUserTokenParams from microsoft_teams.cards import AdaptiveCard -from microsoft_teams.common import Client, ClientOptions, LocalStorage, Storage +from microsoft_teams.common import Client, LocalStorage, Storage if TYPE_CHECKING: from .app_events import EventManager +from .auth_provider import AppAuthProvider from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError from .routing.activity_context import ActivityContext @@ -47,6 +48,7 @@ def __init__( default_connection_name: str, http_client: Client, token_manager: TokenManager, + auth_provider: AppAuthProvider, api_client_settings: Optional[ApiClientSettings], cloud: CloudEnvironment = PUBLIC, ) -> None: @@ -56,6 +58,7 @@ def __init__( self.default_connection_name = default_connection_name self.http_client = http_client self.token_manager = token_manager + self.auth_provider = auth_provider self.api_client_settings = api_client_settings self.cloud = cloud @@ -90,8 +93,10 @@ async def _build_context( ) api_client = ApiClient( service_url, - self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token)), + self.http_client, self.api_client_settings, + auth_provider=self.auth_provider, + agentic_identity=activity.recipient.agentic_identity, ) # Check if user is signed in diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index ee9e372c..9c05bc0e 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -197,7 +197,12 @@ async def send( self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref - return await send_or_update_activity(self.api, activity, ref) + return await send_or_update_activity( + self.api, + activity, + ref, + agentic_identity=self.activity.recipient.agentic_identity, + ) async def reply(self, input: str | ActivityParams) -> SentActivity: """Send a message in the current conversation with a visual quote of the inbound message. diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index fc0c856e..db18e431 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -380,6 +380,30 @@ async def test_send_with_adaptive_card(self) -> None: assert len(sent_activity.attachments) > 0 assert isinstance(result, SentActivity) + @pytest.mark.asyncio + async def test_send_passes_inbound_agentic_identity(self) -> None: + """Sending from an Agent ID activity uses the inbound agentic identity.""" + recipient = Account( + id="bot-id", + name="Test Bot", + agentic_app_id="agentic-app-id", + agentic_user_id="agentic-user-id", + tenant_id="tenant-id", + ) + activity = MessageActivity( + id="incoming-activity-id", + text="Incoming message", + from_=Account(id="user-id", name="Test User"), + recipient=recipient, + conversation=ConversationAccount(id="test-conversation"), + ) + ctx, mock_sender = _create_activity_context(activity=activity) + + await ctx.send("Hello") + + mock_sender.send.assert_called_once() + assert mock_sender.send.call_args.kwargs["agentic_identity"] == recipient.agentic_identity + class TestActivityContextReply: """Tests for ActivityContext.reply().""" @@ -424,6 +448,30 @@ async def test_reply_with_activity_params(self) -> None: assert isinstance(sent_activity.entities[0], QuotedReplyEntity) assert sent_activity.entities[0].quoted_reply.message_id == "evt-id-999" + @pytest.mark.asyncio + async def test_reply_passes_inbound_agentic_identity(self) -> None: + """Replying to an Agent ID activity uses the inbound agentic identity.""" + recipient = Account( + id="bot-id", + name="Test Bot", + agentic_app_id="agentic-app-id", + agentic_user_id="agentic-user-id", + tenant_id="tenant-id", + ) + activity = MessageActivity( + id="incoming-activity-id", + text="Incoming message", + from_=Account(id="user-id", name="Test User"), + recipient=recipient, + conversation=ConversationAccount(id="test-conversation"), + ) + ctx, mock_sender = _create_activity_context(activity=activity) + + await ctx.reply("Hello") + + mock_sender.send.assert_called_once() + assert mock_sender.send.call_args.kwargs["agentic_identity"] == recipient.agentic_identity + class TestActivityContextUserGraph: """Tests for ActivityContext.user_graph property.""" diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index 4c6d57fa..8a27eb15 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -15,6 +15,7 @@ import pytest from microsoft_teams.api import ( Account, + AgenticIdentity, ConversationAccount, FederatedIdentityCredentials, InvokeActivity, @@ -802,6 +803,37 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st assert sent_activity.from_.id == "test-client-id" assert sent_activity.conversation.id == "conv-123" + @pytest.mark.asyncio + async def test_send_passes_agentic_identity_and_service_url(self, mock_storage) -> None: + options = AppOptions(storage=mock_storage, client_id="test-client-id", client_secret="test-secret") + app = App(**options) + app._initialized = True + create = AsyncMock( + return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) + ) + activities = MagicMock() + activities.create = create + app.api.conversations.activities = MagicMock(return_value=activities) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + result = await app.send( + "conv-123", + "Hello", + service_url="https://override.service.url", + agentic_identity=agentic_identity, + ) + + app.api.conversations.activities.assert_called_once_with("conv-123") + create.assert_called_once() + activity = create.call_args.args[0] + assert isinstance(activity, MessageActivityInput) + assert activity.text == "Hello" + assert create.call_args.kwargs == { + "service_url": "https://override.service.url", + "agentic_identity": agentic_identity, + } + assert result.id == "sent-activity-id" + class TestAppInitialize: """Test cases for App.initialize() method.""" @@ -881,12 +913,55 @@ async def test_reply_with_three_args_constructs_threaded_id(self, started_app): started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000") + @pytest.mark.asyncio + async def test_reply_with_three_args_passes_agentic_identity_and_service_url(self, started_app): + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await started_app.reply( + "19:abc@thread.skype", + "1680000000000", + "Hello thread", + service_url="https://override.service.url", + agentic_identity=agentic_identity, + ) + + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000") + create = started_app.api.conversations.activities.return_value.create + activity = create.call_args.args[0] + assert isinstance(activity, MessageActivityInput) + assert activity.text == "Hello thread" + assert create.call_args.kwargs == { + "service_url": "https://override.service.url", + "agentic_identity": agentic_identity, + } + @pytest.mark.asyncio async def test_reply_with_two_args_passes_conversation_id_as_is(self, started_app): await started_app.reply("19:abc@thread.skype", "Hello flat") started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype") + @pytest.mark.asyncio + async def test_reply_with_two_args_passes_agentic_identity_and_service_url(self, started_app): + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await started_app.reply( + "19:abc@thread.skype", + "Hello flat", + service_url="https://override.service.url", + agentic_identity=agentic_identity, + ) + + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype") + create = started_app.api.conversations.activities.return_value.create + activity = create.call_args.args[0] + assert isinstance(activity, MessageActivityInput) + assert activity.text == "Hello flat" + assert create.call_args.kwargs == { + "service_url": "https://override.service.url", + "agentic_identity": agentic_identity, + } + @pytest.mark.asyncio async def test_reply_with_pre_constructed_threaded_id(self, started_app): await started_app.reply("19:abc@thread.skype;messageid=123", "Hello") diff --git a/packages/apps/tests/test_app_oauth.py b/packages/apps/tests/test_app_oauth.py index c685a40d..f6034686 100644 --- a/packages/apps/tests/test_app_oauth.py +++ b/packages/apps/tests/test_app_oauth.py @@ -27,6 +27,7 @@ ) from microsoft_teams.apps.app_oauth import OauthHandlers from microsoft_teams.apps.app_process import ActivityProcessor +from microsoft_teams.apps.auth_provider import AppAuthProvider from microsoft_teams.apps.events import ErrorEvent, SignInEvent from microsoft_teams.apps.routing import ActivityContext from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES @@ -445,6 +446,7 @@ def processor(self, router): default_connection_name="graph", http_client=MagicMock(), token_manager=MagicMock(), + auth_provider=MagicMock(spec=AppAuthProvider), api_client_settings=None, cloud=PUBLIC, ) diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py index f946b00f..fb01ccbc 100644 --- a/packages/apps/tests/test_app_process.py +++ b/packages/apps/tests/test_app_process.py @@ -19,6 +19,7 @@ from microsoft_teams.apps import ActivityContext, ActivityEvent from microsoft_teams.apps.app_events import EventManager from microsoft_teams.apps.app_process import ActivityProcessor +from microsoft_teams.apps.auth_provider import AppAuthProvider from microsoft_teams.apps.events import CoreActivity from microsoft_teams.apps.routing.router import ActivityHandler, ActivityRouter from microsoft_teams.apps.token_manager import TokenManager @@ -42,6 +43,7 @@ def activity_processor(self, mock_http_client): mock_storage = MagicMock(spec=LocalStorage) mock_activity_router = MagicMock(spec=ActivityRouter) mock_token_manager = MagicMock(spec=TokenManager) + mock_auth_provider = MagicMock(spec=AppAuthProvider) return ActivityProcessor( mock_activity_router, "id", @@ -49,6 +51,7 @@ def activity_processor(self, mock_http_client): "default_connection", mock_http_client, mock_token_manager, + mock_auth_provider, None, PUBLIC, ) @@ -305,6 +308,46 @@ async def test_build_context_marks_signed_in_when_token_available(self, activity mock_api_client.users.token.get.assert_called_once() + @pytest.mark.asyncio + async def test_build_context_scopes_api_to_inbound_agentic_identity(self, activity_processor): + """Inbound Agent ID activities scope ctx.api with the inbound agentic identity.""" + core_activity = CoreActivity( + type="message", + id="activity-agentic", + service_url="https://service.url", + **{ + "from": {"id": "user-1", "name": "Test User"}, + "conversation": {"id": "conv-1"}, + "recipient": { + "id": "bot-1", + "name": "Test Bot", + "agenticAppId": "agentic-app-id", + "agenticUserId": "agentic-user-id", + "tenantId": "tenant-id", + }, + "channelId": "msteams", + }, + ) + mock_token = MagicMock(spec=TokenProtocol) + mock_token.service_url = "https://service.url" + mock_activity_event = ActivityEvent(body=core_activity, token=mock_token) + mock_api_client = MagicMock() + mock_api_client.users.token.get = AsyncMock(side_effect=Exception("no token")) + + activity_processor.router.select_handlers = MagicMock(return_value=[]) + activity_processor.event_manager = MagicMock() + activity_processor.event_manager.on_activity_response = AsyncMock() + activity_processor.event_manager.on_error = AsyncMock() + + with patch("microsoft_teams.apps.app_process.ApiClient", return_value=mock_api_client) as mock_api_client_type: + await activity_processor.process_activity([], mock_activity_event) + + assert mock_api_client_type.call_args.kwargs["auth_provider"] is activity_processor.auth_provider + agentic_identity = mock_api_client_type.call_args.kwargs["agentic_identity"] + assert agentic_identity.agentic_app_id == "agentic-app-id" + assert agentic_identity.agentic_user_id == "agentic-user-id" + assert agentic_identity.tenant_id == "tenant-id" + @pytest.mark.asyncio async def test_process_activity_raises_when_event_manager_missing(self, activity_processor): """process_activity raises ValueError if event_manager was never initialized."""