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