diff --git a/packages/apps/src/microsoft_teams/apps/activity_send.py b/packages/apps/src/microsoft_teams/apps/activity_send.py new file mode 100644 index 000000000..6d5924b3f --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/activity_send.py @@ -0,0 +1,58 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_teams.api import ( + ActivityParams, + AgenticIdentity, + ApiClient, + ConversationReference, + MessageActivityInput, + SentActivity, +) + + +async def send_or_update_activity( + api: ApiClient, + activity: ActivityParams, + ref: ConversationReference, + *, + agentic_identity: AgenticIdentity | None = None, +) -> SentActivity: + """Send or update an activity using the same routing rules as the removed ActivitySender.""" + is_targeted = ( + isinstance(activity, MessageActivityInput) + and activity.recipient is not None + and activity.recipient.is_targeted is True + ) + + if is_targeted and ref.conversation.conversation_type == "personal": + raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.") + + activity.from_ = ref.bot + activity.conversation = ref.conversation + + activities = api.conversations.activities(ref.conversation.id) + if activity.id: + activity_id = activity.id + if is_targeted: + res = await activities.update_targeted( + activity_id, + activity, + service_url=ref.service_url, + ) + else: + res = await activities.update( + activity_id, + activity, + service_url=ref.service_url, + agentic_identity=agentic_identity, + ) + return SentActivity.merge(activity, res) + + if is_targeted: + res = await activities.create_targeted(activity, service_url=ref.service_url) + else: + res = await activities.create(activity, service_url=ref.service_url, agentic_identity=agentic_identity) + return SentActivity.merge(activity, res) diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py deleted file mode 100644 index 1ab97c96c..000000000 --- a/packages/apps/src/microsoft_teams/apps/activity_sender.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -import logging -from typing import cast - -from microsoft_teams.api import ( - ActivityParams, - ApiClient, - ConversationReference, - MessageActivityInput, - SentActivity, -) -from microsoft_teams.common import Client - -from .http_stream import HttpStream -from .plugins.streamer import StreamerProtocol - -logger = logging.getLogger(__name__) - - -class ActivitySender: - """ - Handles sending activities to the Bot Framework. - Separate from transport concerns (HTTP, WebSocket, etc.) - """ - - def __init__(self, client: Client): - """ - Initialize ActivitySender. - - Args: - client: HTTP client with token provider configured - """ - self._client = client - - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: - """ - Send an activity to the Bot Framework. - - Args: - activity: The activity to send - ref: The conversation reference - - Returns: - The sent activity with id and other server-populated fields - """ - is_targeted = ( - isinstance(activity, MessageActivityInput) - and activity.recipient is not None - and activity.recipient.is_targeted is True - ) - - if is_targeted and ref.conversation.conversation_type == "personal": - raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.") - - # Create API client for this conversation's service URL - api = ApiClient(service_url=ref.service_url, options=self._client) - - # Merge activity with conversation reference - activity.from_ = ref.bot - activity.conversation = ref.conversation - - is_update = hasattr(activity, "id") and activity.id - activities = api.conversations.activities(ref.conversation.id) - - if is_update: - activity_id = cast(str, activity.id) - if is_targeted: - res = await activities.update_targeted(activity_id, activity) - else: - res = await activities.update(activity_id, activity) - return SentActivity.merge(activity, res) - - if is_targeted: - res = await activities.create_targeted(activity) - else: - res = await activities.create(activity) - return SentActivity.merge(activity, res) - - def create_stream(self, ref: ConversationReference) -> StreamerProtocol: - """ - Create a new activity stream for real-time updates. - - Args: - ref: The conversation reference - - Returns: - A new streaming instance - """ - # Create API client for this conversation's service URL - api = ApiClient(ref.service_url, self._client) - return HttpStream(api, ref) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 5da9a8a70..82c2ec6c7 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: from msgraph.graph_service_client import GraphServiceClient -from .activity_sender import ActivitySender +from .activity_send import send_or_update_activity from .app_events import EventManager from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor @@ -128,9 +128,6 @@ def __init__(self, **options: Unpack[AppOptions]): self._port: Optional[int] = None self._initialized = False - # initialize ActivitySender for sending activities - self.activity_sender = ActivitySender(self.http_client.clone(ClientOptions(token=self._get_bot_token))) - # initialize all event, activity, and plugin processors self.activity_processor = ActivityProcessor( self._router, @@ -140,7 +137,6 @@ def __init__(self, **options: Unpack[AppOptions]): self.http_client, self._token_manager, self.options.api_client_settings, - self.activity_sender, self.cloud, ) self.event_manager = EventManager(self._events) @@ -321,7 +317,7 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap else: activity = activity - return await self.activity_sender.send(activity, conversation_ref) + return await send_or_update_activity(self.api, activity, conversation_ref) @overload async def reply( @@ -574,7 +570,6 @@ async def handler(request: HttpRequest) -> HttpResponse: ctx = FunctionContext( id=self.id, api=self.api, - activity_sender=self.activity_sender, data=request["body"], **client_context.__dict__, ) diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 1b2e9f27a..fc9b69d39 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -26,7 +26,6 @@ if TYPE_CHECKING: from .app_events import EventManager -from .activity_sender import ActivitySender from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError from .routing.activity_context import ActivityContext @@ -49,7 +48,6 @@ def __init__( http_client: Client, token_manager: TokenManager, api_client_settings: Optional[ApiClientSettings], - activity_sender: ActivitySender, cloud: CloudEnvironment = PUBLIC, ) -> None: self.router = router @@ -59,7 +57,6 @@ def __init__( self.http_client = http_client self.token_manager = token_manager self.api_client_settings = api_client_settings - self.activity_sender = activity_sender self.cloud = cloud # This will be set after the EventManager is initialized due to @@ -127,7 +124,6 @@ async def _build_context( conversation_ref, is_signed_in, self.default_connection_name, - activity_sender=self.activity_sender, app_token=lambda: self.token_manager.get_graph_token(tenant_id), cloud=self.cloud, ) diff --git a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py index 4ce90e9e3..754ebcfa9 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -21,7 +21,7 @@ ) from microsoft_teams.cards import AdaptiveCard -from ..activity_sender import ActivitySender +from ..activity_send import send_or_update_activity from .client_context import ClientContext T = TypeVar("T") @@ -44,9 +44,6 @@ class FunctionContext(ClientContext, Generic[T]): api: ApiClient """The API client instance for conversation client.""" - activity_sender: ActivitySender - """The activity sender instance for sending messages.""" - data: T """The function payload.""" @@ -65,13 +62,6 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[ logger.warning("Cannot send activity: conversation ID could not be resolved") return None - conversation_ref = ConversationReference( - channel_id="msteams", - service_url=self.api.service_url, - bot=Account(id=self.id, name=self.name), - conversation=ConversationAccount(id=conversation_id, conversation_type="personal"), - ) - if isinstance(activity, str): activity = MessageActivityInput(text=activity) elif isinstance(activity, AdaptiveCard): @@ -79,7 +69,13 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[ else: activity = activity - return await self.activity_sender.send(activity, conversation_ref) + conversation_ref = ConversationReference( + channel_id="msteams", + service_url=self.api.service_url, + bot=Account(id=self.id, name=self.name), + conversation=ConversationAccount(id=conversation_id, conversation_type="personal"), + ) + return await send_or_update_activity(self.api, activity, conversation_ref) async def _resolve_conversation_id(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[str]: """Resolve or create a conversation ID for the current user/context. diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 58365143b..ee9e372cd 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -41,7 +41,9 @@ from microsoft_teams.common.experimental import ExperimentalWarning from microsoft_teams.common.http.client_token import Token -from ..activity_sender import ActivitySender +from ..activity_send import send_or_update_activity +from ..http_stream import HttpStream +from ..plugins.streamer import StreamerProtocol from ..utils import create_graph_client if TYPE_CHECKING: @@ -86,7 +88,6 @@ def __init__( conversation_ref: ConversationReference, is_signed_in: bool, connection_name: str, - activity_sender: ActivitySender, app_token: Token, cloud: CloudEnvironment = PUBLIC, ): @@ -100,9 +101,8 @@ def __init__( self.connection_name = connection_name self.is_signed_in = is_signed_in self.cloud = cloud - self._activity_sender = activity_sender self._app_token = app_token - self.stream = activity_sender.create_stream(conversation_ref) + self._stream: Optional[StreamerProtocol] = None self._next_handler: Optional[Callable[[], Awaitable[None]]] = None @@ -110,6 +110,12 @@ def __init__( self._user_graph: Optional["GraphServiceClient"] = None self._app_graph: Optional["GraphServiceClient"] = None + @property + def stream(self) -> StreamerProtocol: + if self._stream is None: + self._stream = HttpStream(self.api, self.conversation_ref) + return self._stream + @property def user_graph(self) -> "GraphServiceClient": """ @@ -191,8 +197,7 @@ async def send( self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref - res = await self._activity_sender.send(activity, ref) - return res + return await send_or_update_activity(self.api, activity, ref) async def reply(self, input: str | ActivityParams) -> SentActivity: """Send a message in the current conversation with a visual quote of the inbound message. diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 7f1eeb5da..fc0c856e8 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -39,6 +39,28 @@ def _create_activity_context( ) ) mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) + activities = MagicMock() + activities.create = mock_activity_sender.send + activities.update = AsyncMock( + return_value=SentActivity( + id="updated-activity-id", + activity_params=MessageActivityInput(text="updated"), + ) + ) + activities.create_targeted = AsyncMock( + return_value=SentActivity( + id="targeted-activity-id", + activity_params=MessageActivityInput(text="targeted"), + ) + ) + activities.update_targeted = AsyncMock( + return_value=SentActivity( + id="updated-targeted-activity-id", + activity_params=MessageActivityInput(text="updated targeted"), + ) + ) + api = MagicMock() + api.conversations.activities.return_value = activities conversation_ref = ConversationReference( bot=Account(id="bot-id", name="Test Bot"), @@ -51,12 +73,11 @@ def _create_activity_context( activity=mock_activity, app_id="test-app-id", storage=MagicMock(), - api=MagicMock(), + api=api, user_token=user_token, conversation_ref=conversation_ref, is_signed_in=is_signed_in, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=MagicMock(), cloud=PUBLIC, ) @@ -88,16 +109,21 @@ async def test_defaults_send_to_targeted_when_inbound_message_is_targeted(self) await ctx.send("Secret message") - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Secret message" + assert sent_activity.from_ == ctx.conversation_ref.bot + assert sent_activity.conversation == ctx.conversation_ref.conversation assert sent_activity.recipient is not None assert sent_activity.recipient.id == incoming_sender.id assert sent_activity.recipient.name == incoming_sender.name assert sent_activity.recipient.is_targeted is True assert sent_activity.entities is not None assert any(isinstance(entity, TargetedMessageInfoEntity) for entity in sent_activity.entities) + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + ctx.api.conversations.activities.return_value.create.assert_not_called() @pytest.mark.asyncio async def test_reply_defaults_to_targeted_when_inbound_message_is_targeted(self) -> None: @@ -107,8 +133,9 @@ async def test_reply_defaults_to_targeted_when_inbound_message_is_targeted(self) await ctx.reply("Private reply") - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.reply_to_id is None assert sent_activity.recipient is not None @@ -151,8 +178,8 @@ async def test_different_conversation_does_not_default_to_targeted(self) -> None mock_sender.send.assert_called_once() sent_activity = mock_sender.send.call_args[0][0] - sent_ref = mock_sender.send.call_args[0][1] - assert sent_ref == other_ref + ctx.api.conversations.activities.assert_called_once_with("other-conversation") + assert mock_sender.send.call_args.kwargs["service_url"] == other_ref.service_url assert sent_activity.recipient is None assert sent_activity.entities is None @@ -165,8 +192,9 @@ async def test_explicit_targeted_send_from_public_inbound_does_not_add_targeted_ await ctx.send(activity) - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.recipient is not None assert sent_activity.recipient.is_targeted is True assert sent_activity.entities is None @@ -183,8 +211,9 @@ async def test_targeted_outbound_strips_quoted_reply_metadata(self) -> None: await ctx.send(activity) - mock_sender.send.assert_called_once() - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.text == "Secret" assert sent_activity.entities is not None assert all(not isinstance(entity, QuotedReplyEntity) for entity in sent_activity.entities) @@ -207,10 +236,10 @@ async def test_targeted_message_with_explicit_recipient(self) -> None: await ctx.send(activity) # Verify send was called - mock_sender.send.assert_called_once() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + mock_sender.send.assert_not_called() - # Get the activity that was passed to send - sent_activity = mock_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] # Verify recipient was preserved assert sent_activity.recipient is not None @@ -232,11 +261,8 @@ async def test_targeted_update_preserves_recipient(self) -> None: await ctx.send(activity) - # Verify send was called - mock_sender.send.assert_called_once() - - # Get the activity that was passed to send - sent_activity = mock_sender.send.call_args[0][0] + ctx.api.conversations.activities.return_value.update_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.update_targeted.call_args.args[1] # Verify recipient was preserved assert sent_activity.recipient is not None @@ -244,6 +270,33 @@ async def test_targeted_update_preserves_recipient(self) -> None: assert sent_activity.recipient.name == incoming_sender.name assert sent_activity.recipient.is_targeted is True + @pytest.mark.asyncio + async def test_send_existing_activity_updates(self) -> None: + incoming_sender = Account(id="user-123", name="Test User") + ctx, mock_sender = self._create_activity_context(from_account=incoming_sender) + activity = MessageActivityInput(text="Updated message") + activity.id = "existing-msg-id" + + await ctx.send(activity) + + ctx.api.conversations.activities.return_value.update.assert_called_once_with( + "existing-msg-id", + activity, + service_url=ctx.conversation_ref.service_url, + agentic_identity=None, + ) + mock_sender.send.assert_not_called() + + @pytest.mark.asyncio + async def test_targeted_send_in_personal_chat_raises(self) -> None: + incoming_sender = Account(id="user-123", name="Test User") + ctx, _ = self._create_activity_context(from_account=incoming_sender) + ctx.conversation_ref.conversation.conversation_type = "personal" + activity = MessageActivityInput(text="Nope").with_recipient(incoming_sender, is_targeted=True) + + with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"): + await ctx.send(activity) + @pytest.mark.asyncio async def test_targeted_with_different_recipient(self) -> None: """ @@ -261,10 +314,9 @@ async def test_targeted_with_different_recipient(self) -> None: await ctx.send(activity) # Verify send was called - mock_sender.send.assert_called_once() - - # Get the activity that was passed to send - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + ctx.api.conversations.activities.return_value.create_targeted.assert_called_once() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] # Verify recipient was preserved assert sent_activity.recipient is not None @@ -504,17 +556,20 @@ async def test_sign_in_returns_token_when_already_available(self) -> None: @pytest.mark.asyncio async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None: """sign_in falls through to OAuth card flow when token API fails, returns None.""" - mock_activity = MagicMock() - mock_activity.channel_id = "msteams" - mock_activity.from_ = Account(id="user-001") - mock_activity.conversation.is_group = False + mock_activity = MessageActivity( + id="activity-id", + channel_id="msteams", + from_=Account(id="user-001"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="test-conversation", is_group=False), + ) ctx, mock_sender = _create_activity_context(activity=mock_activity) ctx.api.users.token.get = AsyncMock(side_effect=Exception("no token")) resource_response = MagicMock() - resource_response.token_exchange_resource = MagicMock() - resource_response.token_post_resource = MagicMock() + resource_response.token_exchange_resource = None + resource_response.token_post_resource = None resource_response.sign_in_link = "https://login.example.com" ctx.api.bots.sign_in.get_resource = AsyncMock(return_value=resource_response) @@ -525,10 +580,6 @@ async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None: "microsoft_teams.apps.routing.activity_context.TokenExchangeState", return_value=token_state, ), - patch("microsoft_teams.apps.routing.activity_context.card_attachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCard"), - patch("microsoft_teams.apps.routing.activity_context.CardAction"), patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"), ): result = await ctx.sign_in() @@ -540,11 +591,13 @@ async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None: @pytest.mark.asyncio async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> None: """For group conversations, sign_in creates a 1:1 conversation before sending the OAuth card.""" - mock_activity = MagicMock() - mock_activity.channel_id = "msteams" - mock_activity.from_ = Account(id="user-001") - mock_activity.conversation.is_group = True - mock_activity.conversation.tenant_id = "tenant-001" + mock_activity = MessageActivity( + id="activity-id", + channel_id="msteams", + from_=Account(id="user-001"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="test-conversation", is_group=True, tenant_id="tenant-001"), + ) ctx, mock_sender = _create_activity_context(activity=mock_activity) ctx.api.users.token.get = AsyncMock(side_effect=Exception("no token")) @@ -554,8 +607,8 @@ async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> N ctx.api.conversations.create = AsyncMock(return_value=one_on_one) resource_response = MagicMock() - resource_response.token_exchange_resource = MagicMock() - resource_response.token_post_resource = MagicMock() + resource_response.token_exchange_resource = None + resource_response.token_post_resource = None resource_response.sign_in_link = "https://login.example.com" ctx.api.bots.sign_in.get_resource = AsyncMock(return_value=resource_response) @@ -567,10 +620,6 @@ async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> N return_value=token_state, ), patch("microsoft_teams.apps.routing.activity_context.CreateConversationParams"), - patch("microsoft_teams.apps.routing.activity_context.card_attachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCard"), - patch("microsoft_teams.apps.routing.activity_context.CardAction"), patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"), ): result = await ctx.sign_in() @@ -585,17 +634,20 @@ async def test_sign_in_uses_signin_options_connection_name_override(self) -> Non """sign_in respects SignInOptions.connection_name override when fetching the existing token.""" from microsoft_teams.apps.routing.activity_context import SignInOptions - mock_activity = MagicMock() - mock_activity.channel_id = "msteams" - mock_activity.from_ = Account(id="user-001") - mock_activity.conversation.is_group = False + mock_activity = MessageActivity( + id="activity-id", + channel_id="msteams", + from_=Account(id="user-001"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="test-conversation", is_group=False), + ) ctx, _ = _create_activity_context(activity=mock_activity) ctx.api.users.token.get = AsyncMock(side_effect=Exception("no token")) resource_response = MagicMock() - resource_response.token_exchange_resource = MagicMock() - resource_response.token_post_resource = MagicMock() + resource_response.token_exchange_resource = None + resource_response.token_post_resource = None resource_response.sign_in_link = "https://login.example.com" ctx.api.bots.sign_in.get_resource = AsyncMock(return_value=resource_response) @@ -612,10 +664,6 @@ async def test_sign_in_uses_signin_options_connection_name_override(self) -> Non "microsoft_teams.apps.routing.activity_context.TokenExchangeState", return_value=token_state, ), - patch("microsoft_teams.apps.routing.activity_context.card_attachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"), - patch("microsoft_teams.apps.routing.activity_context.OAuthCard"), - patch("microsoft_teams.apps.routing.activity_context.CardAction"), patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"), ): result = await ctx.sign_in(options=custom_options) @@ -692,7 +740,8 @@ async def test_send_auto_adds_targeted_message_info_entity(self) -> None: await ctx.send("Here is your agenda") - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -720,7 +769,8 @@ async def test_send_does_not_duplicate_entity_if_already_present(self) -> None: msg = MessageActivityInput(text="Reply").add_entity(TargetedMessageInfoEntity(message_id="custom-id")) await ctx.send(msg) - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 assert sent_activity.entities[0].message_id == "custom-id" @@ -735,7 +785,8 @@ async def test_reply_auto_adds_targeted_message_info_entity(self) -> None: await ctx.reply("Reply with prompt preview") - sent_activity = mock_sender.send.call_args[0][0] + mock_sender.send.assert_not_called() + sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0] assert sent_activity.entities is not None targeted_entities = [e for e in sent_activity.entities if isinstance(e, TargetedMessageInfoEntity)] assert len(targeted_entities) == 1 diff --git a/packages/apps/tests/test_activity_sender.py b/packages/apps/tests/test_activity_sender.py deleted file mode 100644 index 599659341..000000000 --- a/packages/apps/tests/test_activity_sender.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" -# pyright: basic - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from microsoft_teams.api import ( - Account, - ConversationAccount, - ConversationReference, - MessageActivityInput, - SentActivity, -) -from microsoft_teams.apps.activity_sender import ActivitySender - - -class TestActivitySender: - """Test cases for ActivitySender.""" - - @pytest.fixture - def sender(self): - """Create an ActivitySender for testing.""" - mock_client = MagicMock() - return ActivitySender(client=mock_client) - - @pytest.fixture - def conversation_ref(self): - """Create a conversation reference for testing.""" - return ConversationReference( - bot=Account(id="bot-123", name="Test Bot", role="bot"), - conversation=ConversationAccount(id="conv-456", conversation_type="personal"), - channel_id="msteams", - service_url="https://test.service.url", - ) - - @pytest.fixture - def group_conversation_ref(self): - """Create a group chat conversation reference for testing.""" - return ConversationReference( - bot=Account(id="bot-123", name="Test Bot", role="bot"), - conversation=ConversationAccount(id="conv-789", conversation_type="groupChat"), - channel_id="msteams", - service_url="https://test.service.url", - ) - - def _create_sent_activity(self, activity, activity_id="msg-123"): - """Helper to create a proper SentActivity mock.""" - return SentActivity(id=activity_id, activity_params=activity) - - @pytest.mark.asyncio - async def test_send_new_message_calls_create(self, sender, conversation_ref): - """Test that new messages (no id) use the create method.""" - activity = MessageActivityInput(text="Hello") - - mock_activities = MagicMock() - mock_activities.create = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.create.assert_called_once_with(activity) - - @pytest.mark.asyncio - async def test_send_existing_message_calls_update(self, sender, conversation_ref): - """Test that messages with an id use the update method.""" - activity = MessageActivityInput(text="Updated message") - activity.id = "existing-msg-id" - - mock_activities = MagicMock() - mock_activities.update = AsyncMock(return_value=self._create_sent_activity(activity, "existing-msg-id")) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.update.assert_called_once_with("existing-msg-id", activity) - - @pytest.mark.asyncio - async def test_send_sets_from_and_conversation(self, sender, conversation_ref): - """Test that send merges activity with conversation reference.""" - activity = MessageActivityInput(text="Hello") - - mock_activities = MagicMock() - mock_activities.create = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - assert activity.from_ == conversation_ref.bot - assert activity.conversation == conversation_ref.conversation - - @pytest.mark.asyncio - async def test_send_targeted_message_calls_create_targeted(self, sender, group_conversation_ref): - """Test that targeted messages use the create_targeted method.""" - recipient = Account(id="user-123", name="Test User", role="user") - activity = MessageActivityInput(text="Hello").with_recipient(recipient, is_targeted=True) - - mock_activities = MagicMock() - mock_activities.create_targeted = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, group_conversation_ref) - - mock_activities.create_targeted.assert_called_once_with(activity) - mock_activities.create.assert_not_called() - - @pytest.mark.asyncio - async def test_send_non_targeted_message_does_not_call_create_targeted(self, sender, conversation_ref): - """Test that non-targeted messages use the regular create method.""" - activity = MessageActivityInput(text="Hello") - - mock_activities = MagicMock() - mock_activities.create = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.create.assert_called_once_with(activity) - mock_activities.create_targeted.assert_not_called() - - @pytest.mark.asyncio - async def test_update_targeted_message_calls_update_targeted(self, sender, group_conversation_ref): - """Test that targeted message updates use the update_targeted method.""" - activity = MessageActivityInput(text="Updated targeted message") - activity.recipient = Account(id="user-123", name="Test User", role="user", is_targeted=True) - activity.id = "existing-msg-id" - - mock_activities = MagicMock() - mock_activities.update_targeted = AsyncMock( - return_value=self._create_sent_activity(activity, "existing-msg-id") - ) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, group_conversation_ref) - - mock_activities.update_targeted.assert_called_once_with("existing-msg-id", activity) - mock_activities.update.assert_not_called() - - @pytest.mark.asyncio - async def test_update_non_targeted_message_calls_update(self, sender, conversation_ref): - """Test that non-targeted message updates use the regular update method.""" - activity = MessageActivityInput(text="Updated message") - activity.id = "existing-msg-id" - - mock_activities = MagicMock() - mock_activities.update = AsyncMock(return_value=self._create_sent_activity(activity, "existing-msg-id")) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, conversation_ref) - - mock_activities.update.assert_called_once_with("existing-msg-id", activity) - mock_activities.update_targeted.assert_not_called() - - @pytest.mark.asyncio - async def test_send_targeted_in_personal_chat_raises(self, sender, conversation_ref): - """Test that sending a targeted message in a personal (1:1) chat raises ValueError.""" - activity = MessageActivityInput(text="Hello").with_recipient( - Account(id="user-1", name="User"), is_targeted=True - ) - - with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"): - await sender.send(activity, conversation_ref) - - @pytest.mark.asyncio - async def test_send_targeted_in_group_chat_succeeds(self, sender, group_conversation_ref): - """Test that sending a targeted message in a group chat proceeds normally.""" - activity = MessageActivityInput(text="Hello").with_recipient( - Account(id="user-1", name="User"), is_targeted=True - ) - - mock_activities = MagicMock() - mock_activities.create_targeted = AsyncMock(return_value=self._create_sent_activity(activity)) - - with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: - mock_api = MagicMock() - mock_api.conversations.activities.return_value = mock_activities - mock_api_client.return_value = mock_api - - await sender.send(activity, group_conversation_ref) - - mock_activities.create_targeted.assert_called_once() diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index a090860f5..4c6d57fac 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -778,9 +778,15 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st ) app = App(**options) app._initialized = True - app.activity_sender.send = AsyncMock( + create = AsyncMock( return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) ) + activities = MagicMock() + activities.create = create + activities.create_targeted = AsyncMock( + return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) + ) + app.api.conversations.activities = MagicMock(return_value=activities) # Create a targeted message with explicit recipient recipient = Account(id="user-456", name="Test User", role="user") @@ -789,8 +795,12 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st # Should not raise - explicit recipient provided result = await app.send("conv-123", activity) - app.activity_sender.send.assert_called_once() + activities.create_targeted.assert_called_once() + create.assert_not_called() assert result.id == "sent-activity-id" + sent_activity = activities.create_targeted.call_args.args[0] + assert sent_activity.from_.id == "test-client-id" + assert sent_activity.conversation.id == "conv-123" class TestAppInitialize: @@ -800,9 +810,13 @@ class TestAppInitialize: async def test_initialize_enables_send(self): """After initialize(), app.send() should work without starting the server.""" app = App(client_id="test-id", client_secret="test-secret", skip_auth=True) - app.activity_sender.send = AsyncMock( - return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi")) + create = AsyncMock(return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi"))) + activities = MagicMock() + activities.create = create + activities.update = AsyncMock( + return_value=SentActivity(id="existing-msg-id", activity_params=MessageActivityInput(text="updated")) ) + app.api.conversations.activities = MagicMock(return_value=activities) with pytest.raises(ValueError, match="app not initialized"): await app.send("conv-1", "hello") @@ -811,6 +825,17 @@ async def test_initialize_enables_send(self): result = await app.send("conv-1", "hello") assert result.id == "msg-1" + activity = MessageActivityInput(text="updated") + activity.id = "existing-msg-id" + result = await app.send("conv-1", activity) + assert result.id == "existing-msg-id" + activities.update.assert_called_once_with( + "existing-msg-id", + activity, + service_url=app.api.service_url, + agentic_identity=None, + ) + @pytest.mark.asyncio async def test_initialize_emits_error_on_plugin_failure(self): """If a plugin's on_init raises, the error event fires and the exception propagates.""" @@ -839,34 +864,34 @@ def started_app(self): options = AppOptions(client_id="test-client-id", client_secret="test-secret") app = App(**options) app._initialized = True - app.activity_sender.send = AsyncMock( + create = AsyncMock( return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent")) ) + activities = MagicMock() + activities.create = create + activities.update = AsyncMock( + return_value=SentActivity(id="updated-activity-id", activity_params=MessageActivityInput(text="updated")) + ) + app.api.conversations.activities = MagicMock(return_value=activities) return app @pytest.mark.asyncio async def test_reply_with_three_args_constructs_threaded_id(self, started_app): await started_app.reply("19:abc@thread.skype", "1680000000000", "Hello thread") - started_app.activity_sender.send.assert_called_once() - _, ref = started_app.activity_sender.send.call_args[0] - assert ref.conversation.id == "19:abc@thread.skype;messageid=1680000000000" + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000") @pytest.mark.asyncio async def test_reply_with_two_args_passes_conversation_id_as_is(self, started_app): await started_app.reply("19:abc@thread.skype", "Hello flat") - started_app.activity_sender.send.assert_called_once() - _, ref = started_app.activity_sender.send.call_args[0] - assert ref.conversation.id == "19:abc@thread.skype" + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype") @pytest.mark.asyncio async def test_reply_with_pre_constructed_threaded_id(self, started_app): await started_app.reply("19:abc@thread.skype;messageid=123", "Hello") - started_app.activity_sender.send.assert_called_once() - _, ref = started_app.activity_sender.send.call_args[0] - assert ref.conversation.id == "19:abc@thread.skype;messageid=123" + started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=123") @pytest.mark.asyncio async def test_reply_with_invalid_message_id_raises(self, started_app): diff --git a/packages/apps/tests/test_app_oauth.py b/packages/apps/tests/test_app_oauth.py index 9d6fd4c5f..c685a40d8 100644 --- a/packages/apps/tests/test_app_oauth.py +++ b/packages/apps/tests/test_app_oauth.py @@ -446,7 +446,6 @@ def processor(self, router): http_client=MagicMock(), token_manager=MagicMock(), api_client_settings=None, - activity_sender=MagicMock(), cloud=PUBLIC, ) @@ -462,7 +461,6 @@ def _make_ctx(activity): conversation_ref=MagicMock(), is_signed_in=False, connection_name="graph", - activity_sender=MagicMock(), app_token=MagicMock(), cloud=PUBLIC, ) diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py index b11d3beb4..f946b00f2 100644 --- a/packages/apps/tests/test_app_process.py +++ b/packages/apps/tests/test_app_process.py @@ -5,7 +5,7 @@ # pyright: basic from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from microsoft_teams.api import ( @@ -17,7 +17,6 @@ ) from microsoft_teams.api.auth.cloud_environment import PUBLIC from microsoft_teams.apps import ActivityContext, ActivityEvent -from microsoft_teams.apps.activity_sender import ActivitySender from microsoft_teams.apps.app_events import EventManager from microsoft_teams.apps.app_process import ActivityProcessor from microsoft_teams.apps.events import CoreActivity @@ -43,11 +42,6 @@ def activity_processor(self, mock_http_client): mock_storage = MagicMock(spec=LocalStorage) mock_activity_router = MagicMock(spec=ActivityRouter) mock_token_manager = MagicMock(spec=TokenManager) - mock_activity_sender = MagicMock(spec=ActivitySender) - # Mock the stream object with async close - mock_stream = MagicMock() - mock_stream.close = AsyncMock() - mock_activity_sender.create_stream.return_value = mock_stream return ActivityProcessor( mock_activity_router, "id", @@ -56,7 +50,6 @@ def activity_processor(self, mock_http_client): mock_http_client, mock_token_manager, None, - mock_activity_sender, PUBLIC, ) @@ -72,18 +65,16 @@ async def test_execute_middleware_chain_with_no_handlers(self, activity_processo @pytest.mark.asyncio async def test_execute_middleware_chain_with_two_handlers(self, activity_processor, mock_http_client): """Test the execute_middleware_chain method with two handlers.""" - mock_activity_sender = MagicMock(spec=ActivitySender) - mock_activity_sender.create_stream.return_value = MagicMock() + api = MagicMock() context = ActivityContext( activity=MagicMock(spec=ActivityBase), app_id="app_id", storage=MagicMock(spec=LocalStorage), - api=mock_http_client, + api=api, user_token=None, conversation_ref=MagicMock(spec=ConversationReference), is_signed_in=True, connection_name="default_connection", - activity_sender=mock_activity_sender, app_token=lambda: None, cloud=PUBLIC, ) @@ -209,22 +200,29 @@ async def test_updated_send_emits_activity_sent_event(self, activity_processor): mock_token.service_url = "https://service.url" mock_activity_event = ActivityEvent(body=core_activity, token=mock_token) - # Activity sender returns a SentActivity from send() + # ApiClient returns a SentActivity from send() sent = SentActivity(id="sent-1", activity_params=MessageActivityInput(text="hi")) - activity_processor.activity_sender.send = AsyncMock(return_value=sent) + activities = MagicMock() + activities.create = AsyncMock(return_value=sent) + activity_processor.http_client.clone.return_value = activity_processor.http_client + with patch("microsoft_teams.apps.app_process.ApiClient") as mock_api_client: + mock_context_api = MagicMock() + mock_context_api.users.token.get = AsyncMock(side_effect=Exception("no token")) + mock_context_api.conversations.activities.return_value = activities + mock_api_client.return_value = mock_context_api + + # Handler that calls ctx.send to exercise the updated_send wrapper + async def calling_handler(ctx): + await ctx.send("hi") + return None + + activity_processor.router.select_handlers = MagicMock(return_value=[calling_handler]) + activity_processor.event_manager = MagicMock() + activity_processor.event_manager.on_activity_response = AsyncMock() + activity_processor.event_manager.on_activity_sent = AsyncMock() + activity_processor.event_manager.on_error = AsyncMock() - # Handler that calls ctx.send to exercise the updated_send wrapper - async def calling_handler(ctx): - await ctx.send("hi") - return None - - activity_processor.router.select_handlers = MagicMock(return_value=[calling_handler]) - activity_processor.event_manager = MagicMock() - activity_processor.event_manager.on_activity_response = AsyncMock() - activity_processor.event_manager.on_activity_sent = AsyncMock() - activity_processor.event_manager.on_error = AsyncMock() - - await activity_processor.process_activity([], mock_activity_event) + await activity_processor.process_activity([], mock_activity_event) activity_processor.event_manager.on_activity_sent.assert_called_once() @@ -248,24 +246,26 @@ async def test_stream_handlers_emit_activity_sent_events(self, activity_processo mock_token.service_url = "https://service.url" mock_activity_event = ActivityEvent(body=core_activity, token=mock_token) - mock_stream = activity_processor.activity_sender.create_stream.return_value - activity_processor.router.select_handlers = MagicMock(return_value=[]) activity_processor.event_manager = MagicMock() activity_processor.event_manager.on_activity_response = AsyncMock() activity_processor.event_manager.on_activity_sent = AsyncMock() activity_processor.event_manager.on_error = AsyncMock() - await activity_processor.process_activity([], mock_activity_event) + with patch("microsoft_teams.apps.routing.activity_context.HttpStream") as mock_stream_class: + mock_stream = mock_stream_class.return_value + mock_stream.close = AsyncMock() + + await activity_processor.process_activity([], mock_activity_event) - # Stream's on_chunk and on_close were registered with the inner handlers. - # Invoke them to exercise their bodies. - chunk_handler = mock_stream.on_chunk.call_args[0][0] - close_handler = mock_stream.on_close.call_args[0][0] + # Stream's on_chunk and on_close were registered with the inner handlers. + # Invoke them to exercise their bodies. + chunk_handler = mock_stream.on_chunk.call_args[0][0] + close_handler = mock_stream.on_close.call_args[0][0] - sent = SentActivity(id="chunk-1", activity_params=MessageActivityInput(text="chunk")) - await chunk_handler(sent) - await close_handler(sent) + sent = SentActivity(id="chunk-1", activity_params=MessageActivityInput(text="chunk")) + await chunk_handler(sent) + await close_handler(sent) assert activity_processor.event_manager.on_activity_sent.call_count == 2 diff --git a/packages/apps/tests/test_function_context.py b/packages/apps/tests/test_function_context.py index 66d153ab3..af091023d 100644 --- a/packages/apps/tests/test_function_context.py +++ b/packages/apps/tests/test_function_context.py @@ -7,9 +7,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from microsoft_teams.api import ApiClient +from microsoft_teams.api import ApiClient, SentActivity from microsoft_teams.api.activities.message.message import MessageActivityInput -from microsoft_teams.api.models.conversation.conversation_reference import ConversationReference from microsoft_teams.apps import FunctionContext from microsoft_teams.cards import AdaptiveCard @@ -26,30 +25,29 @@ def mock_api(self): mock_conversations = MagicMock() mock_conversations.create = AsyncMock(return_value=MagicMock(id="new-conv")) mock_conversations.members_client.get_by_id = AsyncMock(return_value=True) + mock_activities = MagicMock() + mock_activities.create = AsyncMock( + return_value=SentActivity(id="sent-activity", activity_params=MessageActivityInput(text="sent")) + ) + mock_activities.update = AsyncMock( + return_value=SentActivity(id="updated-activity", activity_params=MessageActivityInput(text="updated")) + ) + mock_conversations.activities.return_value = mock_activities api.conversations = mock_conversations return api - @pytest.fixture - def mock_http(self): - """Create a mock activity sender.""" - http = MagicMock() - http.send = AsyncMock() - http.send.return_value = "sent-activity" - return http - @pytest.fixture def mock_logger(self): """Create a mock Logger.""" return MagicMock() @pytest.fixture - def function_context(self, mock_api: ApiClient, mock_http: Any) -> FunctionContext[Any]: + def function_context(self, mock_api: ApiClient) -> FunctionContext[Any]: ctx: FunctionContext[Any] = FunctionContext( id="bot-123", name="Test Bot", api=mock_api, - activity_sender=mock_http, data={"some": "payload"}, app_session_id="dummy-session", tenant_id="tenant-789", @@ -64,51 +62,68 @@ def function_context(self, mock_api: ApiClient, mock_http: Any) -> FunctionConte async def test_send_string_activity( self, function_context: FunctionContext[Any], - mock_http: Any, ) -> None: """Test sending a string message.""" result = await function_context.send("Hello world") - assert result == "sent-activity" + assert result is not None + assert result.id == "sent-activity" - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Hello world" - - assert isinstance(conversation_ref, ConversationReference) - assert conversation_ref.conversation.id == "conv-123" + assert sent_activity.from_.id == "bot-123" + assert sent_activity.conversation.id == "conv-123" + function_context.api.conversations.activities.assert_called_once_with("conv-123") async def test_send_adaptive_card( self, function_context: FunctionContext[Any], - mock_http: Any, ) -> None: """Test sending an AdaptiveCard message.""" card = AdaptiveCard(schema="1.0") result = await function_context.send(card) - assert result == "sent-activity" + assert result is not None + assert result.id == "sent-activity" - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.attachments[0].content == card - assert conversation_ref.conversation.id == "conv-123" + function_context.api.conversations.activities.assert_called_once_with("conv-123") - async def test_send_creates_conversation_if_none( - self, function_context: FunctionContext[Any], mock_http: Any - ) -> None: + async def test_send_creates_conversation_if_none(self, function_context: FunctionContext[Any]) -> None: """Test send() creates a conversation when _resolved_conversation_id is None.""" function_context.chat_id = None - mock_http.send.return_value = "sent-new-conv" + function_context.api.conversations.activities.return_value.create.return_value = SentActivity( + id="sent-new-conv", activity_params=MessageActivityInput(text="sent") + ) result = await function_context.send("Hello new conversation") - assert result == "sent-new-conv" + assert result is not None + assert result.id == "sent-new-conv" # Ensure conversation was created assert function_context.api.conversations.create.call_count == 1 # type: ignore - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == "Hello new conversation" - assert conversation_ref.conversation.id == "new-conv" + function_context.api.conversations.activities.assert_called_with("new-conv") + + async def test_send_existing_activity_updates(self, function_context: FunctionContext[Any]) -> None: + activity = MessageActivityInput(text="Updated message") + activity.id = "existing-msg-id" + + result = await function_context.send(activity) + + assert result is not None + assert result.id == "updated-activity" + function_context.api.conversations.activities.return_value.update.assert_called_once_with( + "existing-msg-id", + activity, + service_url="https://test.service.url", + agentic_identity=None, + ) + function_context.api.conversations.activities.return_value.create.assert_not_called() diff --git a/packages/apps/tests/test_optional_graph_dependencies.py b/packages/apps/tests/test_optional_graph_dependencies.py index 2045e88dd..b01c56907 100644 --- a/packages/apps/tests/test_optional_graph_dependencies.py +++ b/packages/apps/tests/test_optional_graph_dependencies.py @@ -65,8 +65,6 @@ def _create_activity_context(self) -> ActivityContext[Any]: mock_storage = MagicMock() mock_api = MagicMock() mock_conversation_ref = MagicMock() - mock_activity_sender = MagicMock() - mock_activity_sender.create_stream.return_value = MagicMock() mock_app_token = MagicMock() # Provide an app token for graph access return ActivityContext( @@ -78,7 +76,6 @@ def _create_activity_context(self) -> ActivityContext[Any]: conversation_ref=mock_conversation_ref, is_signed_in=False, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=mock_app_token, # This is needed for app_graph to work cloud=PUBLIC, ) @@ -126,7 +123,6 @@ def test_user_graph_property_not_signed_in(self) -> None: conversation_ref=MagicMock(), is_signed_in=False, # Not signed in connection_name="test-connection", - activity_sender=MagicMock(), app_token=None, cloud=PUBLIC, ) @@ -146,7 +142,6 @@ def test_user_graph_property_no_token(self) -> None: conversation_ref=MagicMock(), is_signed_in=True, # Signed in but no token connection_name="test-connection", - activity_sender=MagicMock(), app_token=None, cloud=PUBLIC, ) @@ -166,7 +161,6 @@ def test_app_graph_property_no_token(self) -> None: conversation_ref=MagicMock(), is_signed_in=False, connection_name="test-connection", - activity_sender=MagicMock(), app_token=None, # No app token cloud=PUBLIC, ) diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py index 712888c1d..909b2fe32 100644 --- a/packages/apps/tests/test_quoted_reply.py +++ b/packages/apps/tests/test_quoted_reply.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from microsoft_teams.api import Account, MessageActivityInput, SentActivity +from microsoft_teams.api import Account, ConversationAccount, ConversationReference, MessageActivityInput, SentActivity from microsoft_teams.api.models.entity import QuotedReplyEntity from microsoft_teams.apps.routing.activity_context import ActivityContext @@ -36,19 +36,27 @@ def _create_activity_context( ) ) mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) - - mock_conversation_ref = MagicMock() + activities = MagicMock() + activities.create = mock_activity_sender.send + api = MagicMock() + api.conversations.activities.return_value = activities + + conversation_ref = ConversationReference( + bot=Account(id="bot-id", name="Test Bot"), + conversation=ConversationAccount(id="test-conversation"), + channel_id="msteams", + service_url="https://service.example", + ) return ActivityContext( activity=mock_activity, app_id="test-app-id", storage=MagicMock(), - api=MagicMock(), + api=api, user_token=None, - conversation_ref=mock_conversation_ref, + conversation_ref=conversation_ref, is_signed_in=False, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=MagicMock(), ) @@ -58,7 +66,7 @@ async def test_reply_stamps_quoted_reply_entity(self) -> None: ctx = self._create_activity_context(activity_id="msg-abc") await ctx.reply("Thanks!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -71,7 +79,7 @@ async def test_reply_prepends_placeholder(self) -> None: ctx = self._create_activity_context(activity_id="msg-abc") await ctx.reply("Thanks!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' Thanks!' @pytest.mark.asyncio @@ -81,7 +89,7 @@ async def test_reply_with_empty_text(self) -> None: activity = MessageActivityInput(text="") await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio @@ -91,7 +99,7 @@ async def test_reply_with_none_text(self) -> None: activity = MessageActivityInput() await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio @@ -100,7 +108,7 @@ async def test_reply_with_no_activity_id(self) -> None: ctx = self._create_activity_context(activity_id=None) await ctx.reply("Thanks!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == "Thanks!" assert sent_activity.entities is None @@ -114,7 +122,7 @@ async def test_reply_preserves_existing_entities(self) -> None: await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 2 assert sent_activity.entities[0] is existing_entity @@ -127,7 +135,7 @@ async def test_reply_with_activity_params(self) -> None: activity = MessageActivityInput(text="Hello world") await ctx.reply(activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' Hello world' @@ -148,19 +156,27 @@ def _create_activity_context(self) -> ActivityContext[Any]: ) ) mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) - - mock_conversation_ref = MagicMock() + activities = MagicMock() + activities.create = mock_activity_sender.send + api = MagicMock() + api.conversations.activities.return_value = activities + + conversation_ref = ConversationReference( + bot=Account(id="bot-id", name="Test Bot"), + conversation=ConversationAccount(id="test-conversation"), + channel_id="msteams", + service_url="https://service.example", + ) return ActivityContext( activity=mock_activity, app_id="test-app-id", storage=MagicMock(), - api=MagicMock(), + api=api, user_token=None, - conversation_ref=mock_conversation_ref, + conversation_ref=conversation_ref, is_signed_in=False, connection_name="test-connection", - activity_sender=mock_activity_sender, app_token=MagicMock(), ) @@ -170,7 +186,7 @@ async def test_quote_stamps_entity_with_message_id(self) -> None: ctx = self._create_activity_context() await ctx.quote("arbitrary-msg-id", "Quoting this!") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -183,7 +199,7 @@ async def test_quote_prepends_placeholder(self) -> None: ctx = self._create_activity_context() await ctx.quote("msg-xyz", "My reply text") - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' My reply text' @pytest.mark.asyncio @@ -193,7 +209,7 @@ async def test_quote_with_empty_text(self) -> None: activity = MessageActivityInput(text="") await ctx.quote("msg-xyz", activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio @@ -203,7 +219,7 @@ async def test_quote_with_activity_params(self) -> None: activity = MessageActivityInput(text="Hello world") await ctx.quote("msg-xyz", activity) - sent_activity = ctx._activity_sender.send.call_args[0][0] + sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0] assert sent_activity.text == ' Hello world' assert sent_activity.entities is not None assert len(sent_activity.entities) == 1